feat(site-finder): v3.5 - seasonal weather + hydrology + geotech risk
This commit is contained in:
parent
7bb51aa838
commit
2855a01ca7
7 changed files with 705 additions and 1 deletions
|
|
@ -70,6 +70,82 @@ def _fetch_air_quality_sync(lat: float, lon: float) -> dict | None:
|
|||
return None
|
||||
|
||||
|
||||
def _fetch_seasonal_weather_sync(lat: float, lon: float) -> dict | None:
|
||||
"""Open-Meteo Climate API — 30-летние нормали по сезонам.
|
||||
|
||||
Использует модель MRI-AGCM3.2-S (японская, точная для всего мира).
|
||||
Группирует 1995-2024 по четырём сезонам. Медленнее прогноза — timeout 15s.
|
||||
"""
|
||||
try:
|
||||
with httpx.Client(timeout=15) as c:
|
||||
r = c.get(
|
||||
"https://climate-api.open-meteo.com/v1/climate",
|
||||
params={
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"start_date": "1995-01-01",
|
||||
"end_date": "2024-12-31",
|
||||
"models": "MRI_AGCM3_2_S",
|
||||
"daily": "temperature_2m_max,temperature_2m_min,precipitation_sum",
|
||||
},
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
daily = data.get("daily", {})
|
||||
times = daily.get("time") or []
|
||||
t_max = daily.get("temperature_2m_max") or []
|
||||
t_min = daily.get("temperature_2m_min") or []
|
||||
precip = daily.get("precipitation_sum") or []
|
||||
if not times:
|
||||
return None
|
||||
|
||||
seasons_months = {
|
||||
"winter": [12, 1, 2],
|
||||
"spring": [3, 4, 5],
|
||||
"summer": [6, 7, 8],
|
||||
"autumn": [9, 10, 11],
|
||||
}
|
||||
buckets: dict[str, dict[str, list[float]]] = {
|
||||
k: {"t_max": [], "t_min": [], "precip": []} for k in seasons_months
|
||||
}
|
||||
for i, t in enumerate(times):
|
||||
month = int(t[5:7]) # 'YYYY-MM-DD'
|
||||
for season, months in seasons_months.items():
|
||||
if month in months:
|
||||
if i < len(t_max) and t_max[i] is not None:
|
||||
buckets[season]["t_max"].append(t_max[i])
|
||||
if i < len(t_min) and t_min[i] is not None:
|
||||
buckets[season]["t_min"].append(t_min[i])
|
||||
if i < len(precip) and precip[i] is not None:
|
||||
buckets[season]["precip"].append(precip[i])
|
||||
break
|
||||
|
||||
seasons: dict[str, Any] = {}
|
||||
for season, vals in buckets.items():
|
||||
if not vals["t_max"]:
|
||||
seasons[season] = None
|
||||
continue
|
||||
seasons[season] = {
|
||||
"avg_t_max_c": round(sum(vals["t_max"]) / len(vals["t_max"]), 1),
|
||||
"avg_t_min_c": round(sum(vals["t_min"]) / len(vals["t_min"]), 1),
|
||||
"max_t_c": round(max(vals["t_max"]), 1),
|
||||
"min_t_c": round(min(vals["t_min"]), 1),
|
||||
"avg_precip_per_day_mm": round(sum(vals["precip"]) / len(vals["precip"]), 1),
|
||||
"total_precip_mm": round(sum(vals["precip"]), 0),
|
||||
"days_observed": len(vals["t_max"]),
|
||||
}
|
||||
return {
|
||||
"seasons": seasons,
|
||||
"period": "1995-2024 (30 лет)",
|
||||
"model": "MRI-AGCM3-2-S",
|
||||
"source": "open-meteo-climate",
|
||||
"note": ("Климатические нормали. " "Текущая погода — отдельный API."),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("seasonal weather fetch failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_weather_sync(lat: float, lon: float) -> dict | None:
|
||||
"""Open-Meteo Forecast API — 7-day weather + climate context.
|
||||
|
||||
|
|
@ -165,6 +241,71 @@ _POI_WEIGHTS: dict[str, float] = {
|
|||
}
|
||||
|
||||
|
||||
# Сейсмика по ОСР-2016 карта B (среднее повторяемое за 500 лет).
|
||||
# Добавляй регионы по мере расширения географии продукта.
|
||||
GEOTECH_BY_REGION: dict[int, dict[str, Any]] = {
|
||||
66: { # Свердловская обл.
|
||||
"seismic_intensity_balls": 5,
|
||||
"seismic_label": "минимальная сейсмика (≤5 баллов)",
|
||||
"seismic_description": "Обычное строительство без специальных мер.",
|
||||
"permafrost": False,
|
||||
},
|
||||
77: { # Москва
|
||||
"seismic_intensity_balls": 4,
|
||||
"seismic_label": "практически нет сейсмики",
|
||||
"seismic_description": "Обычное строительство.",
|
||||
"permafrost": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _geotech_risk(region_code: int, db: Session, geom_wkt: str) -> dict[str, Any]:
|
||||
"""Геотехнические риски: сейсмика (ОСР-2016) + промышленная близость.
|
||||
|
||||
industrial_within_500m — proxy для возможного загрязнения почв (без
|
||||
реальных шейпов зон загрязнения). Точная геология/гидрогеология требует
|
||||
инженерных изысканий (bore-holes).
|
||||
"""
|
||||
region_data: dict[str, Any] = GEOTECH_BY_REGION.get(
|
||||
region_code,
|
||||
{
|
||||
"seismic_intensity_balls": None,
|
||||
"seismic_label": "нет данных в встроенной таблице",
|
||||
"seismic_description": "ОСР-2016: уточняйте по карте.",
|
||||
"permafrost": False,
|
||||
},
|
||||
)
|
||||
|
||||
industrial_close: int = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT COUNT(*) AS n
|
||||
FROM osm_noise_sources_ekb
|
||||
WHERE source_type = 'industrial'
|
||||
AND ST_DWithin(
|
||||
geom::geography,
|
||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||||
500
|
||||
)
|
||||
"""),
|
||||
{"wkt": geom_wkt},
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
return {
|
||||
**region_data,
|
||||
"industrial_within_500m": int(industrial_close),
|
||||
"industrial_contamination_flag": int(industrial_close) > 0,
|
||||
"note": (
|
||||
"Сейсмика — ОСР-2016 карта B (среднее повторяемое за 500 лет). "
|
||||
"Для строительства обычно достаточно ≤7 баллов без спецмер. "
|
||||
"Industrial proximity — proxy для возможного загрязнения почв. "
|
||||
"Точная геология/гидрогеология — требует bore-holes (изыскания)."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/search", response_model=ParcelSearchResponse)
|
||||
async def search_parcels(payload: ParcelSearchRequest) -> ParcelSearchResponse:
|
||||
"""Search parcels by filters + scoring.
|
||||
|
|
@ -435,6 +576,58 @@ def analyze_parcel(
|
|||
# 9) Weather — Open-Meteo 7-day forecast (best-effort, null при недоступности)
|
||||
weather = _fetch_weather_sync(centroid_lat, centroid_lon)
|
||||
|
||||
# 9b) Seasonal weather — 30-летние нормали Climate API
|
||||
seasonal_weather = _fetch_seasonal_weather_sync(centroid_lat, centroid_lon)
|
||||
|
||||
# 9c) Hydrology — водоёмы и реки в радиусе 2 км из osm_noise_sources_ekb
|
||||
hydrology: dict[str, Any] | None = None
|
||||
try:
|
||||
hydro_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 source_type = 'water'
|
||||
AND ST_DWithin(
|
||||
n.geom::geography,
|
||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||||
2000
|
||||
)
|
||||
ORDER BY distance_m ASC
|
||||
LIMIT 10
|
||||
"""),
|
||||
{"wkt": geom_wkt},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
hydrology = {
|
||||
"nearest": [
|
||||
{
|
||||
"subtype": r["road_class"],
|
||||
"name": r["name"],
|
||||
"distance_m": round(float(r["distance_m"])),
|
||||
}
|
||||
for r in hydro_rows[:5]
|
||||
],
|
||||
"flood_risk_flag": any(
|
||||
float(r["distance_m"]) < 200 and r["road_class"] in ("river", "canal")
|
||||
for r in hydro_rows
|
||||
),
|
||||
"note": (
|
||||
"Пойма реки (<200м) — повышенный риск подтопления. Точные данные о "
|
||||
"зонах затопления — в Росреестре (ЗОУИТ типа 33: 'Зона затопления, "
|
||||
"подтопления') через ФГИС ТП."
|
||||
),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("hydrology query failed for %s: %s", cad_num, e)
|
||||
hydrology = None
|
||||
|
||||
# 10) Market trend — динамика цен ДДУ в радиусе 3 км за 6 vs предыдущие 6 месяцев
|
||||
market_trend: dict[str, Any] | None = None
|
||||
try:
|
||||
|
|
@ -551,7 +744,10 @@ def analyze_parcel(
|
|||
},
|
||||
"air_quality": air_q,
|
||||
"weather": weather,
|
||||
"seasonal_weather": seasonal_weather,
|
||||
"wind": (weather or {}).get("wind") if weather else None, # backward compat
|
||||
"geology": geology,
|
||||
"hydrology": hydrology,
|
||||
"geotech_risk": _geotech_risk(66, db, geom_wkt),
|
||||
"market_trend": market_trend,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,11 @@ _NOISE_QUERIES: list[tuple[str, str, str, str | None]] = [
|
|||
("railway", "rail", "railway", None),
|
||||
("landuse", "industrial", "industrial", None),
|
||||
("aeroway", "aerodrome", "aerodrome", None),
|
||||
# Гидрология — реки, ручьи, каналы (way-линии) и водоёмы (way-полигоны)
|
||||
("waterway", "river", "water", "river"),
|
||||
("waterway", "stream", "water", "stream"),
|
||||
("waterway", "canal", "water", "canal"),
|
||||
("natural", "water", "water", "lake_or_pond"),
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -104,6 +109,21 @@ def _way_to_linestring_wkt(nodes: list[dict]) -> str | None:
|
|||
return f"LINESTRING({', '.join(points)})"
|
||||
|
||||
|
||||
def _way_to_polygon_wkt(nodes: list[dict]) -> str | None:
|
||||
"""Построить WKT Polygon из замкнутого way Overpass (out geom).
|
||||
|
||||
Используется для natural=water (озёра, пруды). Если кольцо не замкнуто
|
||||
автоматически — добавляем первую точку в конец.
|
||||
"""
|
||||
points = [f"{n['lon']} {n['lat']}" for n in nodes if "lon" in n and "lat" in n]
|
||||
if len(points) < 3:
|
||||
return None
|
||||
# Замкнуть кольцо если нужно
|
||||
if points[0] != points[-1]:
|
||||
points.append(points[0])
|
||||
return f"POLYGON(({', '.join(points)}))"
|
||||
|
||||
|
||||
def sync_noise_sources_to_db() -> dict[str, int]:
|
||||
"""UPSERT шумовых источников из Overpass в osm_noise_sources_ekb.
|
||||
|
||||
|
|
@ -135,7 +155,14 @@ def sync_noise_sources_to_db() -> dict[str, int]:
|
|||
geom_params: dict
|
||||
if osm_type == "way":
|
||||
nodes: list[dict] = el.get("geometry") or []
|
||||
wkt = _way_to_linestring_wkt(nodes)
|
||||
# natural=water → замкнутый полигон; waterway → линия
|
||||
if source_type == "water" and road_class == "lake_or_pond":
|
||||
wkt = _way_to_polygon_wkt(nodes)
|
||||
if wkt is None:
|
||||
# Fallback: попробовать как линию (частично загруженный bbox-обрезок)
|
||||
wkt = _way_to_linestring_wkt(nodes)
|
||||
else:
|
||||
wkt = _way_to_linestring_wkt(nodes)
|
||||
if wkt is None:
|
||||
skipped += 1
|
||||
continue
|
||||
|
|
|
|||
131
frontend/src/components/site-finder/GeotechRiskBlock.tsx
Normal file
131
frontend/src/components/site-finder/GeotechRiskBlock.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"use client";
|
||||
|
||||
import type { GeotechRisk } from "@/types/site-finder";
|
||||
|
||||
interface Props {
|
||||
risk: GeotechRisk;
|
||||
}
|
||||
|
||||
function seismicBadgeStyle(balls: number): { bg: string; color: string } {
|
||||
if (balls <= 5) return { bg: "#dcfce7", color: "#15803d" };
|
||||
if (balls <= 7) return { bg: "#fef3c7", color: "#92400e" };
|
||||
return { bg: "#fecaca", color: "#b91c1c" };
|
||||
}
|
||||
|
||||
export function GeotechRiskBlock({ risk }: Props) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
padding: "14px 16px",
|
||||
background: "#fafafa",
|
||||
border: "1px solid #e5e7eb",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: "#374151" }}>
|
||||
Геотехнический риск
|
||||
</div>
|
||||
|
||||
{/* Seismics */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
{risk.seismic_intensity_balls !== null ? (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 14,
|
||||
fontWeight: 800,
|
||||
flexShrink: 0,
|
||||
...seismicBadgeStyle(risk.seismic_intensity_balls),
|
||||
}}
|
||||
>
|
||||
{risk.seismic_intensity_balls}
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>
|
||||
{risk.seismic_label}
|
||||
</div>
|
||||
{risk.seismic_description && (
|
||||
<div style={{ fontSize: 11, color: "#6b7280" }}>
|
||||
{risk.seismic_description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
padding: "6px 12px",
|
||||
background: "#f3f4f6",
|
||||
fontSize: 13,
|
||||
color: "#6b7280",
|
||||
}}
|
||||
>
|
||||
{risk.seismic_label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Permafrost */}
|
||||
{risk.permafrost && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontSize: 13,
|
||||
color: "#374151",
|
||||
}}
|
||||
>
|
||||
<span>🟡</span>
|
||||
<span>Вечная мерзлота — особые фундаменты</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Industrial contamination */}
|
||||
{risk.industrial_contamination_flag && (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
padding: "10px 14px",
|
||||
background: "#fff7ed",
|
||||
border: "1px solid #fdba74",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "#c2410c",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<span>⚠</span>
|
||||
<span>
|
||||
{risk.industrial_within_500m} промзон(ы) в <500м (риск
|
||||
загрязнения почв)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note */}
|
||||
{risk.note && (
|
||||
<div style={{ fontSize: 11, color: "#9ca3af" }}>{risk.note}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
frontend/src/components/site-finder/HydrologyBlock.tsx
Normal file
109
frontend/src/components/site-finder/HydrologyBlock.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"use client";
|
||||
|
||||
import type { Hydrology, HydrologyNearest } from "@/types/site-finder";
|
||||
|
||||
interface Props {
|
||||
hydrology: Hydrology;
|
||||
}
|
||||
|
||||
const SUBTYPE_ICON: Record<string, string> = {
|
||||
river: "🏞",
|
||||
stream: "💦",
|
||||
lake_or_pond: "🪷",
|
||||
canal: "🚣",
|
||||
};
|
||||
|
||||
function subtypeLabel(subtype: HydrologyNearest["subtype"]): string {
|
||||
switch (subtype) {
|
||||
case "river":
|
||||
return "река";
|
||||
case "stream":
|
||||
return "ручей";
|
||||
case "lake_or_pond":
|
||||
return "озеро/пруд";
|
||||
case "canal":
|
||||
return "канал";
|
||||
default:
|
||||
return "водоём";
|
||||
}
|
||||
}
|
||||
|
||||
export function HydrologyBlock({ hydrology }: Props) {
|
||||
const top5 = hydrology.nearest.slice(0, 5);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
padding: "14px 16px",
|
||||
background: "#f0f9ff",
|
||||
border: "1px solid #bae6fd",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: "#0369a1" }}>
|
||||
Гидрология
|
||||
</div>
|
||||
|
||||
{/* Flood risk banner */}
|
||||
{hydrology.flood_risk_flag && (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
padding: "10px 14px",
|
||||
background: "#fecaca",
|
||||
border: "1px solid #f87171",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "#b91c1c",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<span>⚠</span>
|
||||
<span>Возможная пойма — река/канал в <200м</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nearest water bodies */}
|
||||
{top5.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: "#6b7280" }}>
|
||||
Поблизости водных объектов нет
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
||||
{top5.map((item, i) => {
|
||||
const icon = SUBTYPE_ICON[item.subtype ?? ""] ?? "💧";
|
||||
const label = subtypeLabel(item.subtype);
|
||||
const name = item.name ?? "б/н";
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontSize: 13,
|
||||
color: "#374151",
|
||||
}}
|
||||
>
|
||||
<span>{icon}</span>
|
||||
<span>
|
||||
{name} {label} — {Math.round(item.distance_m)} м
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note */}
|
||||
{hydrology.note && (
|
||||
<div style={{ fontSize: 11, color: "#9ca3af" }}>{hydrology.note}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,6 +10,9 @@ import type {
|
|||
} from "@/types/site-finder";
|
||||
import { MarketTrendBlock } from "./MarketTrendBlock";
|
||||
import { GeologyBlock } from "./GeologyBlock";
|
||||
import { SeasonalWeatherBlock } from "./SeasonalWeatherBlock";
|
||||
import { HydrologyBlock } from "./HydrologyBlock";
|
||||
import { GeotechRiskBlock } from "./GeotechRiskBlock";
|
||||
|
||||
interface Props {
|
||||
data: ParcelAnalysis;
|
||||
|
|
@ -714,6 +717,42 @@ export function ScoreCard({ data }: Props) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Seasonal weather */}
|
||||
{"seasonal_weather" in data && (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 24px 16px",
|
||||
borderTop: "1px solid #e5e7eb",
|
||||
}}
|
||||
>
|
||||
<SeasonalWeatherBlock seasonal={data.seasonal_weather} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hydrology */}
|
||||
{data.hydrology !== undefined && (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 24px 16px",
|
||||
borderTop: "1px solid #e5e7eb",
|
||||
}}
|
||||
>
|
||||
<HydrologyBlock hydrology={data.hydrology} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Geotech risk */}
|
||||
{data.geotech_risk !== undefined && (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 24px 16px",
|
||||
borderTop: "1px solid #e5e7eb",
|
||||
}}
|
||||
>
|
||||
<GeotechRiskBlock risk={data.geotech_risk} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Geology */}
|
||||
{data.geology !== undefined && (
|
||||
<div
|
||||
|
|
|
|||
154
frontend/src/components/site-finder/SeasonalWeatherBlock.tsx
Normal file
154
frontend/src/components/site-finder/SeasonalWeatherBlock.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
"use client";
|
||||
|
||||
import type { SeasonalWeather, SeasonStats } from "@/types/site-finder";
|
||||
|
||||
interface Props {
|
||||
seasonal: SeasonalWeather | null | undefined;
|
||||
}
|
||||
|
||||
const SEASON_CONFIG: Array<{
|
||||
key: keyof SeasonalWeather["seasons"];
|
||||
label: string;
|
||||
bg: string;
|
||||
color: string;
|
||||
}> = [
|
||||
{ key: "winter", label: "Зима", bg: "#dbeafe", color: "#1e40af" },
|
||||
{ key: "spring", label: "Весна", bg: "#dcfce7", color: "#15803d" },
|
||||
{ key: "summer", label: "Лето", bg: "#fef3c7", color: "#92400e" },
|
||||
{ key: "autumn", label: "Осень", bg: "#fed7aa", color: "#c2410c" },
|
||||
];
|
||||
|
||||
function SeasonCard({
|
||||
label,
|
||||
bg,
|
||||
color,
|
||||
stats,
|
||||
}: {
|
||||
label: string;
|
||||
bg: string;
|
||||
color: string;
|
||||
stats: SeasonStats;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
padding: "12px 14px",
|
||||
background: bg,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 6,
|
||||
flex: "1 1 0",
|
||||
minWidth: 120,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color }}>{label}</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: "#111827" }}>
|
||||
{stats.avg_t_min_c}…{stats.avg_t_max_c}°C
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "#6b7280" }}>
|
||||
(макс ↓{stats.min_t_c} / ↑{stats.max_t_c}°C)
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#374151" }}>
|
||||
{stats.total_precip_mm} мм осадков
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SeasonalWeatherBlock({ seasonal }: Props) {
|
||||
if (!seasonal) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
padding: "14px 16px",
|
||||
background: "#f3f4f6",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<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: "#fafafa",
|
||||
border: "1px solid #e5e7eb",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: "#374151" }}>
|
||||
Климат (нормали)
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "#9ca3af" }}>{seasonal.period}</div>
|
||||
</div>
|
||||
|
||||
{/* Season cards */}
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
{SEASON_CONFIG.map(({ key, label, bg, color }) => {
|
||||
const stats = seasonal.seasons[key];
|
||||
if (!stats) {
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
padding: "12px 14px",
|
||||
background: "#f3f4f6",
|
||||
flex: "1 1 0",
|
||||
minWidth: 120,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ fontSize: 13, fontWeight: 700, color: "#9ca3af" }}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#9ca3af", marginTop: 4 }}>
|
||||
Нет данных
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SeasonCard
|
||||
key={key}
|
||||
label={label}
|
||||
bg={bg}
|
||||
color={color}
|
||||
stats={stats}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ fontSize: 11, color: "#9ca3af" }}>
|
||||
{seasonal.source} · {seasonal.model}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -82,6 +82,51 @@ export interface ParcelAnalysisGeology {
|
|||
lon: number;
|
||||
}
|
||||
|
||||
export interface SeasonStats {
|
||||
avg_t_max_c: number;
|
||||
avg_t_min_c: number;
|
||||
max_t_c: number;
|
||||
min_t_c: number;
|
||||
avg_precip_per_day_mm: number;
|
||||
total_precip_mm: number;
|
||||
days_observed: number;
|
||||
}
|
||||
|
||||
export interface SeasonalWeather {
|
||||
seasons: {
|
||||
winter: SeasonStats | null;
|
||||
spring: SeasonStats | null;
|
||||
summer: SeasonStats | null;
|
||||
autumn: SeasonStats | null;
|
||||
};
|
||||
period: string;
|
||||
model: string;
|
||||
source: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface HydrologyNearest {
|
||||
subtype: "river" | "stream" | "lake_or_pond" | "canal" | null;
|
||||
name: string | null;
|
||||
distance_m: number;
|
||||
}
|
||||
|
||||
export interface Hydrology {
|
||||
nearest: HydrologyNearest[];
|
||||
flood_risk_flag: boolean;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface GeotechRisk {
|
||||
seismic_intensity_balls: number | null;
|
||||
seismic_label: string;
|
||||
seismic_description: string;
|
||||
permafrost: boolean;
|
||||
industrial_within_500m: number;
|
||||
industrial_contamination_flag: boolean;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface MarketTrend {
|
||||
recent_avg_price_per_m2: number;
|
||||
prior_avg_price_per_m2: number;
|
||||
|
|
@ -109,6 +154,9 @@ export interface ParcelAnalysis {
|
|||
air_quality: ParcelAnalysisAirQuality | null;
|
||||
wind: ParcelAnalysisWind | null;
|
||||
weather?: ParcelAnalysisWeather | null;
|
||||
seasonal_weather?: SeasonalWeather | null;
|
||||
hydrology?: Hydrology;
|
||||
geotech_risk?: GeotechRisk;
|
||||
geology?: ParcelAnalysisGeology;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue