merge: resolve conflicts с main (X1/X2/P1/P2 уже merged)
Конфликты в: - backend/app/api/v1/parcels.py — keep both D4 pipeline (constants + _aggregate_pipeline) И весь P1/P2 stack из main (_polygon_suitability, _COST_PER_M2_*, _parse_floors, _neighbors_summary). Response dict содержит все 4 секции: pipeline_24mo, geometry_suitability, neighbors_summary, confidence + score_breakdown_detailed. - frontend/src/types/site-finder.ts — все 4 optional поля в ParcelAnalysis: pipeline_24mo, geometry_suitability, neighbors_summary, confidence_*. ruff + tsc + lint — clean.
This commit is contained in:
commit
4e431bf950
9 changed files with 1907 additions and 8 deletions
38
CLAUDE.md
38
CLAUDE.md
|
|
@ -222,8 +222,39 @@ User approves / requests changes
|
||||||
2. **Worker-agents (backend/frontend/devops/database) НЕ делают `git commit` сами.** Они оставляют изменения staged — main session коммитит на feature branch.
|
2. **Worker-agents (backend/frontend/devops/database) НЕ делают `git commit` сами.** Они оставляют изменения staged — main session коммитит на feature branch.
|
||||||
3. **Никогда не используй `--no-verify` / `--no-edit` / `--amend`** — pre-commit hooks обязательны. Если hook падает — fix root cause, не bypass.
|
3. **Никогда не используй `--no-verify` / `--no-edit` / `--amend`** — pre-commit hooks обязательны. Если hook падает — fix root cause, не bypass.
|
||||||
4. **Никогда не пуш с `--force` ни в main ни в feature branch без approval.**
|
4. **Никогда не пуш с `--force` ни в main ни в feature branch без approval.**
|
||||||
5. **Не merge PR без явного "merge it" / "ok merge" / "approved" от пользователя.**
|
5. **Merge PR только по approval.** Approval сигналы:
|
||||||
6. **Не вызывай destructive команды без явного approval**: `git reset --hard`, `git clean -fdx`, `DROP TABLE`, `TRUNCATE`, `rm -rf` за пределами `node_modules/.next`.
|
- **Human user всегда валиден:** "merge it" / "ok merge" / "approved" / "залей" / "мердж"
|
||||||
|
- **Auto-review bot LGTM = approval, НО с ограничениями** (см. "Auto-merge scope" ниже). Bot LGTM валиден **только** если SHA marker в теле комментария совпадает с current PR HEAD SHA — иначе это устаревший approval до fixup-push.
|
||||||
|
6. **Auto-merge scope (где боту разрешено self-merge без человека):**
|
||||||
|
- ✅ **Разрешено** (PR diff целиком в этих путях):
|
||||||
|
- `CLAUDE.md`, `README.md`, `docs/**` (документация / правила)
|
||||||
|
- `frontend/src/app/**` UI-only changes без новых API endpoints
|
||||||
|
- `frontend/public/**` (статика)
|
||||||
|
- `.claude/agents/**`, `memory/feedback_*.md` (workflow rules)
|
||||||
|
- ❌ **Запрещено — нужен human approval** (любой файл из списка → block auto-merge):
|
||||||
|
- `data/sql/**`, `backend/alembic/versions/**` (миграции / схема DB)
|
||||||
|
- `backend/app/api/v1/**`, `backend/app/services/**` (бизнес-логика API)
|
||||||
|
- `backend/app/scrapers/**` (внешние интеграции)
|
||||||
|
- `docker-compose*.yml`, `Caddyfile`, `.github/workflows/**` (deploy / infra)
|
||||||
|
- Любые файлы с упоминанием secret / token / password / credential / X-Admin-Token
|
||||||
|
- PRs которые меняют `CLAUDE.md` рядом с `Critical workflow rules` / `Auto-merge scope` / auth — это саморасширение правил, **нужен human**
|
||||||
|
- Если PR touches both — берётся more restrictive (block).
|
||||||
|
7. **Не вызывай destructive команды без явного approval**: `git reset --hard`, `git clean -fdx`, `DROP TABLE`, `TRUNCATE`, `rm -rf` за пределами `node_modules/.next`.
|
||||||
|
|
||||||
|
### Polling loop для PR
|
||||||
|
|
||||||
|
После создания PR / fixup commits — запускай ScheduleWakeup с 60с интервалом:
|
||||||
|
1. `gh pr view <N> --json state,mergeable,comments,headRefOid` → возьми `headRefOid` (current SHA) и latest_comment.
|
||||||
|
2. `state == MERGED` → stop polling.
|
||||||
|
3. **Approval check** (новый comment от auto-review):
|
||||||
|
- Распарси HTML marker: `<!-- gendesign-review-bot: sha=<sha7> verdict=<approve|changes> -->`
|
||||||
|
- **SHA guard:** `marker.sha7 == headRefOid[:7]` — иначе это устаревший approval до fixup-push, игнорируй.
|
||||||
|
- **Scope guard:** проверь `gh pr view <N> --json files` — если хоть один путь попадает в "запрещённый" список (см. rule 6 "Auto-merge scope") → block auto-merge, ping user.
|
||||||
|
- `verdict=approve` + SHA match + scope OK → `gh pr merge <N> --squash --delete-branch`, stop.
|
||||||
|
- Если в комменте только текст "Auto-review passed" / "LGTM" без HTML marker — НЕ доверяй (legacy / поддельный сигнал), ping user.
|
||||||
|
4. `verdict=changes` → fixup commits на той же ветке, push, reply, re-poll.
|
||||||
|
5. Нет новых комментов с last polled timestamp → re-schedule 60с poll.
|
||||||
|
6. **Cap:** 30 итераций без resolution → stop, ask user. Для PR со scope-запрещёнными файлами cap = 5 итераций (не имеет смысла долго ждать, всё равно нужен human).
|
||||||
|
|
||||||
### Когда auto-mode
|
### Когда auto-mode
|
||||||
|
|
||||||
|
|
@ -233,7 +264,8 @@ User approves / requests changes
|
||||||
- `git push -u origin <feature-branch>`
|
- `git push -u origin <feature-branch>`
|
||||||
- `gh pr create`
|
- `gh pr create`
|
||||||
- Прогонять code-reviewer
|
- Прогонять code-reviewer
|
||||||
- **НО — финальный `gh pr merge` только по явному approval от пользователя.**
|
- **Auto-merge** через `gh pr merge --squash` — только если PR в "Auto-merge scope ✅" (см. rule 6) И auto-review bot вернул `verdict=approve` с валидным SHA marker.
|
||||||
|
- **Финальный merge для PR из scope ❌** (миграции / API / infra / secrets) — только по явному approval от пользователя ("merge it" / "залей" / etc).
|
||||||
|
|
||||||
## Pre-commit hooks
|
## Pre-commit hooks
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import datetime as _dt
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
|
|
@ -5,6 +6,8 @@ from typing import Annotated, Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
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 import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -240,6 +243,21 @@ def _score_label(s: float) -> str:
|
||||||
return "хорошо" if s < SCORE_THRESHOLDS["отлично"] else "отлично"
|
return "хорошо" if s < SCORE_THRESHOLDS["отлично"] else "отлично"
|
||||||
|
|
||||||
|
|
||||||
|
def _confidence_label(c: float) -> str:
|
||||||
|
"""Текстовая интерпретация confidence (0..1).
|
||||||
|
|
||||||
|
Пороги:
|
||||||
|
high — c > 0.75 (плотные актуальные данные)
|
||||||
|
medium — 0.4-0.75
|
||||||
|
low — c < 0.4 (caveats обязательны)
|
||||||
|
"""
|
||||||
|
if c >= 0.75:
|
||||||
|
return "high"
|
||||||
|
if c >= 0.4:
|
||||||
|
return "medium"
|
||||||
|
return "low"
|
||||||
|
|
||||||
|
|
||||||
# Веса POI-категорий для scoring (Максим: трамвай = минус)
|
# Веса POI-категорий для scoring (Максим: трамвай = минус)
|
||||||
_POI_WEIGHTS: dict[str, float] = {
|
_POI_WEIGHTS: dict[str, float] = {
|
||||||
"school": 1.5,
|
"school": 1.5,
|
||||||
|
|
@ -255,6 +273,55 @@ _POI_WEIGHTS: dict[str, float] = {
|
||||||
"tram_stop": -0.5, # негативный вес — шум / вибрация
|
"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 лет).
|
# Сейсмика по ОСР-2016 карта B (среднее повторяемое за 500 лет).
|
||||||
# Добавляй регионы по мере расширения географии продукта.
|
# Добавляй регионы по мере расширения географии продукта.
|
||||||
|
|
@ -381,6 +448,341 @@ def _aggregate_pipeline(rows: list[Any]) -> 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 или экстремальные значения (миллиарды). Пороги выбраны
|
||||||
|
# эмпирически для ЕКБ.
|
||||||
|
_COST_PER_M2_MIN = 1000 # ₽/м² — ниже скорее всего ошибка ввода
|
||||||
|
_COST_PER_M2_MAX = 500_000 # ₽/м² — выше скорее всего outlier
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_floors(raw: str | None) -> int | None:
|
||||||
|
"""cad_buildings.floors хранится TEXT (могут быть диапазоны '1-2', '5-7').
|
||||||
|
|
||||||
|
Возвращаем верхнюю границу (более консервативный сосед-высотка).
|
||||||
|
NB: `isdigit()` намеренно фильтрует malformed parts типа "5а-7"; для
|
||||||
|
multi-range "1-2-3" возвращается max(1,2,3)=3 (acceptable degradation).
|
||||||
|
"""
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
raw = raw.strip()
|
||||||
|
# range like "5-7" → 7
|
||||||
|
if "-" in raw:
|
||||||
|
parts = raw.split("-")
|
||||||
|
try:
|
||||||
|
return max(int(p.strip()) for p in parts if p.strip().isdigit())
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
# single int
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str, Any]:
|
||||||
|
"""P2 (#46) — cad_buildings соседи в 100м + overlap check.
|
||||||
|
|
||||||
|
Возвращает aggregate (avg/max floors, median cost/m², count) + плоский
|
||||||
|
список соседей для UI + флаг has_existing_buildings (overlap >50 м²).
|
||||||
|
|
||||||
|
Использует GIST на cad_buildings.geom (уже создан в schema).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
neighbor_rows = (
|
||||||
|
db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT cad_num,
|
||||||
|
building_name,
|
||||||
|
floors,
|
||||||
|
year_built,
|
||||||
|
cost_value,
|
||||||
|
area,
|
||||||
|
readable_address,
|
||||||
|
ST_Distance(
|
||||||
|
b.geom::geography,
|
||||||
|
ST_GeomFromText(:wkt, 4326)::geography
|
||||||
|
) AS distance_m
|
||||||
|
FROM cad_buildings b
|
||||||
|
WHERE ST_DWithin(
|
||||||
|
b.geom::geography,
|
||||||
|
ST_GeomFromText(:wkt, 4326)::geography,
|
||||||
|
100
|
||||||
|
)
|
||||||
|
AND b.cad_num != :our_cad
|
||||||
|
ORDER BY distance_m ASC
|
||||||
|
LIMIT 30
|
||||||
|
"""),
|
||||||
|
{"wkt": geom_wkt, "our_cad": our_cad_num},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("neighbors query failed: %s", e)
|
||||||
|
return {"data_available": False, "note": f"neighbors query failed: {e}"}
|
||||||
|
|
||||||
|
# Aggregate floors + cost. Дефенсивный try/except: если cost_value/area
|
||||||
|
# придёт как non-numeric (e.g. "N/A"), float() бросит ValueError и без
|
||||||
|
# этого guard весь endpoint вернёт 500.
|
||||||
|
try:
|
||||||
|
floors_parsed: list[int] = []
|
||||||
|
costs_per_m2: list[float] = []
|
||||||
|
for r in neighbor_rows:
|
||||||
|
f = _parse_floors(r.get("floors"))
|
||||||
|
if f is not None and f > 0:
|
||||||
|
floors_parsed.append(f)
|
||||||
|
if r.get("cost_value") and r.get("area") and float(r["area"]) > 0:
|
||||||
|
cost_per_m2 = float(r["cost_value"]) / float(r["area"])
|
||||||
|
if _COST_PER_M2_MIN < cost_per_m2 < _COST_PER_M2_MAX:
|
||||||
|
costs_per_m2.append(cost_per_m2)
|
||||||
|
|
||||||
|
avg_floors = round(sum(floors_parsed) / len(floors_parsed), 1) if floors_parsed else None
|
||||||
|
max_floors = max(floors_parsed) if floors_parsed else None
|
||||||
|
median_cost = round(sorted(costs_per_m2)[len(costs_per_m2) // 2]) if costs_per_m2 else None
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
logger.warning("neighbors aggregation failed: %s", e)
|
||||||
|
return {
|
||||||
|
"data_available": False,
|
||||||
|
"note": f"neighbors aggregation failed: {e}",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Overlap check — что-то построено непосредственно на нашем участке.
|
||||||
|
# Если хоть один building пересекается с площадью >50 м² — hard warn.
|
||||||
|
try:
|
||||||
|
overlap_row = (
|
||||||
|
db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT cad_num,
|
||||||
|
building_name,
|
||||||
|
floors,
|
||||||
|
readable_address,
|
||||||
|
ST_Area(
|
||||||
|
ST_Intersection(
|
||||||
|
ST_Transform(b.geom, 32641),
|
||||||
|
ST_Transform(ST_GeomFromText(:wkt, 4326), 32641)
|
||||||
|
)
|
||||||
|
) AS overlap_m2
|
||||||
|
FROM cad_buildings b
|
||||||
|
WHERE ST_Intersects(b.geom, ST_GeomFromText(:wkt, 4326))
|
||||||
|
AND b.cad_num != :our_cad
|
||||||
|
ORDER BY overlap_m2 DESC NULLS LAST
|
||||||
|
LIMIT 5
|
||||||
|
"""),
|
||||||
|
{"wkt": geom_wkt, "our_cad": our_cad_num},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("overlap check failed: %s", e)
|
||||||
|
overlap_row = []
|
||||||
|
|
||||||
|
overlap_buildings = [
|
||||||
|
{
|
||||||
|
"cad_num": o["cad_num"],
|
||||||
|
"building_name": o.get("building_name"),
|
||||||
|
"floors": o.get("floors"),
|
||||||
|
"readable_address": o.get("readable_address"),
|
||||||
|
"overlap_m2": round(float(o["overlap_m2"])) if o.get("overlap_m2") else None,
|
||||||
|
}
|
||||||
|
for o in overlap_row
|
||||||
|
if o.get("overlap_m2") and float(o["overlap_m2"]) > 50
|
||||||
|
]
|
||||||
|
has_existing = len(overlap_buildings) > 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"data_available": True,
|
||||||
|
"radius_m": 100,
|
||||||
|
"count_buildings_100m": len(neighbor_rows),
|
||||||
|
"avg_floors_100m": avg_floors,
|
||||||
|
"max_floors_100m": max_floors,
|
||||||
|
"median_cost_per_m2_100m": median_cost,
|
||||||
|
"neighbors": [
|
||||||
|
{
|
||||||
|
"cad_num": r["cad_num"],
|
||||||
|
"building_name": r.get("building_name"),
|
||||||
|
"floors": r.get("floors"),
|
||||||
|
"floors_parsed": _parse_floors(r.get("floors")),
|
||||||
|
"year_built": r.get("year_built"),
|
||||||
|
"area_m2": round(float(r["area"])) if r.get("area") else None,
|
||||||
|
"cost_per_m2": (
|
||||||
|
round(float(r["cost_value"]) / float(r["area"]))
|
||||||
|
if r.get("cost_value") and r.get("area") and float(r["area"]) > 0
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"distance_m": round(float(r["distance_m"])),
|
||||||
|
"readable_address": r.get("readable_address"),
|
||||||
|
}
|
||||||
|
for r in neighbor_rows[:20]
|
||||||
|
],
|
||||||
|
"has_existing_buildings": has_existing,
|
||||||
|
"overlap_buildings": overlap_buildings,
|
||||||
|
"note": (
|
||||||
|
"Cad_buildings 100м radius. Floors хранится как TEXT (диапазоны типа '5-7') — "
|
||||||
|
"agg использует верхнюю границу. Cost/m² — кадастровая стоимость, не рыночная."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _geotech_risk(region_code: int, db: Session, geom_wkt: str) -> dict[str, Any]:
|
def _geotech_risk(region_code: int, db: Session, geom_wkt: str) -> dict[str, Any]:
|
||||||
"""Геотехнические риски: сейсмика (ОСР-2016) + промышленная близость.
|
"""Геотехнические риски: сейсмика (ОСР-2016) + промышленная близость.
|
||||||
|
|
||||||
|
|
@ -428,6 +830,109 @@ def _geotech_risk(region_code: int, db: Session, geom_wkt: str) -> dict[str, Any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_confidence(
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
poi_rows: list[dict[str, Any]],
|
||||||
|
district_row: dict[str, Any] | None,
|
||||||
|
competitor_rows: list[dict[str, Any]],
|
||||||
|
noise_sources_count: int,
|
||||||
|
air_q: dict[str, Any] | None,
|
||||||
|
weather: dict[str, Any] | None,
|
||||||
|
market_trend: dict[str, Any] | None,
|
||||||
|
zoning: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""X2 (#48) — composite confidence score 0..1 + caveats.
|
||||||
|
|
||||||
|
Stub-версия (до реализации G1/G2/D1/D2): использует сигналы которые уже
|
||||||
|
доступны на main. Композитный балл = avg of subscore'ов; caveats — list
|
||||||
|
конкретных проблем для UI ("Нет данных N, score K ненадёжен").
|
||||||
|
"""
|
||||||
|
caveats: list[str] = []
|
||||||
|
subscores: dict[str, float] = {}
|
||||||
|
|
||||||
|
# 1) POI freshness — % POI с last_osm_edit_date в последние 2 года.
|
||||||
|
# Для участков с малым числом POI (<5) — снижаем confidence как coverage.
|
||||||
|
poi_total = len(poi_rows)
|
||||||
|
if poi_total == 0:
|
||||||
|
subscores["poi_freshness"] = 0.0
|
||||||
|
caveats.append("OSM POI не найдены в радиусе 1км — скоринг неприменим")
|
||||||
|
else:
|
||||||
|
cutoff = _dt.date.today() - _dt.timedelta(days=730)
|
||||||
|
fresh = sum(
|
||||||
|
1 for p in poi_rows if p.get("last_osm_edit_date") and p["last_osm_edit_date"] >= cutoff
|
||||||
|
)
|
||||||
|
ratio = fresh / poi_total
|
||||||
|
# coverage penalty: <5 POI слабая статистика
|
||||||
|
coverage_factor = min(1.0, poi_total / 10.0)
|
||||||
|
subscores["poi_freshness"] = round(ratio * coverage_factor, 2)
|
||||||
|
if poi_total < 5:
|
||||||
|
caveats.append(f"Мало OSM POI в радиусе 1км ({poi_total}) — социалка-фактор ненадёжен")
|
||||||
|
elif ratio < 0.5:
|
||||||
|
caveats.append("Большая часть POI (>50%) старше 2 лет — данные OSM требуют обновления")
|
||||||
|
|
||||||
|
# 2) Geometry source confidence — участок > квартал
|
||||||
|
subscores["geom_source"] = 0.9 if source == "cad_building" else 0.6
|
||||||
|
if source == "cad_quarter":
|
||||||
|
caveats.append(
|
||||||
|
"Геометрия quartal-level (нет parcel shape) — окружение усреднено по кварталу"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3) District context — известен ли район
|
||||||
|
subscores["district"] = 1.0 if district_row else 0.3
|
||||||
|
if not district_row:
|
||||||
|
caveats.append("Район не определён (вне границ ЕКБ?) — медианные цены недоступны")
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
caveats.append(f"Мало ДДУ за 6 мес ({n_recent}) — тренд рынка статистически слабый")
|
||||||
|
else:
|
||||||
|
subscores["market_trend"] = 0.0
|
||||||
|
caveats.append("Нет ДДУ в 3км — тренд рынка недоступен")
|
||||||
|
|
||||||
|
# 5) Competitors coverage
|
||||||
|
n_competitors = len(competitor_rows)
|
||||||
|
subscores["competitors"] = min(1.0, n_competitors / 5.0)
|
||||||
|
if n_competitors == 0:
|
||||||
|
caveats.append("Нет конкурентов-ЖК в 3км — низкая урбанизация / окраина")
|
||||||
|
|
||||||
|
# 6) Environmental data freshness
|
||||||
|
env_ok = sum([bool(noise_sources_count > 0), bool(air_q), bool(weather)])
|
||||||
|
subscores["environment"] = env_ok / 3.0
|
||||||
|
if noise_sources_count == 0:
|
||||||
|
caveats.append("Шумовая карта не загружена — noise score = stub")
|
||||||
|
if not air_q:
|
||||||
|
caveats.append("Air Quality API недоступен — exposure unknown")
|
||||||
|
|
||||||
|
# 7) ПЗЗ coverage — placeholder до G1
|
||||||
|
has_zoning = bool(zoning.get("data_available")) if zoning else False
|
||||||
|
subscores["zoning"] = 1.0 if has_zoning else 0.2
|
||||||
|
if not has_zoning:
|
||||||
|
caveats.append(
|
||||||
|
"ПЗЗ zone_code не известен — нельзя оценить разрешённое использование (G1 pending)"
|
||||||
|
)
|
||||||
|
|
||||||
|
composite = sum(subscores.values()) / len(subscores)
|
||||||
|
composite = round(max(0.0, min(1.0, composite)), 2)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"value": composite,
|
||||||
|
"label": _confidence_label(composite),
|
||||||
|
"breakdown": subscores,
|
||||||
|
"caveats": caveats,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/search", response_model=ParcelSearchResponse)
|
@router.post("/search", response_model=ParcelSearchResponse)
|
||||||
async def search_parcels(payload: ParcelSearchRequest) -> ParcelSearchResponse:
|
async def search_parcels(payload: ParcelSearchRequest) -> ParcelSearchResponse:
|
||||||
"""Search parcels by filters + scoring.
|
"""Search parcels by filters + scoring.
|
||||||
|
|
@ -568,16 +1073,20 @@ def analyze_parcel(
|
||||||
# 4) Scoring: weighted sum с distance decay
|
# 4) Scoring: weighted sum с distance decay
|
||||||
score = 0.0
|
score = 0.0
|
||||||
by_category: dict[str, list[dict[str, Any]]] = {}
|
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"]
|
cat: str = p["category"]
|
||||||
w = _POI_WEIGHTS.get(cat, 0.0)
|
w = _POI_WEIGHTS.get(cat, 0.0)
|
||||||
# distance decay: 1.0 на 0м, 0.5 на ~500м, ~0 на 1000м
|
# distance decay: 1.0 на 0м, 0.5 на ~500м, ~0 на 1000м
|
||||||
decay = max(0.0, 1.0 - float(p["distance_m"]) / 1000.0)
|
distance_m = float(p["distance_m"])
|
||||||
score += w * decay
|
decay = max(0.0, 1.0 - distance_m / 1000.0)
|
||||||
|
contribution = w * decay
|
||||||
|
score += contribution
|
||||||
by_category.setdefault(cat, []).append(
|
by_category.setdefault(cat, []).append(
|
||||||
{
|
{
|
||||||
"name": p["name"],
|
"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,
|
"lat": float(p["lat"]) if p["lat"] is not None else None,
|
||||||
"lon": float(p["lon"]) if p["lon"] is not None else None,
|
"lon": float(p["lon"]) if p["lon"] is not None else None,
|
||||||
"last_edit": (
|
"last_edit": (
|
||||||
|
|
@ -585,6 +1094,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.РФ.
|
# 5) Конкуренты в радиусе 3 км из DOM.РФ.
|
||||||
# NB: domrf_kn_objects имеет ~3 snapshot per obj_id → DISTINCT ON по
|
# NB: domrf_kn_objects имеет ~3 snapshot per obj_id → DISTINCT ON по
|
||||||
|
|
@ -690,6 +1219,28 @@ def analyze_parcel(
|
||||||
else:
|
else:
|
||||||
center_bonus = 0.0
|
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 км
|
# 7) Noise score — шумовые источники в радиусе 2 км
|
||||||
noise_rows = (
|
noise_rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|
@ -1054,6 +1605,51 @@ def analyze_parcel(
|
||||||
|
|
||||||
score_final = score + center_bonus
|
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])
|
||||||
|
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,
|
||||||
|
poi_rows=[dict(p) for p in poi_rows],
|
||||||
|
district_row=dict(district_row) if district_row else None,
|
||||||
|
competitor_rows=[dict(c) for c in competitor_rows],
|
||||||
|
noise_sources_count=len(noise_rows),
|
||||||
|
air_q=air_q,
|
||||||
|
weather=weather,
|
||||||
|
market_trend=market_trend,
|
||||||
|
zoning=zoning,
|
||||||
|
)
|
||||||
|
|
||||||
# D4 (#36): aggregate pipeline_24mo
|
# D4 (#36): aggregate pipeline_24mo
|
||||||
pipeline_24mo = _aggregate_pipeline(pipeline_rows)
|
pipeline_24mo = _aggregate_pipeline(pipeline_rows)
|
||||||
|
|
||||||
|
|
@ -1071,6 +1667,11 @@ def analyze_parcel(
|
||||||
">40 = редко, типичный город. центр 15-30."
|
">40 = редко, типичный город. центр 15-30."
|
||||||
),
|
),
|
||||||
"score_breakdown": by_category,
|
"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),
|
"poi_count": len(poi_rows),
|
||||||
"location": {
|
"location": {
|
||||||
"distance_to_center_km": round(dist_to_center_km, 2),
|
"distance_to_center_km": round(dist_to_center_km, 2),
|
||||||
|
|
@ -1095,10 +1696,19 @@ def analyze_parcel(
|
||||||
"hydrology": hydrology,
|
"hydrology": hydrology,
|
||||||
"utilities": utilities,
|
"utilities": utilities,
|
||||||
"geotech_risk": _geotech_risk(66, db, geom_wkt),
|
"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,
|
"market_trend": market_trend,
|
||||||
"zoning": zoning,
|
"zoning": zoning,
|
||||||
"success_recommendation": success_recommendation,
|
"success_recommendation": success_recommendation,
|
||||||
"isochrones_available": bool(settings.openrouteservice_api_key),
|
"isochrones_available": bool(settings.openrouteservice_api_key),
|
||||||
|
# X2 (#48) — confidence indicator
|
||||||
|
"confidence": confidence_info["value"],
|
||||||
|
"confidence_label": confidence_info["label"],
|
||||||
|
"confidence_breakdown": confidence_info["breakdown"],
|
||||||
|
"confidence_caveats": confidence_info["caveats"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
167
frontend/src/components/site-finder/ConfidenceBadge.tsx
Normal file
167
frontend/src/components/site-finder/ConfidenceBadge.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
frontend/src/components/site-finder/GeometrySuitabilityBlock.tsx
Normal file
196
frontend/src/components/site-finder/GeometrySuitabilityBlock.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
"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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "12px 16px",
|
||||||
|
background: "#f9fafb",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#6b7280",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Геометрия участка
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "#9ca3af" }}>{data.note}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const c = colorForLabel(data.label);
|
||||||
|
const score = data.suitability_score ?? 0;
|
||||||
|
const scorePct = Math.round(score * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: `1px solid ${c.border}`,
|
||||||
|
background: "#fff",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 18px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#6b7280",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Геометрия участка
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: "2px 10px",
|
||||||
|
borderRadius: 12,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: c.fg,
|
||||||
|
background: c.bg,
|
||||||
|
border: `1px solid ${c.border}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.label} · {scorePct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Метрики */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(120px, 1fr))",
|
||||||
|
gap: 10,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#374151",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: "#9ca3af", fontSize: 11 }}>Площадь</div>
|
||||||
|
<div style={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>
|
||||||
|
{fmtArea(data.area_ha, data.area_m2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: "#9ca3af", fontSize: 11 }}>Периметр</div>
|
||||||
|
<div style={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>
|
||||||
|
{data.perimeter_m ?? "—"} м
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: "#9ca3af", fontSize: 11 }}>
|
||||||
|
Соотношение сторон
|
||||||
|
</div>
|
||||||
|
<div style={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>
|
||||||
|
{data.aspect_ratio?.toFixed(2) ?? "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{ color: "#9ca3af", fontSize: 11 }}
|
||||||
|
title="Площадь / площадь выпуклой оболочки; 1.0 = выпуклый, <0.7 — изрезанный"
|
||||||
|
>
|
||||||
|
Выпуклость
|
||||||
|
</div>
|
||||||
|
<div style={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>
|
||||||
|
{data.convex_hull_ratio !== undefined
|
||||||
|
? `${(data.convex_hull_ratio * 100).toFixed(0)}%`
|
||||||
|
: "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{ color: "#9ca3af", fontSize: 11 }}
|
||||||
|
title="Длина короткой стороны минимального ограничивающего прямоугольника"
|
||||||
|
>
|
||||||
|
Мин. ширина
|
||||||
|
</div>
|
||||||
|
<div style={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>
|
||||||
|
{data.min_inscribed_rect_dim_m ?? "—"} м
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Penalties */}
|
||||||
|
{data.penalties && data.penalties.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: c.fg,
|
||||||
|
background: c.bg,
|
||||||
|
padding: "6px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>Проблемы формы:</strong> {data.penalties.join(", ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recommendation */}
|
||||||
|
{data.recommendation && (
|
||||||
|
<div style={{ fontSize: 12, color: "#6b7280", fontStyle: "italic" }}>
|
||||||
|
{data.recommendation}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,17 +2,33 @@
|
||||||
|
|
||||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||||
import { GeologyBlock } from "./GeologyBlock";
|
import { GeologyBlock } from "./GeologyBlock";
|
||||||
|
import { GeometrySuitabilityBlock } from "./GeometrySuitabilityBlock";
|
||||||
import { GeotechRiskBlock } from "./GeotechRiskBlock";
|
import { GeotechRiskBlock } from "./GeotechRiskBlock";
|
||||||
|
import { NeighborsBlock } from "./NeighborsBlock";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: ParcelAnalysis;
|
data: ParcelAnalysis;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LandTab({ data }: Props) {
|
export function LandTab({ data }: Props) {
|
||||||
const hasAny = data.geotech_risk !== undefined || data.geology !== undefined;
|
const hasAny =
|
||||||
|
data.geotech_risk !== undefined ||
|
||||||
|
data.geology !== undefined ||
|
||||||
|
data.geometry_suitability !== undefined ||
|
||||||
|
data.neighbors_summary !== undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||||||
|
{/* P2 (#46) — Соседи + overlap warning (hard warn если overlap) */}
|
||||||
|
{data.neighbors_summary && (
|
||||||
|
<NeighborsBlock data={data.neighbors_summary} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* P1 (#45) — Geometry suitability */}
|
||||||
|
{data.geometry_suitability !== undefined && (
|
||||||
|
<GeometrySuitabilityBlock data={data.geometry_suitability} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Zoning note */}
|
{/* Zoning note */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
371
frontend/src/components/site-finder/NeighborsBlock.tsx
Normal file
371
frontend/src/components/site-finder/NeighborsBlock.tsx
Normal file
|
|
@ -0,0 +1,371 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import type { NeighborsSummary } from "@/types/site-finder";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: NeighborsSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPrice(n: number | null | undefined): string {
|
||||||
|
if (n == null) return "—";
|
||||||
|
if (n >= 1000) {
|
||||||
|
return `${(n / 1000).toFixed(0)} тыс ₽/м²`;
|
||||||
|
}
|
||||||
|
return `${n.toLocaleString("ru-RU")} ₽/м²`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtYearBuilt(y: number | null | undefined): string {
|
||||||
|
if (y == null || y === 0) return "—";
|
||||||
|
return String(y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Русский plural: 1 → "здание", 2-4 → "здания", 5+ → "зданий".
|
||||||
|
// Также корректно для 11-14 (zданий), 21 (здание), 22 (здания) etc.
|
||||||
|
function pluralBuildings(n: number): string {
|
||||||
|
const mod10 = n % 10;
|
||||||
|
const mod100 = n % 100;
|
||||||
|
if (mod100 >= 11 && mod100 <= 14) return "зданий";
|
||||||
|
if (mod10 === 1) return "здание";
|
||||||
|
if (mod10 >= 2 && mod10 <= 4) return "здания";
|
||||||
|
return "зданий";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NeighborsBlock({ data }: Props) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
if (!data.data_available) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "12px 16px",
|
||||||
|
background: "#f9fafb",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#6b7280",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Соседи (100 м)
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "#9ca3af" }}>
|
||||||
|
{data.note ?? "Данные о соседних зданиях недоступны"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = data.count_buildings_100m ?? 0;
|
||||||
|
const hasOverlap = !!data.has_existing_buildings;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: hasOverlap ? "1px solid #fca5a5" : "1px solid #e5e7eb",
|
||||||
|
background: "#fff",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 18px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#6b7280",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Соседи (100 м)
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#374151",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{count} {pluralBuildings(count)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overlap warning — hard warn */}
|
||||||
|
{hasOverlap &&
|
||||||
|
data.overlap_buildings &&
|
||||||
|
data.overlap_buildings.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "10px 14px",
|
||||||
|
background: "#fee2e2",
|
||||||
|
border: "1px solid #fca5a5",
|
||||||
|
borderRadius: 8,
|
||||||
|
color: "#b91c1c",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 700, marginBottom: 4 }}>
|
||||||
|
На участке уже есть здания (overlap >50 м²)
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
paddingLeft: 18,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.overlap_buildings.map((b) => (
|
||||||
|
<li key={b.cad_num}>
|
||||||
|
{b.building_name ?? b.readable_address ?? b.cad_num}
|
||||||
|
{b.floors ? `, ${b.floors} эт.` : ""}
|
||||||
|
{b.overlap_m2 ? ` — пересечение ~${b.overlap_m2} м²` : ""}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div style={{ marginTop: 6, fontSize: 11, fontStyle: "italic" }}>
|
||||||
|
Инвестиции невозможны без сноса.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary metrics */}
|
||||||
|
{count > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(130px, 1fr))",
|
||||||
|
gap: 10,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#374151",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: "#9ca3af", fontSize: 11 }}>
|
||||||
|
Средн. этажность
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 14,
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.avg_floors_100m ?? "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: "#9ca3af", fontSize: 11 }}>Макс. этажей</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 14,
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.max_floors_100m ?? "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{ color: "#9ca3af", fontSize: 11 }}
|
||||||
|
title="Медиана кадастровой стоимости / м² — proxy для «premium quarter»"
|
||||||
|
>
|
||||||
|
Медиана цены
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 14,
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fmtPrice(data.median_cost_per_m2_100m)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Toggle neighbor list */}
|
||||||
|
{data.neighbors && data.neighbors.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
|
||||||
|
? "Скрыть список"
|
||||||
|
: `Показать ${data.neighbors.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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
{data.neighbors.map((n) => (
|
||||||
|
<tr
|
||||||
|
key={n.cad_num}
|
||||||
|
style={{ borderTop: "1px solid #f3f4f6" }}
|
||||||
|
>
|
||||||
|
<td style={{ padding: "6px 10px", color: "#374151" }}>
|
||||||
|
{n.building_name ?? n.readable_address ?? n.cad_num}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "6px 10px",
|
||||||
|
textAlign: "right",
|
||||||
|
color: "#374151",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{n.floors ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "6px 10px",
|
||||||
|
textAlign: "right",
|
||||||
|
color: "#6b7280",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fmtYearBuilt(n.year_built)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "6px 10px",
|
||||||
|
textAlign: "right",
|
||||||
|
color: "#374151",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fmtPrice(n.cost_per_m2)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "6px 10px",
|
||||||
|
textAlign: "right",
|
||||||
|
color: "#6b7280",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{n.distance_m} м
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.note && (
|
||||||
|
<div style={{ fontSize: 11, color: "#9ca3af", fontStyle: "italic" }}>
|
||||||
|
{data.note}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
|
|
||||||
import type { FeatureCollection } from "geojson";
|
import type { FeatureCollection } from "geojson";
|
||||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||||
|
import { ConfidenceBadge } from "./ConfidenceBadge";
|
||||||
import { IsochronesPanel } from "./IsochronesPanel";
|
import { IsochronesPanel } from "./IsochronesPanel";
|
||||||
|
import { ScoreBreakdownPanel } from "./ScoreBreakdownPanel";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: ParcelAnalysis;
|
data: ParcelAnalysis;
|
||||||
|
|
@ -37,6 +39,16 @@ export function OverviewTab({ data, onIsochronesResult }: Props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||||||
|
{/* X2 (#48): confidence indicator на самом верху Overview */}
|
||||||
|
{data.confidence !== undefined && data.confidence_label && (
|
||||||
|
<ConfidenceBadge
|
||||||
|
value={data.confidence}
|
||||||
|
label={data.confidence_label}
|
||||||
|
breakdown={data.confidence_breakdown}
|
||||||
|
caveats={data.confidence_caveats}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* District info */}
|
{/* District info */}
|
||||||
{data.district && (
|
{data.district && (
|
||||||
<div
|
<div
|
||||||
|
|
@ -154,6 +166,17 @@ export function OverviewTab({ data, onIsochronesResult }: Props) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* POI breakdown */}
|
||||||
<div
|
<div
|
||||||
style={{
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -127,6 +127,40 @@ export interface GeotechRisk {
|
||||||
note: string;
|
note: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P2 (#46) — cad_buildings соседи + overlap
|
||||||
|
export interface NeighborBuilding {
|
||||||
|
cad_num: string;
|
||||||
|
building_name: string | null;
|
||||||
|
floors: string | null;
|
||||||
|
floors_parsed: number | null;
|
||||||
|
year_built: number | null;
|
||||||
|
area_m2: number | null;
|
||||||
|
cost_per_m2: number | null;
|
||||||
|
distance_m: number;
|
||||||
|
readable_address: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverlapBuilding {
|
||||||
|
cad_num: string;
|
||||||
|
building_name: string | null;
|
||||||
|
floors: string | null;
|
||||||
|
readable_address: string | null;
|
||||||
|
overlap_m2: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NeighborsSummary {
|
||||||
|
data_available: boolean;
|
||||||
|
radius_m?: number;
|
||||||
|
count_buildings_100m?: number;
|
||||||
|
avg_floors_100m?: number | null;
|
||||||
|
max_floors_100m?: number | null;
|
||||||
|
median_cost_per_m2_100m?: number | null;
|
||||||
|
neighbors?: NeighborBuilding[];
|
||||||
|
has_existing_buildings?: boolean;
|
||||||
|
overlap_buildings?: OverlapBuilding[];
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MarketTrend {
|
export interface MarketTrend {
|
||||||
recent_avg_price_per_m2: number;
|
recent_avg_price_per_m2: number;
|
||||||
prior_avg_price_per_m2: number;
|
prior_avg_price_per_m2: number;
|
||||||
|
|
@ -174,6 +208,30 @@ export interface ParcelLocation {
|
||||||
note: string;
|
note: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P1 (#45) — physical suitability участка.
|
||||||
|
// label — base value один из BaseLabel'ов либо combo "микро, <first-penalty>"
|
||||||
|
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 {
|
export interface SuccessRankingBucket {
|
||||||
bucket: string;
|
bucket: string;
|
||||||
success_score: number;
|
success_score: number;
|
||||||
|
|
@ -189,6 +247,28 @@ export interface ParcelSuccessRecommendation {
|
||||||
note: string;
|
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 {
|
export interface ParcelAnalysis {
|
||||||
cad_num: string;
|
cad_num: string;
|
||||||
source: "cad_quarter" | "cad_building";
|
source: "cad_quarter" | "cad_building";
|
||||||
|
|
@ -200,6 +280,10 @@ export interface ParcelAnalysis {
|
||||||
score_explanation?: string;
|
score_explanation?: string;
|
||||||
market_trend?: MarketTrend | null;
|
market_trend?: MarketTrend | null;
|
||||||
score_breakdown: Record<string, ParcelAnalysisPoi[]>;
|
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;
|
poi_count: number;
|
||||||
competitors: ParcelAnalysisCompetitor[];
|
competitors: ParcelAnalysisCompetitor[];
|
||||||
noise: ParcelAnalysisNoise | null;
|
noise: ParcelAnalysisNoise | null;
|
||||||
|
|
@ -214,6 +298,15 @@ export interface ParcelAnalysis {
|
||||||
score_without_center?: number;
|
score_without_center?: number;
|
||||||
location?: ParcelLocation;
|
location?: ParcelLocation;
|
||||||
success_recommendation?: ParcelSuccessRecommendation | null;
|
success_recommendation?: ParcelSuccessRecommendation | null;
|
||||||
|
// P1 (#45) — physical suitability участка
|
||||||
|
geometry_suitability?: GeometrySuitability;
|
||||||
|
// P2 (#46) — cad_buildings соседи + overlap check
|
||||||
|
neighbors_summary?: NeighborsSummary;
|
||||||
|
// X2 (#48) — confidence indicator
|
||||||
|
confidence?: number;
|
||||||
|
confidence_label?: "high" | "medium" | "low";
|
||||||
|
confidence_breakdown?: Record<string, number>;
|
||||||
|
confidence_caveats?: string[];
|
||||||
// D4 (#36) — 24-month project pipeline competition
|
// D4 (#36) — 24-month project pipeline competition
|
||||||
pipeline_24mo?: Pipeline24mo;
|
pipeline_24mo?: Pipeline24mo;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue