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) ─────────────────────────────────
@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,

View file

@ -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,
}

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.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

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";
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>
);
}

View file

@ -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 =