feat(site-finder): v3.2 — noise + air quality + wind analytics
This commit is contained in:
parent
a2e8a49e6d
commit
1e9d32ee3c
7 changed files with 853 additions and 1 deletions
|
|
@ -367,6 +367,23 @@ def list_logs(
|
|||
# ── Objective ETL (Антоновский SQLite → PG) ─────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/noise-sync")
|
||||
def trigger_noise_sync(
|
||||
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Manual trigger для синхронизации OSM шумовых источников ЕКБ из Overpass API.
|
||||
|
||||
Обычно запускается еженедельно через beat (понед 3:30 МСК).
|
||||
Этот endpoint — для ad-hoc запуска (например после первого деплоя или
|
||||
после добавления миграции 84_osm_noise_sources_ekb).
|
||||
"""
|
||||
_check_token(x_admin_token)
|
||||
from app.workers.tasks.noise_sync import sync_osm_noise_sources_ekb
|
||||
|
||||
result = sync_osm_noise_sources_ekb.apply_async()
|
||||
return {"task_id": result.id, "queued_at": "now"}
|
||||
|
||||
|
||||
@router.post("/poi-sync")
|
||||
def trigger_poi_sync(
|
||||
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import json
|
||||
import logging
|
||||
import math
|
||||
from typing import Annotated, Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
|
@ -13,6 +15,99 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
router = APIRouter()
|
||||
|
||||
# Базовые уровни шума по типу источника (дБ на 10м) — источник: WHO Environmental Noise Guidelines
|
||||
NOISE_L_BASE: dict[str, float] = {
|
||||
"highway:motorway": 75.0,
|
||||
"highway:trunk": 75.0,
|
||||
"highway:primary": 70.0,
|
||||
"highway:secondary": 65.0,
|
||||
"highway:tertiary": 60.0,
|
||||
"highway:residential": 55.0,
|
||||
"railway": 72.0,
|
||||
"industrial": 65.0,
|
||||
"aerodrome": 70.0,
|
||||
}
|
||||
|
||||
|
||||
def _wind_label(deg: float) -> str:
|
||||
"""Перевести угол направления ветра (0-360) в 8-позиционную розу на русском."""
|
||||
rose = ["Север", "С-В", "Восток", "Ю-В", "Юг", "Ю-З", "Запад", "С-З"]
|
||||
idx = round(deg / 45) % 8
|
||||
return rose[idx]
|
||||
|
||||
|
||||
def _fetch_air_quality_sync(lat: float, lon: float) -> dict | None:
|
||||
"""Синхронный запрос к Open-Meteo Air Quality API.
|
||||
|
||||
Возвращает данные текущего часа (первый элемент hourly). None если API
|
||||
недоступен или вернул неожиданный формат.
|
||||
"""
|
||||
try:
|
||||
with httpx.Client(timeout=5) as c:
|
||||
r = c.get(
|
||||
"https://air-quality-api.open-meteo.com/v1/air-quality",
|
||||
params={
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"hourly": "pm2_5,pm10,nitrogen_dioxide",
|
||||
"forecast_days": 1,
|
||||
},
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
hourly = data.get("hourly", {})
|
||||
if not hourly.get("time"):
|
||||
return None
|
||||
return {
|
||||
"pm2_5": hourly["pm2_5"][0] if hourly.get("pm2_5") else None,
|
||||
"pm10": hourly["pm10"][0] if hourly.get("pm10") else None,
|
||||
"no2": hourly["nitrogen_dioxide"][0] if hourly.get("nitrogen_dioxide") else None,
|
||||
"ts": hourly["time"][0],
|
||||
"source": "open-meteo",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("air quality fetch failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_wind_sync(lat: float, lon: float) -> dict | None:
|
||||
"""Синхронный запрос к Open-Meteo Forecast API для данных о ветре.
|
||||
|
||||
Возвращает доминирующее направление ветра (circular mean по 7 дням) и
|
||||
максимальную скорость. None если API недоступен.
|
||||
"""
|
||||
try:
|
||||
with httpx.Client(timeout=5) as c:
|
||||
r = c.get(
|
||||
"https://api.open-meteo.com/v1/forecast",
|
||||
params={
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"daily": "winddirection_10m_dominant,windspeed_10m_max",
|
||||
"forecast_days": 7,
|
||||
},
|
||||
)
|
||||
r.raise_for_status()
|
||||
daily = r.json().get("daily", {})
|
||||
dirs = daily.get("winddirection_10m_dominant") or []
|
||||
speeds = daily.get("windspeed_10m_max") or []
|
||||
if not dirs:
|
||||
return None
|
||||
# Circular mean направления ветра (vector sum) — избегает jump 359→1
|
||||
x = sum(math.cos(math.radians(d)) for d in dirs)
|
||||
y = sum(math.sin(math.radians(d)) for d in dirs)
|
||||
dominant = (math.degrees(math.atan2(y, x)) + 360) % 360
|
||||
return {
|
||||
"dominant_direction_deg": round(dominant),
|
||||
"dominant_direction_label": _wind_label(dominant),
|
||||
"max_speed_m_s": round(max(speeds), 1) if speeds else None,
|
||||
"forecast_days": len(dirs),
|
||||
"source": "open-meteo",
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# Веса POI-категорий для scoring (Максим: трамвай = минус)
|
||||
_POI_WEIGHTS: dict[str, float] = {
|
||||
"school": 1.5,
|
||||
|
|
@ -224,6 +319,81 @@ def analyze_parcel(
|
|||
.all()
|
||||
)
|
||||
|
||||
# 6) Centroid координаты для внешних API (air quality / wind)
|
||||
centroid_row = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT ST_X(ST_Centroid(ST_GeomFromText(:wkt, 4326))) AS lon,
|
||||
ST_Y(ST_Centroid(ST_GeomFromText(:wkt, 4326))) AS lat
|
||||
"""),
|
||||
{"wkt": geom_wkt},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
centroid_lat: float = float(centroid_row["lat"]) if centroid_row else 56.838
|
||||
centroid_lon: float = float(centroid_row["lon"]) if centroid_row else 60.605
|
||||
|
||||
# 7) Noise score — шумовые источники в радиусе 2 км
|
||||
noise_rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT source_type, road_class, name,
|
||||
ST_Distance(
|
||||
n.geom::geography,
|
||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||||
) AS distance_m
|
||||
FROM osm_noise_sources_ekb n
|
||||
WHERE ST_DWithin(
|
||||
n.geom::geography,
|
||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||||
2000
|
||||
)
|
||||
ORDER BY distance_m ASC
|
||||
LIMIT 30
|
||||
"""),
|
||||
{"wkt": geom_wkt},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
noise_db_max = 0.0
|
||||
nearby_noise_sources: list[dict[str, Any]] = []
|
||||
for nr in noise_rows:
|
||||
src = nr["source_type"]
|
||||
key = f"{src}:{nr['road_class']}" if src == "highway" and nr["road_class"] else src
|
||||
base_db = NOISE_L_BASE.get(key, 50.0)
|
||||
d = max(float(nr["distance_m"]), 10.0)
|
||||
noise_db = base_db - 20.0 * math.log10(d / 10.0)
|
||||
noise_db_max = max(noise_db_max, noise_db)
|
||||
if noise_db >= 50.0: # WHO порог дискомфорта
|
||||
nearby_noise_sources.append(
|
||||
{
|
||||
"source_type": src,
|
||||
"road_class": nr["road_class"],
|
||||
"name": nr["name"],
|
||||
"distance_m": round(d),
|
||||
"estimated_db": round(noise_db, 1),
|
||||
}
|
||||
)
|
||||
|
||||
# noise_score: 0..1, чем тише тем лучше. 45 dB=1.0 (тихо), 75 dB=0.0 (шумно).
|
||||
noise_score = max(0.0, min(1.0, (75.0 - noise_db_max) / 30.0))
|
||||
|
||||
if noise_db_max < 50.0:
|
||||
noise_level = "тихо"
|
||||
elif noise_db_max < 65.0:
|
||||
noise_level = "умеренный"
|
||||
else:
|
||||
noise_level = "шумно"
|
||||
|
||||
# 8) Air quality — Open-Meteo (best-effort, null при недоступности)
|
||||
air_q = _fetch_air_quality_sync(centroid_lat, centroid_lon)
|
||||
|
||||
# 9) Wind — Open-Meteo (best-effort, null при недоступности)
|
||||
wind_data = _fetch_wind_sync(centroid_lat, centroid_lon)
|
||||
|
||||
return {
|
||||
"cad_num": cad_num,
|
||||
"source": source,
|
||||
|
|
@ -233,4 +403,12 @@ def analyze_parcel(
|
|||
"score_breakdown": by_category,
|
||||
"poi_count": len(poi_rows),
|
||||
"competitors": [dict(c) for c in competitor_rows],
|
||||
"noise": {
|
||||
"score": round(noise_score, 2),
|
||||
"estimated_db": round(noise_db_max, 1),
|
||||
"level": noise_level,
|
||||
"sources": nearby_noise_sources[:10],
|
||||
},
|
||||
"air_quality": air_q,
|
||||
"wind": wind_data,
|
||||
}
|
||||
|
|
|
|||
205
backend/app/services/site_finder/noise_loader.py
Normal file
205
backend/app/services/site_finder/noise_loader.py
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
"""Загрузчик OSM источников шума из Overpass API для site-finder.
|
||||
|
||||
Запускается раз в неделю через Celery beat. Хранит дороги (way), ж/д пути,
|
||||
промзоны и аэродромы в таблице osm_noise_sources_ekb.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
||||
EKB_BBOX = (56.7, 60.5, 56.95, 60.75) # (south, west, north, east)
|
||||
|
||||
# Overpass блокирует дефолтный User-Agent (406) — явный UA с контактом.
|
||||
_HEADERS = {
|
||||
"User-Agent": "GenDesign-SiteFinder/1.0 (+https://gendsgn.ru)",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
# Описание запросов: (overpass_key, overpass_value, source_type, road_class_from_value)
|
||||
_NOISE_QUERIES: list[tuple[str, str, str, str | None]] = [
|
||||
("highway", "motorway", "highway", "motorway"),
|
||||
("highway", "trunk", "highway", "trunk"),
|
||||
("highway", "primary", "highway", "primary"),
|
||||
("highway", "secondary", "highway", "secondary"),
|
||||
("highway", "tertiary", "highway", "tertiary"),
|
||||
("highway", "residential", "highway", "residential"),
|
||||
("railway", "rail", "railway", None),
|
||||
("landuse", "industrial", "industrial", None),
|
||||
("aeroway", "aerodrome", "aerodrome", None),
|
||||
]
|
||||
|
||||
|
||||
def _build_overpass_query(key: str, value: str, el_type: str) -> str:
|
||||
"""Запрос Overpass для одного типа элемента.
|
||||
|
||||
Для way используем `out geom` — получаем полные координаты узлов (нужно
|
||||
для построения LineString геометрии).
|
||||
Для node — `out body`.
|
||||
"""
|
||||
south, west, north, east = EKB_BBOX
|
||||
bbox = f"({south},{west},{north},{east})"
|
||||
if el_type == "way":
|
||||
return f"[out:json][timeout:30];" f'way["{key}"="{value}"]{bbox};' f"out geom;"
|
||||
else:
|
||||
return f"[out:json][timeout:30];" f'node["{key}"="{value}"]{bbox};' f"out body;"
|
||||
|
||||
|
||||
async def fetch_overpass_noise() -> list[dict]:
|
||||
"""Запрашивает Overpass API для всех шумовых источников ЕКБ.
|
||||
|
||||
Возвращает enriched list dict'ов с дополнительными полями:
|
||||
_source_type, _road_class — для использования при UPSERT.
|
||||
|
||||
Между запросами sleep 1с (Overpass usage policy).
|
||||
"""
|
||||
all_elements: list[dict] = []
|
||||
async with httpx.AsyncClient(timeout=60, headers=_HEADERS) as client:
|
||||
for key, value, source_type, road_class in _NOISE_QUERIES:
|
||||
# aerodrome — node; остальное — way
|
||||
el_type = "node" if key == "aeroway" else "way"
|
||||
query = _build_overpass_query(key, value, el_type)
|
||||
try:
|
||||
r = await client.post(OVERPASS_URL, data={"data": query})
|
||||
r.raise_for_status()
|
||||
elements: list[dict] = r.json().get("elements", [])
|
||||
logger.info(
|
||||
"Overpass noise: %s=%s (%s) → %d elements",
|
||||
key,
|
||||
value,
|
||||
source_type,
|
||||
len(elements),
|
||||
)
|
||||
for el in elements:
|
||||
el["_source_type"] = source_type
|
||||
el["_road_class"] = road_class
|
||||
all_elements.extend(elements)
|
||||
except Exception as e:
|
||||
logger.warning("Overpass noise failed for %s=%s: %s", key, value, e)
|
||||
await asyncio.sleep(1.0)
|
||||
logger.info("Overpass noise: total %d elements", len(all_elements))
|
||||
return all_elements
|
||||
|
||||
|
||||
def _way_to_linestring_wkt(nodes: list[dict]) -> str | None:
|
||||
"""Построить WKT LineString из списка узлов Overpass (out geom).
|
||||
|
||||
Каждый узел — {'lat': ..., 'lon': ...}. Минимум 2 точки для валидной линии.
|
||||
"""
|
||||
if len(nodes) < 2:
|
||||
return None
|
||||
parts = " ".join(f"{n['lon']} {n['lat']}" for n in nodes if "lon" in n and "lat" in n)
|
||||
if not parts or parts.count(" ") < 1:
|
||||
return None
|
||||
return f"LINESTRING({parts})"
|
||||
|
||||
|
||||
def sync_noise_sources_to_db() -> dict[str, int]:
|
||||
"""UPSERT шумовых источников из Overpass в osm_noise_sources_ekb.
|
||||
|
||||
UPSERT по UNIQUE(osm_type, osm_id).
|
||||
Геометрия:
|
||||
- way с nodes → LineString WKT через ST_GeomFromText(..., 4326)
|
||||
- node → ST_SetSRID(ST_MakePoint(lon, lat), 4326)
|
||||
Записывает breadcrumb в nspd_geo_log (stage='noise_sync').
|
||||
"""
|
||||
elements = asyncio.run(fetch_overpass_noise())
|
||||
|
||||
inserted = 0
|
||||
updated = 0
|
||||
skipped = 0
|
||||
fetched = len(elements)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
for el in elements:
|
||||
osm_id: int = el["id"]
|
||||
osm_type: str = el["type"] # 'way' | 'node'
|
||||
source_type: str = el.get("_source_type", "unknown")
|
||||
road_class: str | None = el.get("_road_class")
|
||||
tags: dict = el.get("tags") or {}
|
||||
name: str | None = tags.get("name")
|
||||
|
||||
# Геометрия зависит от типа элемента
|
||||
geom_sql: str
|
||||
geom_params: dict
|
||||
if osm_type == "way":
|
||||
nodes: list[dict] = el.get("geometry") or []
|
||||
wkt = _way_to_linestring_wkt(nodes)
|
||||
if wkt is None:
|
||||
skipped += 1
|
||||
continue
|
||||
geom_sql = "ST_GeomFromText(:wkt, 4326)"
|
||||
geom_params = {"wkt": wkt}
|
||||
elif osm_type == "node":
|
||||
lat = el.get("lat")
|
||||
lon = el.get("lon")
|
||||
if lat is None or lon is None:
|
||||
skipped += 1
|
||||
continue
|
||||
geom_sql = "ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)"
|
||||
geom_params = {"lat": lat, "lon": lon}
|
||||
else:
|
||||
# relation — skip (сложная геометрия, не нужна сейчас)
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
params: dict = {
|
||||
"osm_id": osm_id,
|
||||
"osm_type": osm_type,
|
||||
"source_type": source_type,
|
||||
"road_class": road_class,
|
||||
"name": name,
|
||||
"tags": json.dumps(tags, ensure_ascii=False),
|
||||
**geom_params,
|
||||
}
|
||||
|
||||
result = db.execute(
|
||||
text(f"""
|
||||
INSERT INTO osm_noise_sources_ekb
|
||||
(osm_type, osm_id, source_type, road_class, name, geom, tags, fetched_at)
|
||||
VALUES (
|
||||
:osm_type, :osm_id, :source_type, :road_class, :name,
|
||||
{geom_sql},
|
||||
CAST(:tags AS jsonb), NOW()
|
||||
)
|
||||
ON CONFLICT (osm_type, osm_id) DO UPDATE
|
||||
SET source_type = EXCLUDED.source_type,
|
||||
road_class = EXCLUDED.road_class,
|
||||
name = EXCLUDED.name,
|
||||
geom = EXCLUDED.geom,
|
||||
tags = EXCLUDED.tags,
|
||||
fetched_at = NOW()
|
||||
RETURNING (xmax = 0) AS is_insert
|
||||
"""),
|
||||
params,
|
||||
).scalar()
|
||||
|
||||
if result:
|
||||
inserted += 1
|
||||
else:
|
||||
updated += 1
|
||||
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
result_dict = {
|
||||
"fetched": fetched,
|
||||
"inserted": inserted,
|
||||
"updated": updated,
|
||||
"skipped": skipped,
|
||||
}
|
||||
logger.info("noise_sync done: %s", result_dict)
|
||||
return result_dict
|
||||
|
|
@ -54,6 +54,7 @@ celery_app = Celery(
|
|||
"app.workers.tasks.objective_etl",
|
||||
"app.workers.tasks.nspd_geo",
|
||||
"app.workers.tasks.poi_sync",
|
||||
"app.workers.tasks.noise_sync",
|
||||
],
|
||||
)
|
||||
celery_app.conf.timezone = "Europe/Moscow"
|
||||
|
|
@ -220,6 +221,14 @@ def _build_beat_schedule() -> dict:
|
|||
"options": {"queue": "celery"},
|
||||
}
|
||||
|
||||
# OSM noise sources sync — еженедельно в понедельник в 03:30 МСК
|
||||
# (через 30 мин после poi-sync, чтобы не нагружать Overpass одновременно)
|
||||
schedule["noise-sync-weekly"] = {
|
||||
"task": "tasks.noise_sync.sync_osm_noise_sources_ekb",
|
||||
"schedule": _parse_cron("30 3 * * mon"),
|
||||
"options": {"queue": "celery"},
|
||||
}
|
||||
|
||||
return schedule
|
||||
|
||||
|
||||
|
|
|
|||
51
backend/app/workers/tasks/noise_sync.py
Normal file
51
backend/app/workers/tasks/noise_sync.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""Celery task: еженедельная синхронизация OSM источников шума для site-finder."""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.workers.celery_app import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _log_breadcrumb(level: str, message: str) -> None:
|
||||
"""Записать строку в nspd_geo_log (job_id=NULL, stage='noise_sync')."""
|
||||
try:
|
||||
from app.core.db import SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.execute(
|
||||
text(
|
||||
"INSERT INTO nspd_geo_log (job_id, level, stage, message) "
|
||||
"VALUES (NULL, :lvl, 'noise_sync', :msg)"
|
||||
),
|
||||
{"lvl": level, "msg": message[:500]},
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.warning("noise_sync breadcrumb failed: %s", e)
|
||||
|
||||
|
||||
@celery_app.task(name="tasks.noise_sync.sync_osm_noise_sources_ekb", queue="celery")
|
||||
def sync_osm_noise_sources_ekb() -> dict:
|
||||
"""Еженедельная синхронизация OSM шумовых источников из Overpass в osm_noise_sources_ekb.
|
||||
|
||||
Запускается через beat (понедельник 03:30 МСК — через 30 мин после poi-sync).
|
||||
Persistent breadcrumbs в nspd_geo_log для диагностики без SSH.
|
||||
"""
|
||||
_log_breadcrumb("info", "task started")
|
||||
try:
|
||||
from app.services.site_finder.noise_loader import sync_noise_sources_to_db
|
||||
|
||||
result = sync_noise_sources_to_db()
|
||||
logger.info("noise_sync done: %s", result)
|
||||
_log_breadcrumb("info", f"done: {result}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.exception("noise_sync failed: %s", e)
|
||||
_log_breadcrumb("error", f"{type(e).__name__}: {e}")
|
||||
raise
|
||||
|
|
@ -1,6 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||
import { useState } from "react";
|
||||
import type {
|
||||
ParcelAnalysis,
|
||||
ParcelAnalysisNoise,
|
||||
ParcelAnalysisAirQuality,
|
||||
ParcelAnalysisWind,
|
||||
} from "@/types/site-finder";
|
||||
|
||||
interface Props {
|
||||
data: ParcelAnalysis;
|
||||
|
|
@ -20,6 +26,13 @@ const CATEGORY_LABELS: Record<string, string> = {
|
|||
metro_stop: "Метро",
|
||||
};
|
||||
|
||||
const SOURCE_TYPE_LABELS: Record<string, string> = {
|
||||
highway: "Магистраль",
|
||||
railway: "Ж/д",
|
||||
industrial: "Производство",
|
||||
aerodrome: "Аэродром",
|
||||
};
|
||||
|
||||
function scoreColor(score: number): string {
|
||||
if (score > 5) return "#16a34a";
|
||||
if (score >= 2) return "#d97706";
|
||||
|
|
@ -31,6 +44,289 @@ function avgDist(items: Array<{ distance_m: number }>): number {
|
|||
return Math.round(items.reduce((s, i) => s + i.distance_m, 0) / items.length);
|
||||
}
|
||||
|
||||
function noiseBg(score: number): string {
|
||||
if (score >= 0.7) return "#dcfce7";
|
||||
if (score >= 0.4) return "#fef3c7";
|
||||
return "#fecaca";
|
||||
}
|
||||
|
||||
function pm25Color(v: number): string {
|
||||
if (v < 10) return "#16a34a";
|
||||
if (v < 25) return "#d97706";
|
||||
if (v < 50) return "#ea580c";
|
||||
return "#dc2626";
|
||||
}
|
||||
|
||||
function pm10Color(v: number): string {
|
||||
if (v < 20) return "#16a34a";
|
||||
if (v < 50) return "#d97706";
|
||||
if (v < 100) return "#ea580c";
|
||||
return "#dc2626";
|
||||
}
|
||||
|
||||
function no2Color(v: number): string {
|
||||
if (v < 40) return "#16a34a";
|
||||
if (v < 100) return "#d97706";
|
||||
return "#dc2626";
|
||||
}
|
||||
|
||||
function formatTs(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Noise block ───────────────────────────────────────────────────────────────
|
||||
|
||||
function NoiseBlock({ noise }: { noise: ParcelAnalysisNoise }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const top3 = noise.sources.slice(0, 3);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
padding: "14px 16px",
|
||||
background: noiseBg(noise.score),
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>Шум</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontWeight: 800,
|
||||
color: "#111827",
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
~{Math.round(noise.estimated_db)} dB
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#374151" }}>{noise.level}</div>
|
||||
|
||||
{top3.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setOpen((p) => !p)}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
fontSize: 12,
|
||||
color: "#6b7280",
|
||||
cursor: "pointer",
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
>
|
||||
{open ? "Скрыть источники" : `Источники (${noise.sources.length})`}
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{top3.map((s, i) => (
|
||||
<div key={i} style={{ fontSize: 12, color: "#374151" }}>
|
||||
{SOURCE_TYPE_LABELS[s.source_type] ?? s.source_type}
|
||||
{s.name ? ` «${s.name}»` : ""} — {Math.round(s.distance_m)} м
|
||||
({Math.round(s.estimated_db)} dB)
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Air Quality block ─────────────────────────────────────────────────────────
|
||||
|
||||
function AirQualityBlock({ aq }: { aq: ParcelAnalysisAirQuality | null }) {
|
||||
if (!aq) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
padding: "14px 16px",
|
||||
background: "#f3f4f6",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
|
||||
Воздух
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#9ca3af" }}>Данные недоступны</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
padding: "14px 16px",
|
||||
background: "#f0fdf4",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
|
||||
Воздух
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<AqBadge
|
||||
label="PM2.5"
|
||||
value={aq.pm2_5}
|
||||
unit="мкг/м³"
|
||||
color={pm25Color(aq.pm2_5)}
|
||||
/>
|
||||
<AqBadge
|
||||
label="PM10"
|
||||
value={aq.pm10}
|
||||
unit="мкг/м³"
|
||||
color={pm10Color(aq.pm10)}
|
||||
/>
|
||||
<AqBadge
|
||||
label="NO₂"
|
||||
value={aq.no2}
|
||||
unit="мкг/м³"
|
||||
color={no2Color(aq.no2)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "#9ca3af" }}>
|
||||
{aq.source} · {formatTs(aq.ts)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AqBadge({
|
||||
label,
|
||||
value,
|
||||
unit,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
padding: "6px 10px",
|
||||
background: `${color}18`,
|
||||
border: `1px solid ${color}55`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
minWidth: 62,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 11, color: "#6b7280" }}>{label}</div>
|
||||
<div style={{ fontSize: 17, fontWeight: 700, color, lineHeight: 1.2 }}>
|
||||
{value.toFixed(1)}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: "#9ca3af" }}>{unit}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Wind block ────────────────────────────────────────────────────────────────
|
||||
|
||||
function WindArrow({ deg }: { deg: number }) {
|
||||
// Arrow points "up" by default (north). Rotate to show wind coming FROM that direction.
|
||||
return (
|
||||
<svg
|
||||
width="52"
|
||||
height="52"
|
||||
viewBox="0 0 52 52"
|
||||
style={{ display: "block" }}
|
||||
aria-label={`Направление ветра ${deg}°`}
|
||||
>
|
||||
{/* compass ring */}
|
||||
<circle cx="26" cy="26" r="24" fill="#e5e7eb" />
|
||||
{/* arrow group rotated to dominant_direction_deg */}
|
||||
<g transform={`rotate(${deg}, 26, 26)`}>
|
||||
{/* shaft */}
|
||||
<rect x="24.5" y="10" width="3" height="24" rx="1.5" fill="#374151" />
|
||||
{/* arrowhead */}
|
||||
<polygon points="26,6 21,16 31,16" fill="#374151" />
|
||||
{/* tail notch */}
|
||||
<polygon points="26,34 22,44 30,44" fill="#9ca3af" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function WindBlock({ wind }: { wind: ParcelAnalysisWind | null }) {
|
||||
if (!wind) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
padding: "14px 16px",
|
||||
background: "#f3f4f6",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
|
||||
Ветер
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#9ca3af" }}>Данные недоступны</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
padding: "14px 16px",
|
||||
background: "#eff6ff",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
|
||||
Ветер
|
||||
</div>
|
||||
<WindArrow deg={wind.dominant_direction_deg} />
|
||||
<div style={{ fontSize: 13, color: "#374151" }}>
|
||||
{wind.dominant_direction_label}
|
||||
{wind.max_speed_m_s != null ? ` · до ${wind.max_speed_m_s} м/с` : ""}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "#9ca3af" }}>
|
||||
за {wind.forecast_days} дн.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── ScoreCard ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ScoreCard({ data }: Props) {
|
||||
const tramPois = data.score_breakdown["tram_stop"] ?? [];
|
||||
const nearestTram =
|
||||
|
|
@ -38,6 +334,11 @@ export function ScoreCard({ data }: Props) {
|
|||
? Math.round(Math.min(...tramPois.map((p) => p.distance_m)))
|
||||
: null;
|
||||
|
||||
const hasEnvironmental =
|
||||
data.noise !== undefined ||
|
||||
data.air_quality !== undefined ||
|
||||
data.wind !== undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -159,6 +460,63 @@ export function ScoreCard({ data }: Props) {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Environmental factors */}
|
||||
{hasEnvironmental && (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 24px 16px",
|
||||
borderTop: "1px solid #e5e7eb",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "#6b7280",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
Внешние факторы
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, 1fr)",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{data.noise !== undefined ? (
|
||||
data.noise !== null ? (
|
||||
<NoiseBlock noise={data.noise} />
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
padding: "14px 16px",
|
||||
background: "#f3f4f6",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}
|
||||
>
|
||||
Шум
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#9ca3af", marginTop: 8 }}>
|
||||
Данные недоступны
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
|
||||
<AirQualityBlock aq={data.air_quality ?? null} />
|
||||
|
||||
<WindBlock wind={data.wind ?? null} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,36 @@
|
|||
import type { Geometry } from "geojson";
|
||||
|
||||
export interface ParcelAnalysisNoiseSource {
|
||||
source_type: "highway" | "railway" | "industrial" | "aerodrome";
|
||||
road_class: string | null;
|
||||
name: string | null;
|
||||
distance_m: number;
|
||||
estimated_db: number;
|
||||
}
|
||||
|
||||
export interface ParcelAnalysisNoise {
|
||||
score: number;
|
||||
estimated_db: number;
|
||||
level: "тихо" | "умеренный" | "шумно";
|
||||
sources: ParcelAnalysisNoiseSource[];
|
||||
}
|
||||
|
||||
export interface ParcelAnalysisAirQuality {
|
||||
pm2_5: number;
|
||||
pm10: number;
|
||||
no2: number;
|
||||
ts: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface ParcelAnalysisWind {
|
||||
dominant_direction_deg: number;
|
||||
dominant_direction_label: string;
|
||||
max_speed_m_s: number | null;
|
||||
forecast_days: number;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface ParcelAnalysisCompetitor {
|
||||
obj_id: number;
|
||||
comm_name: string | null;
|
||||
|
|
@ -33,6 +64,9 @@ export interface ParcelAnalysis {
|
|||
score_breakdown: Record<string, ParcelAnalysisPoi[]>;
|
||||
poi_count: number;
|
||||
competitors: ParcelAnalysisCompetitor[];
|
||||
noise: ParcelAnalysisNoise | null;
|
||||
air_quality: ParcelAnalysisAirQuality | null;
|
||||
wind: ParcelAnalysisWind | null;
|
||||
}
|
||||
|
||||
export type PoiCategory =
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue