feat(site-finder): v3.2 — noise + air quality + wind analytics

This commit is contained in:
lekss361 2026-05-11 20:42:37 +03:00
parent a2e8a49e6d
commit 1e9d32ee3c
7 changed files with 853 additions and 1 deletions

View file

@ -367,6 +367,23 @@ def list_logs(
# ── Objective ETL (Антоновский SQLite → PG) ───────────────────────────────── # ── 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") @router.post("/poi-sync")
def trigger_poi_sync( def trigger_poi_sync(
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None, x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,

View file

@ -1,7 +1,9 @@
import json import json
import logging import logging
import math
from typing import Annotated, Any from typing import Annotated, Any
import httpx
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -13,6 +15,99 @@ logger = logging.getLogger(__name__)
router = APIRouter() 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-категорий для scoring (Максим: трамвай = минус)
_POI_WEIGHTS: dict[str, float] = { _POI_WEIGHTS: dict[str, float] = {
"school": 1.5, "school": 1.5,
@ -224,6 +319,81 @@ def analyze_parcel(
.all() .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 { return {
"cad_num": cad_num, "cad_num": cad_num,
"source": source, "source": source,
@ -233,4 +403,12 @@ def analyze_parcel(
"score_breakdown": by_category, "score_breakdown": by_category,
"poi_count": len(poi_rows), "poi_count": len(poi_rows),
"competitors": [dict(c) for c in competitor_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,
} }

View 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

View file

@ -54,6 +54,7 @@ celery_app = Celery(
"app.workers.tasks.objective_etl", "app.workers.tasks.objective_etl",
"app.workers.tasks.nspd_geo", "app.workers.tasks.nspd_geo",
"app.workers.tasks.poi_sync", "app.workers.tasks.poi_sync",
"app.workers.tasks.noise_sync",
], ],
) )
celery_app.conf.timezone = "Europe/Moscow" celery_app.conf.timezone = "Europe/Moscow"
@ -220,6 +221,14 @@ def _build_beat_schedule() -> dict:
"options": {"queue": "celery"}, "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 return schedule

View 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

View file

@ -1,6 +1,12 @@
"use client"; "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 { interface Props {
data: ParcelAnalysis; data: ParcelAnalysis;
@ -20,6 +26,13 @@ const CATEGORY_LABELS: Record<string, string> = {
metro_stop: "Метро", metro_stop: "Метро",
}; };
const SOURCE_TYPE_LABELS: Record<string, string> = {
highway: "Магистраль",
railway: "Ж/д",
industrial: "Производство",
aerodrome: "Аэродром",
};
function scoreColor(score: number): string { function scoreColor(score: number): string {
if (score > 5) return "#16a34a"; if (score > 5) return "#16a34a";
if (score >= 2) return "#d97706"; 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); 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) { export function ScoreCard({ data }: Props) {
const tramPois = data.score_breakdown["tram_stop"] ?? []; const tramPois = data.score_breakdown["tram_stop"] ?? [];
const nearestTram = const nearestTram =
@ -38,6 +334,11 @@ export function ScoreCard({ data }: Props) {
? Math.round(Math.min(...tramPois.map((p) => p.distance_m))) ? Math.round(Math.min(...tramPois.map((p) => p.distance_m)))
: null; : null;
const hasEnvironmental =
data.noise !== undefined ||
data.air_quality !== undefined ||
data.wind !== undefined;
return ( return (
<div <div
style={{ style={{
@ -159,6 +460,63 @@ export function ScoreCard({ data }: Props) {
</div> </div>
)} )}
</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> </div>
); );
} }

View file

@ -1,5 +1,36 @@
import type { Geometry } from "geojson"; 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 { export interface ParcelAnalysisCompetitor {
obj_id: number; obj_id: number;
comm_name: string | null; comm_name: string | null;
@ -33,6 +64,9 @@ export interface ParcelAnalysis {
score_breakdown: Record<string, ParcelAnalysisPoi[]>; score_breakdown: Record<string, ParcelAnalysisPoi[]>;
poi_count: number; poi_count: number;
competitors: ParcelAnalysisCompetitor[]; competitors: ParcelAnalysisCompetitor[];
noise: ParcelAnalysisNoise | null;
air_quality: ParcelAnalysisAirQuality | null;
wind: ParcelAnalysisWind | null;
} }
export type PoiCategory = export type PoiCategory =