diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..db0364d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+.env
+__pycache__/
+./venv
+.idea
\ No newline at end of file
diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index 26d3352..0000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
deleted file mode 100644
index 5f8f784..0000000
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index c354098..f67aa98 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,7 +1,7 @@
-
-
+
+
+
-
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
index 1906244..de28711 100644
--- a/.idea/modules.xml
+++ b/.idea/modules.xml
@@ -2,7 +2,7 @@
-
+
\ No newline at end of file
diff --git a/.idea/nonlinear_process_manager.iml b/.idea/nonlinear_process_manager.iml
deleted file mode 100644
index 2c80e12..0000000
--- a/.idea/nonlinear_process_manager.iml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..82e164f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,4 @@
+# industrial-emission-impact-forecaster
+AI-powered system for predicting and mapping harmful substance concentrations. Leverages historical emission data from industrial sources, terrain analysis, and weather patterns to create real-time pollution dispersion forecasts and visualizations.
+
+Visual interface prototype (click to make an access request): https://www.figma.com/design/o5duNLISrMPGriaWRyAwBy/ConcViewer-Online?node-id=0-1&t=RTBJ7TECdi2ffIIN-1
diff --git a/app.py b/app.py
deleted file mode 100644
index 5596b44..0000000
--- a/app.py
+++ /dev/null
@@ -1,16 +0,0 @@
-# This is a sample Python script.
-
-# Press Shift+F10 to execute it or replace it with your code.
-# Press Double Shift to search everywhere for classes, files, tool windows, actions, and settings.
-
-
-def print_hi(name):
- # Use a breakpoint in the code line below to debug your script.
- print(f'Hi, {name}') # Press Ctrl+F8 to toggle the breakpoint.
-
-
-# Press the green button in the gutter to run the script.
-if __name__ == '__main__':
- print_hi('PyCharm')
-
-# See PyCharm help at https://www.jetbrains.com/help/pycharm/
diff --git a/backend/__init__.py b/backend/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/__init__.py b/backend/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/app.py b/backend/app/app.py
new file mode 100644
index 0000000..f411231
--- /dev/null
+++ b/backend/app/app.py
@@ -0,0 +1,32 @@
+from fastapi import FastAPI
+from fastapi.staticfiles import StaticFiles
+from .routers import pages, sources_api, simulation_params_api, layout, substances_api
+from .core.config import STATIC_DIR
+from .database import init_models
+from contextlib import asynccontextmanager
+from . import models
+
+"""
+ как только будет Alembic - удалить init_model
+ заменить app = FastAPI(lifespan=lifespan) на app = FastAPI()
+"""
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ print("Пересоздание таблиц в БД...")
+ await init_models()
+ print("Таблицы готовы.")
+ yield
+ print("Завершение работы...")
+
+
+app = FastAPI(lifespan=lifespan)
+
+app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
+
+app.include_router(pages.router)
+app.include_router(sources_api.router)
+app.include_router(simulation_params_api.router)
+app.include_router(layout.router)
+app.include_router(substances_api.router)
diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
new file mode 100644
index 0000000..15e725f
--- /dev/null
+++ b/backend/app/core/config.py
@@ -0,0 +1,47 @@
+from pathlib import Path
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+BASE_DIR = Path(__file__).resolve().parents[3]
+
+TEMPLATES_DIR = BASE_DIR / "templates"
+STATIC_DIR = BASE_DIR / "static"
+
+
+
+
+class Settings(BaseSettings):
+ # --- База данных ---
+ database_hostname: str
+ database_port: str
+ database_password: str
+ database_name: str
+ database_username: str
+
+ # --- Авторизация (На будущее) ---
+# secret_key: str
+# algorithm: str
+# access_token_expiration_minutes: int
+
+ # --- Yandex карты ---
+ ymaps_api_key: str
+ ymaps_lang: str = "ru_RU"
+ yweather_api_key: str
+
+ # extra="ignore" позволяет иметь в .env лишние переменные без ошибок
+ model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
+
+ @property
+ def template_config(self) -> dict:
+ return {
+ "YMAPS_API_KEY": self.ymaps_api_key,
+ "YMAPS_LANG": self.ymaps_lang,
+ "YWEATHER_API_KEY": self.yweather_api_key,
+ }
+
+try:
+ settings = Settings()
+except Exception as e:
+ print(f"ОШИБКА ЗАГРУЗКИ КОНФИГУРАЦИИ: {e}")
+ raise e
+
+TEMPLATE_CONFIG = settings.template_config
diff --git a/backend/app/core/emissions_calculation_math/__init__.py b/backend/app/core/emissions_calculation_math/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/core/emissions_calculation_math/coloring.py b/backend/app/core/emissions_calculation_math/coloring.py
new file mode 100644
index 0000000..284c793
--- /dev/null
+++ b/backend/app/core/emissions_calculation_math/coloring.py
@@ -0,0 +1,34 @@
+import numpy as np
+
+
+def colorize_tile_numpy(conc_grid, pdk=0.008, alpha_bg=110):
+ c = conc_grid.flatten()
+
+ # Цвета (R, G, B, Alpha)
+ color_green = np.array([46, 204, 113, alpha_bg])
+ color_yellow = np.array([241, 196, 15, 180])
+ color_orange = np.array([230, 126, 34, 210])
+ color_red = np.array([231, 76, 60, 240])
+
+ rgba = np.zeros((len(c), 4), dtype=np.float32)
+
+ t0, t1, t2, t3 = pdk * 0.001, pdk * 0.2, pdk * 0.6, pdk * 1.0
+
+ m_bg = c <= t0
+ m_gy = (c > t0) & (c <= t1)
+ m_yo = (c > t1) & (c <= t2)
+ m_or = (c > t2)
+
+ rgba[m_bg] = color_green
+
+ if np.any(m_gy):
+ f = (c[m_gy] - t0) / (t1 - t0)
+ rgba[m_gy] = color_green * (1 - f[:, None]) + color_yellow * f[:, None]
+ if np.any(m_yo):
+ f = (c[m_yo] - t1) / (t2 - t1)
+ rgba[m_yo] = color_yellow * (1 - f[:, None]) + color_orange * f[:, None]
+ if np.any(m_or):
+ f = np.clip((c[m_or] - t2) / (t3 - t2), 0, 1)
+ rgba[m_or] = color_orange * (1 - f[:, None]) + color_red * f[:, None]
+
+ return rgba.astype(np.uint8).reshape((256, 256, 4))
diff --git a/backend/app/core/emissions_calculation_math/discretization.py b/backend/app/core/emissions_calculation_math/discretization.py
new file mode 100644
index 0000000..4c55b10
--- /dev/null
+++ b/backend/app/core/emissions_calculation_math/discretization.py
@@ -0,0 +1,103 @@
+import numpy as np
+from matplotlib.path import Path
+from backend.app.core.emissions_calculation_math.math_numba import lat_lon_to_meters_yandex_single
+
+
+def discretize_sources(sources_db):
+ v_xs, v_ys, v_rates, v_heights = [], [], [], []
+ v_sy0, v_sz0, v_settling = [], [], [] # Массивы для начального расширения
+
+ MAX_POINTS_PER_SOURCE = 200
+ MIN_STEP_METERS = 100.0
+
+ for s in sources_db:
+ s_type_str = s.type.value if hasattr(s.type, 'value') else str(s.type)
+
+ settling_vel = s.pollutant.settling_velocity if hasattr(s, 'pollutant') and s.pollutant else 0.0
+
+ if s_type_str == "point" or not s.coordinates:
+ mx, my = lat_lon_to_meters_yandex_single(s.latitude, s.longitude)
+ v_xs.append(mx)
+ v_ys.append(my)
+ v_rates.append(s.emission_rate)
+ v_heights.append(s.height)
+ v_sy0.append(0.0)
+ v_sz0.append(0.0)
+ v_settling.append(settling_vel)
+
+ elif s_type_str == "line":
+ coords = s.coordinates
+ line_points = []
+ for i in range(len(coords) - 1):
+ p1_x, p1_y = lat_lon_to_meters_yandex_single(coords[i][0], coords[i][1])
+ p2_x, p2_y = lat_lon_to_meters_yandex_single(coords[i + 1][0], coords[i + 1][1])
+
+ dist = np.hypot(p2_x - p1_x, p2_y - p1_y)
+
+ step = max(MIN_STEP_METERS, dist / MAX_POINTS_PER_SOURCE)
+ num_points = max(2, int(dist / step))
+
+ xs = np.linspace(p1_x, p2_x, num_points, endpoint=True)
+ ys = np.linspace(p1_y, p2_y, num_points, endpoint=True)
+ line_points.extend(list(zip(xs, ys)))
+
+ if not line_points:
+ continue
+
+ unique_points = list(dict.fromkeys(line_points))
+ rate_per_point = s.emission_rate / len(unique_points)
+
+ for x, y in unique_points:
+ v_xs.append(x)
+ v_ys.append(y)
+ v_rates.append(rate_per_point)
+ v_heights.append(s.height)
+ v_sy0.append(step)
+ v_sz0.append(5.0)
+ v_settling.append(settling_vel)
+
+ elif s_type_str == "polygon":
+ coords = s.coordinates
+ if coords[0] != coords[-1]:
+ coords.append(coords[0])
+
+ poly_points = [lat_lon_to_meters_yandex_single(c[0], c[1]) for c in coords]
+ poly_path = Path(poly_points)
+
+ xs, ys = zip(*poly_points)
+ min_x, max_x = min(xs), max(xs)
+ min_y, max_y = min(ys), max(ys)
+
+ area = (max_x - min_x) * (max_y - min_y)
+ dynamic_step = max(MIN_STEP_METERS, np.sqrt(area / MAX_POINTS_PER_SOURCE))
+
+ grid_x, grid_y = np.meshgrid(
+ np.arange(min_x, max_x, dynamic_step),
+ np.arange(min_y, max_y, dynamic_step)
+ )
+ flat_grid = np.vstack((grid_x.flatten(), grid_y.flatten())).T
+ mask = poly_path.contains_points(flat_grid)
+ inside_points = flat_grid[mask]
+
+ if len(inside_points) == 0:
+ inside_points = [[np.mean(xs), np.mean(ys)]]
+
+ rate_per_point = s.emission_rate / len(inside_points)
+
+ for x, y in inside_points:
+ v_xs.append(x)
+ v_ys.append(y)
+ v_rates.append(rate_per_point)
+ v_heights.append(s.height)
+ v_sy0.append(dynamic_step)
+ v_sz0.append(5.0)
+ v_settling.append(settling_vel)
+ return (
+ np.array(v_xs, dtype=np.float32),
+ np.array(v_ys, dtype=np.float32),
+ np.array(v_rates, dtype=np.float32),
+ np.array(v_heights, dtype=np.float32),
+ np.array(v_sy0, dtype=np.float32),
+ np.array(v_sz0, dtype=np.float32),
+ np.array(v_settling, dtype=np.float32)
+ )
\ No newline at end of file
diff --git a/backend/app/core/emissions_calculation_math/math_numba.py b/backend/app/core/emissions_calculation_math/math_numba.py
new file mode 100644
index 0000000..e9a2246
--- /dev/null
+++ b/backend/app/core/emissions_calculation_math/math_numba.py
@@ -0,0 +1,105 @@
+# app/core/emissions_calculation_math/math_numba.py
+import numpy as np
+from numba import njit, prange
+
+E_WGS84 = 0.0818191908426215
+R_EARTH = 6378137.0
+
+
+@njit(fastmath=True)
+def lat_lon_to_meters_yandex_single(lat, lon):
+ if lat > 89.9: lat = 89.9
+ if lat < -89.9: lat = -89.9
+ phi = lat * (np.pi / 180.0)
+ lam = lon * (np.pi / 180.0)
+ x = R_EARTH * lam
+ sin_phi = np.sin(phi)
+ con = E_WGS84 * sin_phi
+ com = (1.0 - con) / (1.0 + con)
+ factor = np.power(com, 0.5 * E_WGS84)
+ tan_val = np.tan(0.5 * (np.pi * 0.5 - phi))
+ ts = tan_val / factor
+ y = -R_EARTH * np.log(ts)
+ return x, y
+
+
+@njit(fastmath=True, parallel=True)
+def calculate_concentration_chunk(
+ x_grid_flat, y_grid_flat, src_xs, src_ys, src_rates, src_heights,
+ src_sy0, src_sz0, src_settling_vel,
+ u, base_wind_rad
+):
+ n_pixels = len(x_grid_flat)
+ n_sources = len(src_xs)
+ result = np.zeros(n_pixels, dtype=np.float32)
+
+ for i in prange(n_pixels):
+ px = x_grid_flat[i]
+ py = y_grid_flat[i]
+ val = 0.0
+
+ for k in range(n_sources):
+ dx = px - src_xs[k]
+ dy = py - src_ys[k]
+
+ dist = np.sqrt(dx * dx + dy * dy)
+ if dist < 1.0: dist = 1.0
+
+ swirl_angle = dist * 0.000015 * (15.0 / max(u, 1.0))
+ current_angle = base_wind_rad + swirl_angle
+
+ cos_a = np.cos(current_angle)
+ sin_a = np.sin(current_angle)
+
+ downwind = dx * cos_a + dy * sin_a
+ crosswind = -dx * sin_a + dy * cos_a
+
+ # Мягкое отсечение против ветра (оставляем только ближнюю зону для диффузии)
+ if downwind < -300.0:
+ continue
+
+ wiggle = np.sin(dist / 1500.0) * (dist * 0.08)
+ crosswind += wiggle
+
+ Q = src_rates[k] * 1000.0 # Перевод в мг/с
+
+ # ФИЗИКА: Влияние плотности/оседания частиц
+ # Если это тяжелые частицы (PM10), то H со временем уменьшается, и шлейф "падает" на землю
+ time_in_air = dist / max(u, 0.5)
+ effective_height = src_heights[k] - (src_settling_vel[k] * time_in_air)
+ if effective_height < 0:
+ effective_height = 0.0 # Осело на землю
+
+
+ # --- 1. ФИЗИЧНОЕ РАДИАЛЬНОЕ ПЯТНО (PUFF) ---
+ # Это диффузия газа против ветра и вокруг трубы.
+ # Зависит от Q, высоты трубы (src_heights) и скорости ветра (u).
+ puff_radius = src_sy0[k] + 20.0
+ puff_sigma_z = src_sz0[k] + 10.0
+
+ puff_peak = Q / (6.283185 * max(u, 0.5) * puff_radius * puff_sigma_z)
+ puff_exp_xy = np.exp(-0.5 * (dist / puff_radius) ** 2)
+ puff_exp_z = np.exp(-0.5 * (src_heights[k] / puff_sigma_z) ** 2)
+
+ puff_val = puff_peak * puff_exp_xy * puff_exp_z
+
+ # --- 2. ОСНОВНОЙ ШЛЕЙФ ПО ВЕТРУ (PLUME) ---
+ plume_val = 0.0
+ if downwind > 0:
+ sigma_y = src_sy0[k] + 0.18 * downwind / np.sqrt(1.0 + 0.0001 * downwind) + 20.0
+ sigma_z = src_sz0[k] + 0.10 * downwind / np.sqrt(1.0 + 0.0015 * downwind) + 10.0
+
+ peak = Q / (6.283185 * max(u, 0.5) * sigma_y * sigma_z)
+ exp_y = np.exp(-0.5 * (crosswind / sigma_y) ** 2)
+ exp_z = np.exp(-0.5 * (src_heights[k] / sigma_z) ** 2)
+
+ plume_val = peak * exp_y * exp_z
+
+ # Берем максимум из радиального пятна и шлейфа, чтобы в центре трубы
+ # они плавно перетекали друг в друга без искусственного удвоения яркости.
+ # После этого суммируем с остальными источниками (суперпозиция).
+ val += max(plume_val, puff_val)
+
+ result[i] = val
+
+ return result
\ No newline at end of file
diff --git a/backend/app/core/emissions_calculation_math/state.py b/backend/app/core/emissions_calculation_math/state.py
new file mode 100644
index 0000000..322d0b7
--- /dev/null
+++ b/backend/app/core/emissions_calculation_math/state.py
@@ -0,0 +1,22 @@
+'''Singleton хранения кэша выбросов'''
+
+
+class PollutionState:
+ _instance = None
+
+ def __new__(cls):
+ if cls._instance is None:
+ cls._instance = super(PollutionState, cls).__new__(cls)
+ cls._instance.cached_sources = {} #substances_id: кэшированные данные
+ cls._instance.sources_time = {} #substances_id: время
+ cls._instance.cached_params = None
+ cls._instance.params_time = 0
+ return cls._instance
+
+ def invalidate_sources(self):
+ self.cached_sources = None
+ self.sources_time = 0
+
+
+# Глобальный объект кэша
+pollution_state = PollutionState()
diff --git a/backend/app/core/templates.py b/backend/app/core/templates.py
new file mode 100644
index 0000000..f598300
--- /dev/null
+++ b/backend/app/core/templates.py
@@ -0,0 +1,7 @@
+from fastapi.templating import Jinja2Templates
+from backend.app.core.config import TEMPLATES_DIR, TEMPLATE_CONFIG
+
+templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
+
+# Теперь переменная 'config' доступна во всех HTML файлах
+templates.env.globals["config"] = TEMPLATE_CONFIG
\ No newline at end of file
diff --git a/backend/app/database.py b/backend/app/database.py
new file mode 100644
index 0000000..12df16e
--- /dev/null
+++ b/backend/app/database.py
@@ -0,0 +1,30 @@
+from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
+from sqlalchemy.orm import sessionmaker, DeclarativeBase
+from .core.config import settings
+
+
+SQLALCHEMY_DATABASE_URL = f'postgresql+asyncpg://{settings.database_username}:{settings.database_password}@' \
+ f'{settings.database_hostname}:{settings.database_port}/{settings.database_name}'
+
+
+engine = create_async_engine(SQLALCHEMY_DATABASE_URL)
+
+SessionLocal = sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False)
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+async def get_db():
+ async with SessionLocal() as db:
+ yield db
+
+"""
+ как только будет Alembic - удалить init_model
+"""
+
+
+async def init_models():
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
\ No newline at end of file
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
new file mode 100644
index 0000000..45c6caf
--- /dev/null
+++ b/backend/app/models/__init__.py
@@ -0,0 +1,2 @@
+from .sources import Sources
+from .simulation_params import SimulationParams
\ No newline at end of file
diff --git a/backend/app/models/simulation_params.py b/backend/app/models/simulation_params.py
new file mode 100644
index 0000000..bcb117f
--- /dev/null
+++ b/backend/app/models/simulation_params.py
@@ -0,0 +1,12 @@
+from backend.app.database import Base
+from sqlalchemy import Column, String, Integer, func, TIMESTAMP, Float
+
+
+class SimulationParams(Base):
+ __tablename__ = "simulation_params"
+
+ id = Column(Integer, primary_key=True, nullable=False)
+ wind_speed = Column(Float, nullable=False)
+ wind_direction = Column(Float, nullable=False) # Тут будет измеряться вградусах
+ stability_class = Column(String, nullable=False)
+ updated_at = Column(TIMESTAMP, nullable=False, server_default=func.now())
diff --git a/backend/app/models/sources.py b/backend/app/models/sources.py
new file mode 100644
index 0000000..d90e287
--- /dev/null
+++ b/backend/app/models/sources.py
@@ -0,0 +1,27 @@
+from ..database import Base
+from sqlalchemy import Column, String, Integer, func, TIMESTAMP, Float, JSON, ForeignKey, SmallInteger, Enum as SQLEnum
+from enum import Enum
+from sqlalchemy.orm import relationship
+
+
+class SourceTypeEnum(str, Enum):
+ point = "point"
+ line = "line"
+ polygon = "polygon"
+
+
+class Sources(Base):
+ __tablename__ = "sources"
+
+ id = Column(Integer, primary_key=True, nullable=False, index=True)
+ name = Column(String, nullable=False, index=True)
+ type = Column(SQLEnum(SourceTypeEnum), default=SourceTypeEnum.point, nullable=False, index=True)
+ latitude = Column(Float, nullable=False)
+ longitude = Column(Float, nullable=False)
+ height = Column(Float, nullable=False)
+ emission_rate = Column(Float, nullable=False)
+ substance_id = Column(SmallInteger, ForeignKey("substances.id", ondelete="CASCADE"), nullable=False)
+ substance = relationship("Substances", lazy="selectin")
+ coordinates = Column(JSON, nullable=False)
+ created_at = Column(TIMESTAMP, server_default=func.now(), index=True)
+
diff --git a/backend/app/models/substances.py b/backend/app/models/substances.py
new file mode 100644
index 0000000..829c611
--- /dev/null
+++ b/backend/app/models/substances.py
@@ -0,0 +1,15 @@
+from sqlalchemy import Column, Integer, String, SmallInteger, Float
+from ..database import Base
+
+
+class Substances(Base):
+ __tablename__ = "substances"
+
+ id = Column(SmallInteger, nullable=False, primary_key=True, index=True)
+ name = Column(String, nullable=False, unique=True, index=True)
+ short_name = Column(String, nullable=False, unique=True, index=True)
+ mcl = Column(Float, nullable=False) # MCL = ПДК
+ density = Column(Float, nullable=False)
+ settling_velocity = Column(Float, nullable=False) # скорость оседания
+ # hazard_level = Column(Integer, nullable=False) - может понадобиться если будем пилить справочную информацию
+ # custom_color=Column(String, default="#E74C3C")-если захочется шлейф задавать кастомным цветом для каждого вещества
diff --git a/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/repositories/simulation_params.py b/backend/app/repositories/simulation_params.py
new file mode 100644
index 0000000..38fc23a
--- /dev/null
+++ b/backend/app/repositories/simulation_params.py
@@ -0,0 +1,27 @@
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, Sequence
+from ..models.simulation_params import SimulationParams
+from ..schemas.simulation_params import SimulationParamsCreate
+
+
+class SimulationParamsRepository:
+ def __init__(self, db: AsyncSession):
+ self.db = db
+
+ async def get_simulation_params(self) -> Sequence[SimulationParams]:
+ stmt = select(SimulationParams)
+ result = await self.db.execute(stmt)
+ return result.scalars().all()
+
+ async def create_simulation_params(
+ self,
+ new_simulation_params: SimulationParamsCreate
+ ) -> SimulationParams | None:
+ simulation_params_data = new_simulation_params.model_dump()
+ simulation_params = SimulationParams(**simulation_params_data)
+
+ self.db.add(simulation_params)
+ await self.db.commit()
+ await self.db.refresh(simulation_params)
+
+ return simulation_params
diff --git a/backend/app/repositories/sources.py b/backend/app/repositories/sources.py
new file mode 100644
index 0000000..b12c91a
--- /dev/null
+++ b/backend/app/repositories/sources.py
@@ -0,0 +1,44 @@
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, Sequence
+from ..models.sources import Sources
+from ..schemas.sources import SourcesCreate
+
+
+class SourcesRepository:
+ def __init__(self, db:AsyncSession):
+ self.db = db
+
+ async def get_all_sources(self) -> Sequence[Sources]:
+ stmt = select(Sources)
+ result = await self.db.execute(stmt)
+ return result.scalars().all()
+
+ async def get_sources_by_substance(self, substance_id: id) -> Sequence[Sources]:
+ stmt = select(Sources).where(Sources.substance_id == substance_id)
+
+ result = await self.db.execute(stmt)
+ return result.scalars().all()
+
+ async def add_source(self, source_schema: SourcesCreate) -> Sources:
+ source_data = source_schema.model_dump()
+
+ source = Sources(**source_data)
+
+ self.db.add(source)
+ await self.db.commit()
+ await self.db.refresh(source)
+ return source
+
+ async def del_source(self, source_id: int) -> bool:
+ stmt = select(Sources).where(Sources.id==source_id)
+
+ result = await self.db.execute(stmt)
+ source = result.scalars().first()
+
+ if not source:
+ return False
+
+ await self.db.delete(source)
+ await self.db.commit()
+
+ return True
diff --git a/backend/app/repositories/substance.py b/backend/app/repositories/substance.py
new file mode 100644
index 0000000..1388b04
--- /dev/null
+++ b/backend/app/repositories/substance.py
@@ -0,0 +1,26 @@
+from sqlalchemy.ext.asyncio import AsyncSession
+from ..models.substances import Substances
+from ..schemas.substance import SubstanceCreate
+from sqlalchemy import select, Sequence
+
+class SubstancesRepository:
+ def __init__(self, db: AsyncSession):
+ self.db = db
+
+ async def get_all_substances(self) -> Sequence[Substances]:
+ stmt = select(Substances)
+ result = await self.db.execute(stmt)
+ return result.scalars().all()
+
+ async def get_substance_by_id(self, substance_id: int) -> Substances | None:
+ stmt = select(Substances).where(Substances.id == substance_id)
+ result = await self.db.execute(stmt)
+ return result.scalars().first()
+
+ async def add_substance(self, substance_schema:SubstanceCreate) -> Substances:
+ substance_data = substance_schema.model_dump()
+ substance = Substances(**substance_data)
+ self.db.add(substance)
+ await self.db.commit()
+ await self.db.refresh(substance)
+ return substance
diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/routers/layout.py b/backend/app/routers/layout.py
new file mode 100644
index 0000000..c851a6b
--- /dev/null
+++ b/backend/app/routers/layout.py
@@ -0,0 +1,28 @@
+from fastapi import APIRouter, Depends, Response, status
+from sqlalchemy.ext.asyncio import AsyncSession
+from io import BytesIO
+from ..database import get_db
+from ..services.layout_service import LayoutService
+from ..services.simulation_params_service import SimulationParamsService
+from ..schemas.simulation_params import SimulationParamsCreate
+
+router = APIRouter(tags=['simulation'], prefix="/api/simulation")
+
+
+@router.get("/tiles/{substance_id}/{z}/{x}/{y}.png")
+async def get_pollution_tile(substance_id: int, z: int, x: int, y: int, db: AsyncSession = Depends(get_db)):
+ service = LayoutService(db)
+ image = await service.render_tile(substance_id, x, y, z)
+ buf = BytesIO()
+ image.save(buf, format="PNG")
+ return Response(content=buf.getvalue(), media_type="image/png")
+
+
+@router.post("/params", status_code=status.HTTP_201_CREATED)
+async def update_simulation_params(
+ new_params: SimulationParamsCreate,
+ db: AsyncSession = Depends(get_db)
+):
+ params_service = SimulationParamsService(db)
+ await params_service.create_simulation_params(new_params)
+ return {"status": "ok"}
\ No newline at end of file
diff --git a/backend/app/routers/pages.py b/backend/app/routers/pages.py
new file mode 100644
index 0000000..9627d28
--- /dev/null
+++ b/backend/app/routers/pages.py
@@ -0,0 +1,61 @@
+from fastapi import APIRouter, Request, HTTPException, status, Depends
+from ..services.source_service import SourceService
+from ..services.simulation_params_service import SimulationParamsService
+from sqlalchemy.ext.asyncio import AsyncSession
+from ..database import get_db
+from ..core.templates import templates
+
+router = APIRouter(tags=['pages'])
+
+
+@router.get('/')
+async def index(request: Request, db:AsyncSession = Depends(get_db)):
+ source_service = SourceService(db)
+ simulation_params_service = SimulationParamsService(db)
+
+ sources = await source_service.get_all_sources()
+ simulation_params = await simulation_params_service.get_simulation_params()
+
+ template = templates.TemplateResponse("index.html", {
+ "request": request,
+ "sources": sources,
+ "simulation_params": simulation_params
+ })
+ return template
+
+
+@router.get('/history')
+async def history(request:Request):
+ template = templates.TemplateResponse("history.html", {"request": request})
+ return template
+
+
+@router.get('/recommendations')
+async def recommendations(request:Request):
+ template = templates.TemplateResponse("recommendations.html", {"request": request})
+ return template
+
+
+@router.get('/forecasting')
+async def forecasting(request:Request):
+ template = templates.TemplateResponse("forecasting.html", {"request":request})
+ return template
+
+
+@router.get('/enterprise')
+async def enterprise(request: Request, db: AsyncSession = Depends(get_db)):
+
+ source_service = SourceService(db)
+ sources = await source_service.get_all_sources()
+ template = templates.TemplateResponse("enterprise.html", {
+ "request": request,
+ "sources": sources})
+ return template
+
+
+@router.get('/login')
+async def login(request: Request):
+ template = templates.TemplateResponse("login.html", {"request": request})
+ return template
+
+
diff --git a/backend/app/routers/simulation_params_api.py b/backend/app/routers/simulation_params_api.py
new file mode 100644
index 0000000..1a78610
--- /dev/null
+++ b/backend/app/routers/simulation_params_api.py
@@ -0,0 +1,25 @@
+from ..services.simulation_params_service import SimulationParamsService
+from sqlalchemy.ext.asyncio import AsyncSession
+from ..database import get_db
+from fastapi import APIRouter, HTTPException, status, Depends
+from ..schemas.simulation_params import SimulationParamsResponse, SimulationParamsCreate
+
+router = APIRouter(tags=['simulation_params'], prefix="/api/simulation_params")
+
+@router.get("/")
+async def get_params(db: AsyncSession = Depends(get_db)):
+ params_service = SimulationParamsService(db)
+
+ result = await params_service.get_simulation_params()
+
+ return result
+
+
+@router.post("/", status_code=status.HTTP_201_CREATED)
+async def create_params(new_params: SimulationParamsCreate,db: AsyncSession = Depends(get_db)):
+ params_service = SimulationParamsService(db)
+
+ await params_service.create_simulation_params(new_params)
+
+
+
diff --git a/backend/app/routers/sources_api.py b/backend/app/routers/sources_api.py
new file mode 100644
index 0000000..54fffbc
--- /dev/null
+++ b/backend/app/routers/sources_api.py
@@ -0,0 +1,44 @@
+from fastapi import APIRouter, HTTPException, status, Depends
+from ..services.source_service import SourceService
+from ..core.emissions_calculation_math.state import pollution_state
+from sqlalchemy.ext.asyncio import AsyncSession
+from ..database import get_db
+from ..schemas.sources import SourcesCreate, SourcesResponse
+from typing import List
+
+router = APIRouter(tags=['sources'], prefix="/api/sources")
+
+
+@router.get("/", response_model=List[SourcesResponse])
+async def get_all_sources(db: AsyncSession = Depends(get_db)):
+ source_service = SourceService(db)
+ result = await source_service.get_all_sources()
+
+ return result
+
+
+@router.post("/", status_code=status.HTTP_201_CREATED)
+async def create_source(new_source: SourcesCreate, db: AsyncSession = Depends(get_db)):
+ source_service = SourceService(db)
+ result = await source_service.add_source(new_source)
+ print(result)
+
+ # СБРАСЫВАЕМ КЭШ ПОСЛЕ Добавления ИСТОЧНИКА
+ pollution_state.invalidate_sources()
+
+ return result
+
+
+@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_source(id: int, db: AsyncSession = Depends(get_db)):
+ source_service = SourceService(db)
+ result = await source_service.delete_source(id)
+
+ if not result:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
+ detail="Пользователь с таким id не существует")
+
+ # СБРАСЫВАЕМ КЭШ ЛЭЙАУТА ПОСЛЕ ДОБАВЛЕНИЯ ИСТОЧНИКА
+ pollution_state.invalidate_sources()
+
+ return result
diff --git a/backend/app/routers/substances_api.py b/backend/app/routers/substances_api.py
new file mode 100644
index 0000000..9ebc757
--- /dev/null
+++ b/backend/app/routers/substances_api.py
@@ -0,0 +1,29 @@
+from fastapi import APIRouter, Depends, status
+from sqlalchemy.ext.asyncio import AsyncSession
+from typing import List
+from ..database import get_db
+from ..schemas.substance import SubstanceCreate, SubstanceResponse
+from ..services.substance_service import SubstanceService
+
+router = APIRouter(prefix="/api/substances", tags=["substances"])
+
+
+@router.get("", response_model=List[SubstanceResponse])
+async def get_all_substances(db: AsyncSession = Depends(get_db)):
+ substance_service = SubstanceService(db)
+ result = await substance_service.get_all_substances()
+ return result
+
+
+@router.get("/{id}", response_model=SubstanceResponse)
+async def get_substance_by_id(id: int, db: AsyncSession = Depends(get_db)):
+ substance_service = SubstanceService(db)
+ result = await substance_service.get_substance_by_id(id)
+ return result
+
+
+@router.post("", response_model=SubstanceResponse, status_code=status.HTTP_201_CREATED)
+async def create_substance(substance: SubstanceCreate, db: AsyncSession = Depends(get_db)):
+ substance_service = SubstanceService(db)
+ result = await substance_service.add_substance(substance)
+ return result
\ No newline at end of file
diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/schemas/simulation_params.py b/backend/app/schemas/simulation_params.py
new file mode 100644
index 0000000..775a2d1
--- /dev/null
+++ b/backend/app/schemas/simulation_params.py
@@ -0,0 +1,18 @@
+from pydantic import BaseModel, Field
+from typing import Annotated
+
+
+class SimulationParamsBase(BaseModel):
+ wind_speed: Annotated[float, Field(gt=0, lt=150)] # В метрах в секунду
+ wind_direction: float
+ stability_class: str
+
+
+class SimulationParamsCreate(SimulationParamsBase):
+ pass
+
+
+class SimulationParamsResponse(SimulationParamsBase):
+
+ class Config:
+ from_attributes = True
diff --git a/backend/app/schemas/sources.py b/backend/app/schemas/sources.py
new file mode 100644
index 0000000..5e4a426
--- /dev/null
+++ b/backend/app/schemas/sources.py
@@ -0,0 +1,26 @@
+from pydantic import BaseModel, Field
+from pydantic_extra_types.coordinate import Longitude, Latitude
+from typing import Annotated, Optional, List
+from ..models.sources import SourceTypeEnum
+
+
+class SourcesBase(BaseModel):
+ name: str
+ type: SourceTypeEnum = SourceTypeEnum.point
+ latitude: Latitude
+ longitude: Longitude
+ height: Annotated[float, Field(gt=0, lt=8000)]
+ emission_rate: float
+ substance_id: int
+ coordinates: Optional[List[List[float]]] = None
+
+
+class SourcesResponse(SourcesBase):
+ id: int
+
+ class Config:
+ from_attributes = True
+
+
+class SourcesCreate(SourcesBase):
+ pass
diff --git a/backend/app/schemas/substance.py b/backend/app/schemas/substance.py
new file mode 100644
index 0000000..ae3f79c
--- /dev/null
+++ b/backend/app/schemas/substance.py
@@ -0,0 +1,20 @@
+from pydantic import BaseModel
+
+
+class BaseSubstance(BaseModel):
+ name: str
+ short_name: str
+ mcl: float
+ density: float
+ settling_velocity: float
+
+
+class SubstanceCreate(BaseSubstance):
+ pass
+
+
+class SubstanceResponse(BaseSubstance):
+ id: int
+
+ class Config:
+ from_attributes = True
diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/services/layout_service.py b/backend/app/services/layout_service.py
new file mode 100644
index 0000000..67bc1a7
--- /dev/null
+++ b/backend/app/services/layout_service.py
@@ -0,0 +1,124 @@
+import numpy as np
+from PIL import Image
+from sqlalchemy.ext.asyncio import AsyncSession
+import time
+import logging
+
+from .source_service import SourceService
+from .simulation_params_service import SimulationParamsService
+from .substance_service import SubstanceService
+from backend.app.core.emissions_calculation_math.math_numba import calculate_concentration_chunk
+from ..core.emissions_calculation_math.state import pollution_state
+from ..core.emissions_calculation_math.discretization import discretize_sources
+from ..core.emissions_calculation_math.coloring import colorize_tile_numpy
+
+logger = logging.getLogger("uvicorn")
+
+
+class LayoutService:
+ def __init__(self, db: AsyncSession):
+ self.db = db
+ self.source_service = SourceService(db)
+ self.params_service = SimulationParamsService(db)
+ self.substance_service = SubstanceService(db)
+
+ def get_tile_bounds(self, tx, ty, zoom):
+ equator = 40075016.685578488
+ tile_size = equator / (2 ** zoom)
+ origin = equator / 2.0
+ min_x = tx * tile_size - origin
+ max_x = (tx + 1) * tile_size - origin
+ max_y = origin - ty * tile_size
+ min_y = origin - (ty + 1) * tile_size
+ return min_x, min_y, max_x, max_y
+
+ async def _get_prepared_sources(self, substance_id: int):
+ current_time = time.time()
+
+ # Инициализация словарей в кэше, если их еще нет (для безопасности)
+ if not isinstance(pollution_state.cached_sources, dict):
+ pollution_state.cached_sources = {}
+ pollution_state.sources_time = {}
+
+ # Проверка свежести кэша для КОНКРЕТНОГО вещества
+ if substance_id in pollution_state.cached_sources and \
+ (current_time - pollution_state.sources_time.get(substance_id, 0) < 2):
+ return pollution_state.cached_sources[substance_id]
+
+ # Обращение к БД через Сервисы
+ sources_db = await self.source_service.get_sources_by_substance(substance_id)
+ substance = await self.substance_service.get_substance_by_id(substance_id)
+
+ if not substance:
+ logger.error(f"Substance {substance_id} not found!")
+ mcl = 0.008 # Фолбэк ПДК
+ else:
+ mcl = substance.mcl
+
+ # Дискретизация (внутри вытянет settling_velocity благодаря selectinload в репозитории)
+ prepared_data = discretize_sources(sources_db)
+
+ # Сохраняем в кэш кортеж: (массивы_данных, ПДК_вещества)
+ pollution_state.cached_sources[substance_id] = (prepared_data, mcl)
+ pollution_state.sources_time[substance_id] = current_time
+
+ return pollution_state.cached_sources[substance_id]
+
+ async def render_tile(self, substance_id: int, tx: int, ty: int, tz: int):
+ if tz < 11:
+ return Image.new("RGBA", (256, 256), (0, 0, 0, 0))
+
+ min_x, min_y, max_x, max_y = self.get_tile_bounds(tx, ty, tz)
+ res = 256
+ px_size = (max_x - min_x) / res
+
+ current_time = time.time()
+ if pollution_state.cached_params is not None and (current_time - pollution_state.params_time < 2):
+ params = pollution_state.cached_params
+ else:
+ params_list = await self.params_service.get_simulation_params()
+ params = params_list[-1] if params_list else None
+ pollution_state.cached_params = params
+ pollution_state.params_time = current_time
+
+ u = float(params.wind_speed) if params else 3.0
+ wind_dir = float(params.wind_direction) if params else 180.0
+ wind_math_rad = np.radians((270 - wind_dir) % 360)
+
+ # Получаем данные И ПДК конкретного вещества
+ prepared_data, mcl = await self._get_prepared_sources(substance_id)
+
+ # Распаковываем 7 массивов (включая settling_velocity)
+ src_xs, src_ys, src_rates, src_heights, src_sy0, src_sz0, src_settling = prepared_data
+
+ BUFFER = 200000
+ mask = (src_xs > min_x - BUFFER) & (src_xs < max_x + BUFFER) & \
+ (src_ys > min_y - BUFFER) & (src_ys < max_y + BUFFER)
+
+ if not np.any(mask):
+ return Image.new("RGBA", (256, 256), (46, 204, 113, 110))
+
+ s_xs, s_ys = src_xs[mask], src_ys[mask]
+ s_rates, s_heights = src_rates[mask], src_heights[mask]
+ s_sy0, s_sz0 = src_sy0[mask], src_sz0[mask]
+ s_settling = src_settling[mask] # Маска для скоростей оседания
+
+ px_half = px_size / 2.0
+ xs = np.linspace(min_x + px_half, max_x - px_half, res, dtype=np.float32)
+ ys = np.linspace(max_y - px_half, min_y + px_half, res, dtype=np.float32)
+ xv, yv = np.meshgrid(xs, ys)
+
+ # Передаем s_settling в Numba!
+ conc_flat = calculate_concentration_chunk(
+ xv.ravel(), yv.ravel(),
+ s_xs, s_ys, s_rates, s_heights,
+ s_sy0, s_sz0, s_settling,
+ u, wind_math_rad
+ )
+
+ grid_conc = conc_flat.reshape((res, res))
+
+ # Передаем реальный MCL (ПДК) в раскраску
+ img_data = colorize_tile_numpy(grid_conc, pdk=mcl, alpha_bg=110)
+
+ return Image.fromarray(img_data, 'RGBA')
diff --git a/backend/app/services/simulation_params_service.py b/backend/app/services/simulation_params_service.py
new file mode 100644
index 0000000..893d7f1
--- /dev/null
+++ b/backend/app/services/simulation_params_service.py
@@ -0,0 +1,16 @@
+from sqlalchemy.ext.asyncio import AsyncSession
+from ..repositories.simulation_params import SimulationParamsRepository
+from ..schemas.simulation_params import SimulationParamsCreate
+
+
+class SimulationParamsService:
+ def __init__(self, db: AsyncSession):
+ self.repository = SimulationParamsRepository(db)
+
+ async def get_simulation_params(self):
+ result = await self.repository.get_simulation_params()
+ return result
+
+ async def create_simulation_params(self, new_simulation_params:SimulationParamsCreate):
+ result = await self.repository.create_simulation_params(new_simulation_params)
+ return result
diff --git a/backend/app/services/source_service.py b/backend/app/services/source_service.py
new file mode 100644
index 0000000..a4dc36e
--- /dev/null
+++ b/backend/app/services/source_service.py
@@ -0,0 +1,24 @@
+from sqlalchemy.ext.asyncio import AsyncSession
+from ..repositories.sources import SourcesRepository
+from ..schemas.sources import SourcesCreate
+
+
+class SourceService:
+ def __init__(self, db: AsyncSession):
+ self.repository = SourcesRepository(db)
+
+ async def add_source(self, source_schema: SourcesCreate):
+ result = await self.repository.add_source(source_schema)
+ return result
+
+ async def delete_source(self, source_id: int):
+ result = await self.repository.del_source(source_id)
+ return result
+
+ async def get_all_sources(self):
+ result = await self.repository.get_all_sources()
+ return result
+
+ async def get_sources_by_substance(self, substance_id:int):
+ result = await self.repository.get_sources_by_substance(substance_id)
+ return result
diff --git a/backend/app/services/substance_service.py b/backend/app/services/substance_service.py
new file mode 100644
index 0000000..8d7bf02
--- /dev/null
+++ b/backend/app/services/substance_service.py
@@ -0,0 +1,20 @@
+from sqlalchemy.ext.asyncio import AsyncSession
+from ..repositories.substance import SubstancesRepository
+from ..schemas.substance import SubstanceCreate
+
+class SubstanceService:
+ def __init__(self, db: AsyncSession):
+ self.repository = SubstancesRepository(db)
+
+ async def get_all_substances(self):
+ result = await self.repository.get_all_substances()
+ return result
+
+ async def get_substance_by_id(self, substance_id: id):
+ result = await self.repository.get_substance_by_id(substance_id)
+ return result
+
+ async def add_substance(self, substance_schema: SubstanceCreate):
+ result = await self.repository.add_substance(substance_schema)
+ return result
+
diff --git a/natasha.html b/natasha.html
deleted file mode 100644
index 7b4bbcb..0000000
--- a/natasha.html
+++ /dev/null
@@ -1,724 +0,0 @@
-
-
-
-
- Моделирование выбросов H2S (версия 1.12)
-
-
-
-
-
-
-
-
-
-
Параметры ветра
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Источник выброса
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Вы уверены, что хотите удалить этот источник?
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..59c162d
Binary files /dev/null and b/requirements.txt differ
diff --git a/static/css/base.css b/static/css/base.css
new file mode 100644
index 0000000..ba1f771
--- /dev/null
+++ b/static/css/base.css
@@ -0,0 +1,276 @@
+:root {
+ --accent: #f07a2a;
+ --header-bg: #2f4b5b;
+ --panel-bg: #eef6fb;
+ --card-bg: linear-gradient(180deg,#f7fbfe,#e8f0f4);
+ --muted: #2f4350;
+ --text-dark: #1a2a38;
+}
+
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+html, body {
+ height: 100%;
+ font-family: "Segoe UI", Roboto, Arial, sans-serif;
+ background: #f3f6f8;
+ color: var(--muted);
+}
+
+a {
+ text-decoration: none;
+ color: inherit;
+}
+
+.header {
+ background: linear-gradient(180deg, #2f4b5b, #27424f);
+ padding: 18px 28px;
+ color: white;
+}
+
+.title-container {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.site-title {
+ font-size: 24px;
+ font-weight: 700;
+ margin: 0;
+ color: white;
+}
+
+.title-underline {
+ flex-grow: 1;
+ height: 3px;
+ background: rgba(255,255,255,0.3);
+ margin-left: 20px;
+}
+
+.navbar {
+ margin-top: 16px;
+ display: flex;
+ gap: 32px;
+}
+
+ .navbar a {
+ color: rgba(255,255,255,0.9);
+ font-size: 18px;
+ font-weight: 600;
+ padding: 6px 0;
+ position: relative;
+ }
+
+ .navbar a:hover, .navbar a.active {
+ color: var(--accent);
+ }
+
+ .navbar a.active::after {
+ content: '';
+ position: absolute;
+ bottom: -8px;
+ left: 0;
+ width: 100%;
+ height: 3px;
+ background: var(--accent);
+ border-radius: 2px;
+ }
+
+.container {
+ display: grid;
+ grid-template-columns: 360px 1fr;
+ gap: 24px;
+ padding: 24px 32px;
+ max-width: 1920px;
+ margin: 0 auto;
+}
+
+.panel {
+ background: var(--panel-bg);
+ padding: 24px;
+ border-radius: 14px;
+ border: 6px solid rgba(255,255,255,0.6);
+ box-shadow: 0 4px 20px rgba(0,0,0,0.08);
+ color: var(--text-dark);
+}
+
+ .panel h2 {
+ font-size: 19px;
+ font-weight: 700;
+ margin-bottom: 20px;
+ color: #222;
+ }
+
+.select {
+ width: 100%;
+ padding: 16px;
+ border-radius: 10px;
+ border: none;
+ background: #dfeaf1;
+ font-size: 17px;
+ font-weight: 500;
+ color: #333;
+}
+
+.form-row {
+ margin: 20px 0;
+}
+
+ .form-row label {
+ font-size: 16px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ cursor: pointer;
+ }
+
+.btn {
+ width: 100%;
+ padding: 16px;
+ margin-top: 16px;
+ background: linear-gradient(180deg, #ffa64d, #f07a2a);
+ color: white;
+ border: none;
+ border-radius: 16px;
+ font-size: 16px;
+ font-weight: 700;
+ cursor: pointer;
+ box-shadow: 0 6px 16px rgba(240,122,42,0.3);
+ transition: all 0.2s;
+}
+
+ .btn:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 10px 20px rgba(240,122,42,0.4);
+ background: linear-gradient(180deg, #ffb766, #f58c3b);
+ }
+
+.legend {
+ margin-top: 30px;
+}
+
+ .legend h3 {
+ font-size: 17px;
+ margin-bottom: 16px;
+ font-weight: 700;
+ }
+
+ .legend .row {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ margin: 12px 0;
+ font-size: 15.5px;
+ font-weight: 500;
+ }
+
+.swatch {
+ width: 36px;
+ height: 20px;
+ border-radius: 6px;
+ box-shadow: inset 0 1px 3px rgba(0,0,0,0.2);
+}
+
+ .swatch.ok {
+ background: #2ab34a;
+ }
+
+ .swatch.warn {
+ background: #d8f135;
+ }
+
+ .swatch.pdq {
+ background: #f0c12b;
+ }
+
+ .swatch.danger {
+ background: #e44b2f;
+ }
+
+.card {
+ background: var(--card-bg);
+ border-radius: 20px;
+ padding: 20px;
+ border: 6px solid rgba(255,255,255,0.6);
+ box-shadow: 0 8px 25px rgba(0,0,0,0.1);
+ display: flex;
+ flex-direction: column;
+}
+
+.map-header {
+ font-size: 14px;
+ color: var(--muted);
+ padding-bottom: 10px;
+ border-bottom: 1px solid rgba(0,0,0,0.08);
+}
+
+#map {
+ height: 720px;
+ border-radius: 16px;
+ overflow: hidden;
+ border: 5px solid rgba(0,0,0,0.05);
+ margin-top: 16px;
+ box-shadow: inset 0 4px 10px rgba(0,0,0,0.08);
+}
+
+.timeline-wrap {
+ margin-top: 20px;
+ text-align: center;
+}
+
+.time-ticks {
+ display: flex;
+ justify-content: space-between;
+ font-size: 13.5px;
+ color: #123;
+ padding: 0 12px;
+ font-weight: 500;
+}
+
+.timeline {
+ position: relative;
+ height: 20px;
+ background: #213642;
+ border-radius: 12px;
+ margin: 10px 24px;
+}
+
+.handle {
+ position: absolute;
+ top: -16px;
+ left: 50%;
+ transform: translateX(-50%);
+ pointer-events: none;
+}
+
+ .handle .triangle {
+ width: 0;
+ height: 0;
+ border-left: 12px solid transparent;
+ border-right: 12px solid transparent;
+ border-bottom: 16px solid var(--accent);
+ margin-bottom: 6px;
+ }
+
+ .handle .dot {
+ width: 22px;
+ height: 22px;
+ background: var(--accent);
+ border-radius: 50%;
+ box-shadow: 0 6px 12px rgba(240,122,42,0.5);
+ }
+
+@media (max-width: 1100px) {
+ .container {
+ grid-template-columns: 1fr;
+ padding: 16px;
+ }
+
+ #map {
+ height: 560px;
+ }
+}
diff --git a/static/css/enterprise.css b/static/css/enterprise.css
new file mode 100644
index 0000000..238ab0e
--- /dev/null
+++ b/static/css/enterprise.css
@@ -0,0 +1,460 @@
+html, body {
+ height: 100vh;
+ overflow: hidden;
+ margin: 0;
+ padding: 0;
+}
+
+.container {
+ display: grid;
+ grid-template-columns: 380px 1fr;
+ gap: 24px;
+ height: calc(100vh - 120px);
+ overflow: hidden;
+ padding: 0 24px 24px 24px;
+}
+
+.panel {
+ height: 100%;
+ overflow-y: auto;
+ background: #eef6fb;
+ padding: 24px;
+ border-radius: 12px;
+ border: 6px solid rgba(255,255,255,0.6);
+}
+
+.card {
+ height: 100%;
+ border-radius: 18px;
+ padding: 12px;
+ border: 6px solid rgba(255,255,255,0.6);
+ background: linear-gradient(180deg,#f7fbfe,#e8f0f4);
+ position: relative;
+ display: flex;
+ flex-direction: column;
+}
+
+#map {
+ width: 100%;
+ flex: 1;
+ border-radius: 14px;
+ border: 4px solid rgba(0,0,0,0.04);
+ min-height: 500px;
+ position: relative;
+ z-index: 1;
+}
+
+.input-pill {
+ width: 100%;
+ padding: 14px 18px;
+ background: #c8d6e0;
+ border-radius: 30px;
+ border: none;
+ text-align: center;
+ font-size: 17px;
+ font-weight: 600;
+ color: #2c3e50;
+ margin-bottom: 15px;
+}
+
+.add-source {
+ margin-top: 20px;
+ background: linear-gradient(180deg, #95a5a6, #7f8c8d);
+ width: 100%;
+ padding: 12px;
+ color: white;
+ border: none;
+ border-radius: 8px;
+ font-weight: 600;
+ cursor: pointer;
+ font-size: 14px;
+}
+
+.add-source:hover {
+ background: linear-gradient(180deg, #a0b0b1, #95a5a6);
+}
+
+.enterprise-source-item {
+ margin-top: 15px;
+ background: white;
+ padding: 14px;
+ border-radius: 12px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 15px;
+ box-shadow: 0 3px 10px rgba(0,0,0,0.08);
+}
+
+.btn-delete {
+ background: #e74c3c;
+ color: white;
+ border: none;
+ padding: 8px 16px;
+ border-radius: 20px;
+ font-size: 13px;
+ cursor: pointer;
+}
+
+.enterprise-legend-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin: 12px 0;
+ font-size: 15px;
+}
+
+.swatch {
+ width: 20px;
+ height: 10px;
+ border-radius: 2px;
+}
+
+.swatch.ok { background-color: #00FF00; }
+.swatch.warn { background-color: #FFFF00; }
+.swatch.pdq { background-color: #FFA500; }
+.swatch.danger { background-color: #FF0000; }
+
+.select.styled {
+ width: 100%;
+ padding: 14px 18px;
+ background: #c8d6e0;
+ border-radius: 30px;
+ border: none;
+ text-align: center;
+ font-size: 17px;
+ font-weight: 600;
+ color: #2c3e50;
+ margin-bottom: 15px;
+ cursor: pointer;
+ position: relative;
+}
+
+.select.styled .arrow {
+ position: absolute;
+ right: 18px;
+}
+
+.results-panel {
+ margin-top: 20px;
+ padding: 15px;
+ background: white;
+ border-radius: 8px;
+ font-size: 13px;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+}
+
+.calculation-status {
+ padding: 10px;
+ background: #fff3cd;
+ border: 1px solid #ffeaa7;
+ border-radius: 4px;
+ margin: 10px 0;
+ font-size: 13px;
+}
+
+.legend {
+ position: absolute;
+ bottom: 120px;
+ right: 20px;
+ background: white;
+ padding: 10px;
+ border-radius: 5px;
+ box-shadow: 0 2px 5px rgba(0,0,0,0.2);
+ z-index: 1000;
+}
+
+.legend-item {
+ display: flex;
+ align-items: center;
+ margin: 5px 0;
+ font-size: 12px;
+}
+
+.legend-color {
+ width: 20px;
+ height: 10px;
+ margin-right: 5px;
+ border-radius: 2px;
+}
+
+.modal {
+ display: none;
+ position: fixed;
+ z-index: 2000;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0,0,0,0.5);
+}
+
+.modal-content {
+ background-color: white;
+ margin: 15% auto;
+ padding: 20px;
+ border-radius: 5px;
+ width: 350px;
+ box-shadow: 0 3px 9px rgba(0,0,0,0.2);
+}
+
+.modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 15px;
+}
+
+.modal-title {
+ margin: 0;
+ font-size: 18px;
+}
+
+.close {
+ font-size: 24px;
+ cursor: pointer;
+}
+
+.modal-footer {
+ display: flex;
+ justify-content: flex-end;
+ margin-top: 20px;
+}
+
+.modal-btn {
+ padding: 8px 15px;
+ margin-left: 10px;
+ background: linear-gradient(180deg, #4a90e2, #357ABD);
+ color: white;
+ border: none;
+ border-radius: 8px;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.heatmap-controls {
+ position: absolute;
+ top: 70px;
+ right: 20px;
+ background: white;
+ padding: 10px;
+ border-radius: 5px;
+ box-shadow: 0 2px 5px rgba(0,0,0,0.2);
+ z-index: 1001;
+}
+
+.heatmap-controls label {
+ display: flex;
+ align-items: center;
+ font-size: 12px;
+ margin: 5px 0;
+ cursor: pointer;
+}
+
+.heatmap-controls input[type="range"] {
+ width: 120px;
+ margin-left: 10px;
+}
+
+.heatmap-intensity {
+ font-size: 11px;
+ color: #666;
+ margin-top: 5px;
+}
+
+.loading-indicator {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ background: rgba(255, 255, 255, 0.95);
+ padding: 20px;
+ border-radius: 10px;
+ box-shadow: 0 4px 20px rgba(0,0,0,0.2);
+ z-index: 1002;
+ text-align: center;
+ display: none;
+ min-width: 250px;
+}
+
+.progress-bar {
+ width: 200px;
+ height: 10px;
+ background: #eee;
+ border-radius: 5px;
+ margin: 15px auto;
+ overflow: hidden;
+}
+
+.progress-fill {
+ height: 100%;
+ background: linear-gradient(90deg, #4CAF50, #8BC34A, #FFC107, #FF9800, #FF5722);
+ width: 0%;
+ transition: width 0.3s ease;
+}
+
+.map-header {
+ background: rgba(255, 255, 255, 0.95);
+ padding: 10px 15px;
+ border-radius: 8px;
+ margin-bottom: 10px;
+ font-size: 14px;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+ z-index: 999;
+ position: relative;
+}
+
+.test-button {
+ margin-top: 10px;
+ padding: 5px 10px;
+ background: #4a90e2;
+ color: white;
+ border: none;
+ border-radius: 3px;
+ cursor: pointer;
+ font-size: 11px;
+}
+
+.error-message {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ background: rgba(255, 0, 0, 0.1);
+ border: 2px solid red;
+ padding: 20px;
+ border-radius: 10px;
+ z-index: 1003;
+ text-align: center;
+ display: none;
+}
+
+.debug-info {
+ position: absolute;
+ bottom: 10px;
+ left: 10px;
+ background: rgba(0, 0, 0, 0.7);
+ color: white;
+ padding: 5px 10px;
+ border-radius: 3px;
+ font-size: 10px;
+ z-index: 1004;
+ display: none;
+}
+
+.wind-controls {
+ position: absolute;
+ top: 70px;
+ left: 20px;
+ background: white;
+ padding: 10px;
+ border-radius: 5px;
+ box-shadow: 0 2px 5px rgba(0,0,0,0.2);
+ z-index: 1001;
+ width: 180px;
+}
+
+.wind-direction-display {
+ width: 100px;
+ height: 100px;
+ margin: 10px auto;
+ position: relative;
+ border: 2px solid #ddd;
+ border-radius: 50%;
+ background: #f9f9f9;
+}
+
+.wind-direction-arrow {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 2px;
+ height: 40px;
+ background: #3498db;
+ transform-origin: 50% 0;
+ transition: transform 0.3s ease;
+}
+
+.wind-direction-label {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ font-size: 12px;
+ color: #666;
+}
+
+.wind-compass-points {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+}
+
+.compass-point {
+ position: absolute;
+ font-size: 10px;
+ color: #888;
+}
+
+.north { top: 2px; left: 50%; transform: translateX(-50%); }
+.east { top: 50%; right: 2px; transform: translateY(-50%); }
+.south { bottom: 2px; left: 50%; transform: translateX(-50%); }
+.west { top: 50%; left: 2px; transform: translateY(-50%); }
+
+.wind-effect-intensity {
+ display: flex;
+ align-items: center;
+ margin-top: 10px;
+}
+
+.wind-effect-intensity label {
+ flex: 1;
+}
+
+.timeline-wrap {
+ margin-top: 20px;
+ background: rgba(255, 255, 255, 0.9);
+ padding: 10px 15px;
+ border-radius: 8px;
+ position: relative;
+}
+
+.time-ticks {
+ display: flex;
+ justify-content: space-between;
+ font-size: 11px;
+ color: #666;
+ margin-bottom: 5px;
+}
+
+.timeline {
+ height: 4px;
+ background: #e0e0e0;
+ border-radius: 2px;
+ position: relative;
+}
+
+.handle {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ z-index: 1;
+}
+
+.dot {
+ width: 12px;
+ height: 12px;
+ background: #4a90e2;
+ border-radius: 50%;
+ box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.2);
+}
+
+.triangle {
+ width: 0;
+ height: 0;
+ border-left: 6px solid transparent;
+ border-right: 6px solid transparent;
+ border-bottom: 8px solid #4a90e2;
+ margin: 0 auto 2px auto;
+}
\ No newline at end of file
diff --git a/static/css/forecasting.css b/static/css/forecasting.css
new file mode 100644
index 0000000..42899e7
--- /dev/null
+++ b/static/css/forecasting.css
@@ -0,0 +1,208 @@
+.styled {
+ background: #c8d6e0;
+ padding: 16px;
+ border-radius: 12px;
+ font-weight: 600;
+ color: #2c3e50;
+ position: relative;
+ font-size: 16px;
+}
+
+ .styled .arrow {
+ position: absolute;
+ right: 16px;
+ top: 50%;
+ transform: translateY(-50%);
+ font-size: 18px;
+ }
+
+.date-select {
+ position: relative;
+}
+
+.calendar-icon {
+ position: absolute;
+ right: 16px;
+ top: 50%;
+ transform: translateY(-50%);
+ font-size: 20px;
+ pointer-events: none;
+}
+
+.checkbox-row label {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 16px;
+ cursor: pointer;
+}
+
+.checkbox-row input[type="checkbox"] {
+ width: 20px;
+ height: 20px;
+ accent-color: #f07a2a;
+}
+
+.period-tabs {
+ display: flex;
+ gap: 8px;
+ margin: 16px 0;
+ flex-wrap: wrap;
+}
+
+.tab {
+ padding: 10px 18px;
+ background: #d0dbe3;
+ border: none;
+ border-radius: 10px;
+ font-weight: 600;
+ color: #555;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+ .tab.active {
+ background: linear-gradient(180deg, #ffa64d, #f07a2a);
+ color: white;
+ box-shadow: 0 4px 12px rgba(240,122,42,0.4);
+ }
+
+.update-btn {
+ margin-top: 32px;
+ background: linear-gradient(180deg, #95a5a6, #7f8c8d) !important;
+}
+
+ .update-btn:hover {
+ background: linear-gradient(180deg, #a0b0b1, #95a5a6) !important;
+ }
+
+.forecast-card {
+ padding: 32px;
+ display: flex;
+ flex-direction: column;
+ gap: 40px;
+}
+
+.results-block {
+ text-align: center;
+}
+
+.results-title {
+ font-size: 22px;
+ font-weight: 700;
+ color: #2c3e50;
+ margin-bottom: 28px;
+}
+
+.forecast-values {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ align-items: center;
+}
+
+.value-item {
+ display: flex;
+ align-items: center;
+ gap: 20px;
+ font-size: 17px;
+}
+
+ .value-item .label {
+ color: #555;
+ min-width: 280px;
+ text-align: right;
+ }
+
+.value-badge {
+ padding: 12px 24px;
+ border-radius: 30px;
+ font-weight: 700;
+ font-size: 18px;
+ min-width: 140px;
+ text-align: center;
+}
+
+ .value-badge.orange {
+ background: linear-gradient(135deg, #f39c12, #e67e22);
+ color: white;
+ }
+
+ .value-badge.gray {
+ background: #bdc3c7;
+ color: #2c3e50;
+ }
+
+.factors-title {
+ text-align: center;
+ font-size: 21px;
+ font-weight: 700;
+ color: #2c3e50;
+ margin-bottom: 32px;
+}
+
+.factors-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 32px;
+ justify-items: center;
+}
+
+.factor {
+ text-align: center;
+ max-width: 200px;
+}
+
+.circle {
+ width: 140px;
+ height: 140px;
+ border-radius: 50%;
+ margin: 0 auto 16px;
+ position: relative;
+ background: conic-gradient(from 0deg, #27ae60 0%, #f1c40f 50%, #e74c3c 100%);
+ box-shadow: 0 8px 25px rgba(0,0,0,0.15);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+ .circle.green {
+ background: conic-gradient(#27ae60 0% 30%, #f1c40f 30% 100%);
+ }
+
+ .circle.yellow {
+ background: conic-gradient(#f1c40f 0% 70%, #e67e22 70% 100%);
+ }
+
+ .circle.red {
+ background: conic-gradient(#e74c3c 0% 80%, #f1c40f 80% 100%);
+ }
+
+.percent {
+ font-size: 28px;
+ font-weight: 800;
+ color: white;
+ text-shadow: 0 2px 8px rgba(0,0,0,0.4);
+}
+
+.factor-label {
+ font-size: 15px;
+ color: #444;
+ line-height: 1.4;
+}
+
+@media (max-width: 1100px) {
+ .container {
+ grid-template-columns: 1fr;
+ }
+
+ .value-item {
+ flex-direction: column;
+ text-align: center;
+ }
+
+ .value-item .label {
+ text-align: center;
+ min-width: auto;
+ }
+}
diff --git a/static/css/history.css b/static/css/history.css
new file mode 100644
index 0000000..167ba97
--- /dev/null
+++ b/static/css/history.css
@@ -0,0 +1,152 @@
+.styled {
+ background: #c8d6e0;
+ padding: 16px;
+ border-radius: 12px;
+ font-weight: 600;
+ color: #2c3e50;
+ position: relative;
+}
+
+ .styled .arrow {
+ position: absolute;
+ right: 16px;
+ top: 50%;
+ transform: translateY(-50%);
+ font-size: 18px;
+ }
+
+.checkbox-row {
+ margin: 20px 0;
+}
+
+ .checkbox-row label {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 16px;
+ cursor: pointer;
+ }
+
+ .checkbox-row input[type="checkbox"] {
+ width: 20px;
+ height: 20px;
+ accent-color: #f07a2a;
+ }
+
+.period-tabs {
+ display: flex;
+ gap: 8px;
+ margin: 16px 0;
+ flex-wrap: wrap;
+}
+
+.tab {
+ padding: 10px 16px;
+ background: #c8d6e0;
+ border: none;
+ border-radius: 12px;
+ font-weight: 600;
+ color: #2c3e50;
+ cursor: pointer;
+ transition: all 0.25s ease;
+}
+
+.tab.active {
+ background: #c8d9e5 !important;
+ box-shadow: none !important;
+ color: #2c3e50 !important;
+}
+
+.tab:hover {
+ background: #d9e3eb;
+ transform: translateY(-2px);
+ box-shadow: 0 6px 12px rgba(0,0,0,0.1);
+}
+
+.export-btn {
+ background: linear-gradient(180deg, #95a5a6, #7f8c8d) !important;
+ margin-top: 12px;
+}
+
+ .export-btn:hover {
+ background: linear-gradient(180deg, #a0b0b1, #95a5a6) !important;
+ }
+
+.chart-container {
+ background: white;
+ border-radius: 18px;
+ padding: 24px;
+ margin-top: 20px;
+ box-shadow: 0 6px 20px rgba(0,0,0,0.08);
+ position: relative;
+ height: 620px;
+}
+
+.chart-title {
+ position: absolute;
+ top: 16px;
+ left: 50%;
+ transform: translateX(-50%);
+ font-size: 20px;
+ font-weight: 700;
+ color: #2c3e50;
+ z-index: 10;
+}
+
+#concentrationChart {
+ width: 100% !important;
+ height: 100% !important;
+}
+
+.clickable {
+ cursor: pointer;
+ user-select: none;
+}
+
+.dropdown-list {
+ display: none;
+ position: absolute;
+ background: white;
+ border-radius: 12px;
+ box-shadow: 0 10px 25px rgba(0,0,0,0.15);
+ margin-top: 6px;
+ width: 100%;
+ max-width: 302.5px;
+ max-height: 180px;
+ overflow-y: auto;
+ z-index: 1000;
+ border: 1px solid #ddd;
+}
+
+.dropdown-item {
+ padding: 12px 16px;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.dropdown-item:hover {
+ background: #e3f2fd;
+ color: #1976d2;
+}
+
+.address-input {
+ width: 100%;
+ padding: 14px 16px;
+ border: 2px solid #c8d6e0;
+ border-radius: 12px;
+ font-size: 16px;
+ background: #f8fbff;
+}
+
+.address-input:focus {
+ outline: none;
+ border-color: #f07a2a;
+ border-color: #f07a2a;
+ box-shadow: 0 0 0 3px rgba(240,122,42,0.2);
+}
+
+#substanceDropdown {
+ width: 100%;
+ max-width: 302.5px !important;
+ max-height: 200px;
+}
diff --git a/static/css/index.css b/static/css/index.css
new file mode 100644
index 0000000..d3af1d8
--- /dev/null
+++ b/static/css/index.css
@@ -0,0 +1,83 @@
+html, body {
+ height: 100%;
+ margin: 0;
+ padding: 0;
+ overflow: hidden;
+ width: 100%;
+}
+
+body {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+}
+
+.header {
+ flex-shrink: 0;
+ width: 100%;
+}
+
+.container {
+ display: flex;
+ flex: 1;
+ overflow: hidden;
+ min-height: 0;
+ width: 100%;
+ max-width: none;
+}
+
+.panel {
+ flex-shrink: 0;
+ overflow-y: auto;
+ height: 100%;
+ min-width: fit-content;
+}
+
+.card {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ overflow: hidden;
+ width: 100%;
+}
+
+.map-header {
+ flex-shrink: 0;
+ width: 100%;
+}
+
+#map {
+ flex: 1;
+ min-height: 0;
+ width: 100%;
+}
+
+.timeline-wrap {
+ flex-shrink: 0;
+ width: 100%;
+}
+
+.panel .btn {
+ background: linear-gradient(180deg, #ffa64d, #f07a2a) !important;
+}
+
+ .panel .btn:hover {
+ background: linear-gradient(180deg, #ffb766, #f58c3b) !important;
+ }
+
+.container-fluid {
+ max-width: none !important;
+ padding-left: 0 !important;
+ padding-right: 0 !important;
+}
+
+.row {
+ margin-left: 0 !important;
+ margin-right: 0 !important;
+}
+
+.col, [class*="col-"] {
+ padding-left: 0 !important;
+ padding-right: 0 !important;
+}
diff --git a/static/css/login.css b/static/css/login.css
new file mode 100644
index 0000000..65bfd44
--- /dev/null
+++ b/static/css/login.css
@@ -0,0 +1,87 @@
+.login-container {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 40px;
+ padding: 40px;
+ max-width: 1400px;
+ margin: 0 auto;
+}
+
+.login-card, .register-card {
+ background: white;
+ padding: 40px;
+ border-radius: 20px;
+ box-shadow: 0 10px 30px rgba(0,0,0,0.12);
+}
+
+ .login-card h2, .register-card h2 {
+ text-align: center;
+ font-size: 22px;
+ margin-bottom: 30px;
+ color: #2c3e50;
+ }
+
+.form-group {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 20px 30px;
+}
+
+.input-field {
+ padding: 16px 20px;
+ background: #dce4ec;
+ border-radius: 14px;
+ border: none;
+ font-size: 16px;
+ color: #2c3e50;
+}
+
+.wide {
+ grid-column: 1 / -1;
+}
+
+.wide-label {
+ margin-top: 20px;
+ display: block;
+}
+
+.captcha {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin: 20px 0;
+ font-size: 15px;
+}
+
+.consent {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin: 25px 0;
+ font-size: 15px;
+}
+
+.login-btn {
+ background: linear-gradient(180deg, #ffa64d, #f07a2a);
+ margin-top: 20px;
+}
+
+.register-btn {
+ background: linear-gradient(180deg, #ffa64d, #f07a2a);
+ width: 100%;
+ padding: 18px;
+ font-size: 18px;
+ margin-top: 20px;
+}
+
+@media (max-width: 1000px) {
+ .login-container {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/static/css/recommendations.css b/static/css/recommendations.css
new file mode 100644
index 0000000..825e07e
--- /dev/null
+++ b/static/css/recommendations.css
@@ -0,0 +1,105 @@
+.alerts-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 28px;
+ padding: 28px 24px;
+}
+
+.section-title {
+ font-size: 19px;
+ font-weight: 700;
+ color: #222;
+ margin-bottom: 8px;
+}
+
+.recommendations-title {
+ margin-top: 12px;
+}
+
+.alert-item {
+ background: white;
+ padding: 18px;
+ border-radius: 16px;
+ box-shadow: 0 4px 15px rgba(0,0,0,0.08);
+}
+
+.alert-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 10px;
+ font-size: 17px;
+ font-weight: 600;
+}
+
+.dot {
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: #f1c40f;
+ box-shadow: 0 2px 6px rgba(241,196,15,0.4);
+}
+
+.substance {
+ flex-grow: 1;
+ color: #2c3e50;
+}
+
+.percent-badge {
+ background: #2c3e50;
+ color: white;
+ padding: 6px 14px;
+ border-radius: 20px;
+ font-weight: 700;
+ font-size: 15px;
+}
+
+ .percent-badge.dark {
+ background: #34495e;
+ }
+
+.alert-time {
+ font-size: 14px;
+ color: #777;
+ margin-bottom: 4px;
+}
+
+.alert-time-value {
+ font-size: 16px;
+ font-weight: 700;
+ color: #2c3e50;
+ text-decoration: underline;
+ text-decoration-color: #f07a2a;
+}
+
+.recommendations-list {
+ list-style: none;
+ padding-left: 4px;
+}
+
+ .recommendations-list li {
+ position: relative;
+ padding-left: 28px;
+ margin: 18px 0;
+ font-size: 16px;
+ line-height: 1.5;
+ color: #333;
+ }
+
+ .recommendations-list li::before {
+ content: "•";
+ position: absolute;
+ left: 0;
+ color: #2c3e50;
+ font-size: 24px;
+ font-weight: bold;
+ }
+
+#map {
+ height: 720px !important;
+ margin-top: 16px;
+ border-radius: 16px;
+ overflow: hidden;
+ border: 5px solid rgba(0,0,0,0.05);
+ box-shadow: inset 0 4px 10px rgba(0,0,0,0.08);
+}
diff --git a/static/img/logo_white.png b/static/img/logo_white.png
new file mode 100644
index 0000000..7cf08a6
Binary files /dev/null and b/static/img/logo_white.png differ
diff --git a/static/js/enterprise.js b/static/js/enterprise.js
new file mode 100644
index 0000000..ddb59e9
--- /dev/null
+++ b/static/js/enterprise.js
@@ -0,0 +1,319 @@
+window.appConfig = {
+ YMAPS_API_KEY: '{{ config.YMAPS_API_KEY }}',
+ YMAPS_LANG: '{{ config.YMAPS_LANG }}',
+ YWEATHER_API_KEY: '{{ config.YWEATHER_API_KEY }}',
+};
+
+let map = null;
+let paramsUpdateTimeout = null;
+
+const state = {
+ sources: [],
+ sourceToDelete: null,
+ heatmapVisible: true,
+ windSpeed: 2.4,
+ windDirection: 180,
+ stabilityClass: 'D',
+ showWindVectors: true,
+ currentSubstanceId: null // Текущее выбранное вещество
+};
+
+window.requestDeleteSource = function(sourceId) {
+ state.sourceToDelete = state.sources.find(s => s.id === sourceId);
+ if (state.sourceToDelete) {
+ document.getElementById('confirm-modal').style.display = 'block';
+ }
+};
+
+window.toggleDebugInfo = function() {
+ const debugInfo = document.getElementById('debug-info');
+ if (debugInfo) debugInfo.style.display = debugInfo.style.display === 'block' ? 'none' : 'block';
+};
+
+window.resetWindSettings = function() {
+ state.windSpeed = 2.4;
+ state.windDirection = 180;
+
+ const windSpeedEl = document.getElementById('wind-speed');
+ const windDirectionEl = document.getElementById('wind-direction');
+ if (windSpeedEl) windSpeedEl.value = state.windSpeed;
+ if (windDirectionEl) windDirectionEl.value = state.windDirection;
+
+ updateWindDisplay();
+ if (state.showWindVectors) MapGraphics.drawWindVectors(map, state.sources, state.windDirection);
+ saveSimulationParamsAndRefresh();
+};
+
+window.updateHeatmapData = function() {
+ MapGraphics.refreshPollutionLayer(map, state.currentSubstanceId);
+};
+
+// --- Работа с API и БД ---
+
+async function apiCall(url, options = {}) {
+ try {
+ const response = await fetch(url, {
+ headers: { 'Content-Type': 'application/json', ...options.headers },
+ ...options
+ });
+ if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
+ const text = await response.text();
+ return text ? JSON.parse(text) : { success: true };
+ } catch (error) {
+ console.error('API Error:', error);
+ return null;
+ }
+}
+
+// Загрузка веществ
+async function loadSubstances() {
+ const substances = await apiCall('/api/substances');
+ const select = document.getElementById('substance-select');
+
+ if (substances && select) {
+ select.innerHTML = '';
+ substances.forEach(sub => {
+ const option = document.createElement('option');
+ option.value = sub.id;
+ option.textContent = `${sub.name} (${sub.short_name})`;
+ select.appendChild(option);
+ });
+
+ if (substances.length > 0) {
+ state.currentSubstanceId = substances[0].id;
+ }
+
+ select.addEventListener('change', (e) => {
+ state.currentSubstanceId = parseInt(e.target.value);
+ loadSourcesFromDB();
+ MapGraphics.refreshPollutionLayer(map, state.currentSubstanceId);
+ });
+ }
+}
+
+async function loadSourcesFromDB() {
+ const allSources = await apiCall('/api/sources');
+ if (allSources) {
+ state.sources = allSources
+ .filter(source => source.substance_id === state.currentSubstanceId)
+ .map(source => ({
+ ...source,
+ id: source.id,
+ lat: source.latitude,
+ lng: source.longitude,
+ emissionRate: source.emission_rate,
+ height: source.height,
+ type: source.type
+ }));
+
+ updateSourcesList();
+
+ MapGraphics.drawSources(map, state.sources);
+ if (state.showWindVectors) MapGraphics.drawWindVectors(map, state.sources, state.windDirection);
+
+ MapGraphics.refreshPollutionLayer(map, state.currentSubstanceId);
+ }
+}
+
+async function addSourceToDB(sourceData) {
+ return await apiCall('/api/sources', { method: 'POST', body: JSON.stringify(sourceData) });
+}
+
+async function deleteSourceFromDB(sourceId) {
+ return await apiCall(`/api/sources/${sourceId}`, { method: 'DELETE' });
+}
+
+async function saveSimulationParamsAndRefresh() {
+ updateDebugStatus("Обновление параметров и перерисовка...");
+ const paramsData = {
+ wind_speed: state.windSpeed,
+ wind_direction: state.windDirection,
+ stability_class: state.stabilityClass
+ };
+
+ try {
+ const result = await apiCall('/api/simulation_params/', {
+ method: 'POST',
+ body: JSON.stringify(paramsData)
+ });
+ if (result) MapGraphics.refreshPollutionLayer(map, state.currentSubstanceId);
+ } catch (e) {
+ console.error("Ошибка сохранения параметров", e);
+ }
+}
+
+// --- Логика карты ---
+
+function initMap() {
+ try {
+ document.getElementById('loading-indicator').style.display = 'block';
+ updateDebugStatus("Инициализация карты...");
+
+ map = new ymaps.Map('map', {
+ center: [55.7558, 37.6173],
+ zoom: 13,
+ controls: ['zoomControl', 'typeSelector', 'fullscreenControl']
+ });
+
+ updateDebugStatus("Карта загружена");
+ setTimeout(() => document.getElementById('loading-indicator').style.display = 'none', 500);
+
+ init(); // Грузим вещества, затем слой, затем источники
+ } catch (error) {
+ console.error("Ошибка при инициализации карты:", error);
+ showErrorMessage("Ошибка загрузки карты: " + error.message);
+ }
+}
+
+function updateDebugStatus(status) {
+ const debugStatus = document.getElementById('debug-status');
+ if (debugStatus) debugStatus.textContent = status;
+}
+
+function showErrorMessage(message) {
+ const errorMessage = document.getElementById('error-message');
+ if (errorMessage) {
+ errorMessage.innerHTML = `${message}
`;
+ errorMessage.style.display = 'block';
+ }
+}
+
+function updateWindDisplay() {
+ const windDirectionArrow = document.getElementById('wind-direction-arrow');
+ const windSpeedValue = document.getElementById('wind-speed-value');
+ const windDirectionValue = document.getElementById('wind-direction-value');
+
+ if (windDirectionArrow) windDirectionArrow.style.transform = `translateX(-50%) rotate(${state.windDirection}deg)`;
+ if (windSpeedValue) windSpeedValue.textContent = `${state.windSpeed.toFixed(1)} м/с`;
+
+ if (windDirectionValue) {
+ const dirs = ['С', 'СВ', 'В', 'ЮВ', 'Ю', 'ЮЗ', 'З', 'СЗ'];
+ const idx = Math.round(state.windDirection / 45) % 8;
+ windDirectionValue.textContent = `${state.windDirection}° (${dirs[idx]})`;
+ }
+}
+
+function updateSourcesList() {
+ const sourcesList = document.getElementById('sources-list');
+ if (!sourcesList) return;
+
+ sourcesList.innerHTML = '';
+ state.sources.forEach(source => {
+ const sourceEl = document.createElement('div');
+ sourceEl.className = 'enterprise-source-item';
+ sourceEl.innerHTML = `
+ ${source.name} (${source.type})
${source.emissionRate} г/с
+ `;
+ sourcesList.appendChild(sourceEl);
+ });
+}
+
+async function init() {
+ await loadSubstances();
+ MapGraphics.initPollutionLayer(map, state.currentSubstanceId);
+ setupEventListeners();
+ loadSourcesFromDB();
+}
+
+function handleWindChange() {
+ updateWindDisplay();
+ if (state.showWindVectors) MapGraphics.drawWindVectors(map, state.sources, state.windDirection);
+
+ if (paramsUpdateTimeout) clearTimeout(paramsUpdateTimeout);
+ paramsUpdateTimeout = setTimeout(() => {
+ saveSimulationParamsAndRefresh();
+ }, 500);
+}
+
+function setupEventListeners() {
+ const heatmapToggle = document.getElementById('heatmap-toggle');
+ if (heatmapToggle) {
+ heatmapToggle.addEventListener('change', function() {
+ state.heatmapVisible = this.checked;
+ if (map && MapGraphics.pollutionLayer) {
+ if (state.heatmapVisible) map.layers.add(MapGraphics.pollutionLayer);
+ else map.layers.remove(MapGraphics.pollutionLayer);
+ }
+ });
+ }
+
+ const windSpeedEl = document.getElementById('wind-speed');
+ if (windSpeedEl) windSpeedEl.addEventListener('input', function() { state.windSpeed = parseFloat(this.value); handleWindChange(); });
+
+ const windDirectionEl = document.getElementById('wind-direction');
+ if (windDirectionEl) windDirectionEl.addEventListener('input', function() { state.windDirection = parseInt(this.value); handleWindChange(); });
+
+ const showWindVectorsEl = document.getElementById('show-wind-vectors');
+ if (showWindVectorsEl) {
+ showWindVectorsEl.addEventListener('change', function() {
+ state.showWindVectors = this.checked;
+ if (this.checked) MapGraphics.drawWindVectors(map, state.sources, state.windDirection);
+ else MapGraphics.windVectors.forEach(pm => map.geoObjects.remove(pm));
+ });
+ }
+
+ const addSourceBtn = document.getElementById('add-source-btn');
+ if (addSourceBtn) {
+ addSourceBtn.addEventListener('click', () => {
+ if (!state.currentSubstanceId) {
+ alert("Сначала выберите или создайте вещество!");
+ return;
+ }
+
+ const typeSelect = document.getElementById('source-type');
+ const sourceType = typeSelect ? typeSelect.value : 'point';
+
+ if (MapGraphics.drawMode) {
+ MapGraphics.disableDrawing(map);
+ addSourceBtn.textContent = 'Добавить источник';
+ } else {
+ addSourceBtn.textContent = sourceType === 'point' ? 'Кликните на карту' : 'Рисуйте (Двойной клик)';
+
+ MapGraphics.enableDrawing(map, sourceType, async (type, centerCoords, fullCoords) => {
+ const heightInput = document.getElementById('source-height');
+ const rateInput = document.getElementById('emission-rate');
+ const h = heightInput ? parseFloat(heightInput.value) : 40;
+ const rate = rateInput ? parseFloat(rateInput.value.replace(',', '.')) : 3.7;
+
+ await addSourceToDB({
+ name: `Источник ${Date.now() % 1000}`,
+ type: type,
+ latitude: centerCoords[0],
+ longitude: centerCoords[1],
+ coordinates: fullCoords,
+ height: h,
+ emission_rate: rate,
+ substance_id: state.currentSubstanceId
+ });
+
+ addSourceBtn.textContent = 'Добавить источник';
+ loadSourcesFromDB();
+ });
+ }
+ });
+ }
+
+ const updateHeatmapBtn = document.getElementById('update-heatmap-btn');
+ if (updateHeatmapBtn) updateHeatmapBtn.addEventListener('click', () => MapGraphics.refreshPollutionLayer(map, state.currentSubstanceId));
+
+ document.querySelectorAll('.close').forEach(el => el.addEventListener('click', () => document.getElementById('confirm-modal').style.display = 'none'));
+ const cancelDelete = document.getElementById('cancel-delete');
+ if (cancelDelete) cancelDelete.addEventListener('click', () => document.getElementById('confirm-modal').style.display = 'none');
+ const confirmDelete = document.getElementById('confirm-delete');
+ if (confirmDelete) confirmDelete.addEventListener('click', confirmDeleteSource);
+
+ updateWindDisplay();
+}
+
+async function confirmDeleteSource() {
+ if (!state.sourceToDelete) return;
+ const result = await deleteSourceFromDB(state.sourceToDelete.id);
+ if (result) {
+ await loadSourcesFromDB();
+ MapGraphics.refreshPollutionLayer(map, state.currentSubstanceId);
+ }
+ document.getElementById('confirm-modal').style.display = 'none';
+ state.sourceToDelete = null;
+}
+
+ymaps.ready(initMap);
\ No newline at end of file
diff --git a/static/js/history.js b/static/js/history.js
new file mode 100644
index 0000000..ae456d7
--- /dev/null
+++ b/static/js/history.js
@@ -0,0 +1,110 @@
+document.addEventListener('DOMContentLoaded', () => {
+ // Выбор города из выпадающего списка для дальнейшего составления прогноза
+ const citySelect = document.getElementById('citySelect');
+ const cityDropdown = document.getElementById('cityDropdown');
+
+ if (citySelect && cityDropdown) {
+ citySelect.addEventListener('click', () => {
+ const isOpen = cityDropdown.style.display === 'block';
+ cityDropdown.style.display = isOpen ? 'none' : 'block';
+ });
+
+ cityDropdown.querySelectorAll('.dropdown-item').forEach(item => {
+ item.addEventListener('click', () => {
+ const text = item.textContent.trim();
+ citySelect.innerHTML = text + ' ▼';
+ cityDropdown.style.display = 'none';
+ console.log('Выбран город:', text);
+ });
+ });
+
+ // Закрытие выпадающего списка при клике в другом месте
+ document.addEventListener('click', (e) => {
+ if (!citySelect.contains(e.target) && !cityDropdown.contains(e.target)) {
+ cityDropdown.style.display = 'none';
+ }
+ });
+ }
+
+ const checkbox = document.getElementById('manualAddressCheckbox');
+ const inputBlock = document.getElementById('manualAddressInput');
+ const addressField = document.getElementById('addressField');
+
+ if (checkbox && inputBlock) {
+ checkbox.addEventListener('change', () => {
+ if (checkbox.checked) {
+ inputBlock.style.display = 'block';
+ addressField.focus();
+ } else {
+ inputBlock.style.display = 'none';
+ addressField.value = '';
+ }
+ });
+ }
+
+ if (addressField) {
+ addressField.addEventListener('keypress', (e) => {
+ if (e.key === 'Enter') {
+ console.log('Введён адрес:', addressField.value);
+ // Здесь будет запрос к геокодеру
+ }
+ });
+ }
+
+ // Выбор вещества из выпадающего списка для дальнейшего составления прогноза
+ const substanceSelect = document.getElementById('substanceSelect');
+ const substanceDropdown = document.getElementById('substanceDropdown');
+
+ if (substanceSelect && substanceDropdown) {
+ substanceSelect.addEventListener('click', () => {
+ const isOpen = substanceDropdown.style.display === 'block';
+ substanceDropdown.style.display = isOpen ? 'none' : 'block';
+ });
+
+ substanceDropdown.querySelectorAll('.dropdown-item').forEach(item => {
+ item.addEventListener('click', () => {
+ const text = item.textContent.trim();
+ substanceSelect.innerHTML = text + ' ▼';
+ substanceDropdown.style.display = 'none';
+ console.log('Выбрано вещество:', text);
+ });
+ });
+
+ // Закрытие выпадающего списка при клике в другом месте
+ document.addEventListener('click', (e) => {
+ if (!substanceSelect.contains(e.target) && !substanceDropdown.contains(e.target)) {
+ substanceDropdown.style.display = 'none';
+ }
+ });
+ }
+
+ const ctx = document.getElementById('concentrationChart').getContext('2d');
+
+ new Chart(ctx, {
+ type: 'line',
+ data: {
+ labels: ['Дек', 'Янв', 'Фев', 'Мар', 'Апр', 'Май', 'Июн', 'Июл', 'Авг'],
+ datasets: [{
+ data: [0.0033, 0.0028, 0.0025, 0.0024, 0.0026, 0.0027, 0.0029, 0.0031, 0.0030],
+ borderColor: '#3498db',
+ backgroundColor: 'rgba(52, 152, 219, 0.1)',
+ fill: true,
+ tension: 0.4,
+ pointRadius: 5,
+ pointBackgroundColor: '#3498db'
+ }]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: { legend: { display: false } },
+ scales: {
+ y: {
+ beginAtZero: true,
+ max: 0.01,
+ ticks: { stepSize: 0.001, callback: value => value.toFixed(6) }
+ }
+ }
+ }
+ });
+});
diff --git a/static/js/main.js b/static/js/main.js
new file mode 100644
index 0000000..f685b17
--- /dev/null
+++ b/static/js/main.js
@@ -0,0 +1,322 @@
+window.appConfig = {
+ YMAPS_API_KEY: '{{ config.YMAPS_API_KEY }}',
+ YMAPS_LANG: '{{ config.YMAPS_LANG }}',
+ YWEATHER_API_KEY: '{{ config.YWEATHER_API_KEY }}',
+};
+
+let monitoringMap = null;
+
+const monitoringState = {
+ sources: [],
+ sourceToDelete: null,
+ isHeatmapVisible: true,
+ currentSubstanceId: null // Текущее выбранное вещество
+};
+
+document.addEventListener('DOMContentLoaded', () => {
+ const mapContainer = document.getElementById('map');
+ if (mapContainer && mapContainer.dataset.mapRole === 'monitoring') {
+ initMonitoringMap();
+ }
+});
+
+function initMonitoringMap() {
+ if (typeof ymaps === 'undefined') return;
+
+ ymaps.ready(async () => {
+ monitoringMap = new ymaps.Map('map', {
+ center: [55.7558, 37.6173],
+ zoom: 13,
+ controls: ['zoomControl', 'typeSelector', 'fullscreenControl']
+ });
+
+ // Сначала грузим вещества
+ await loadSubstances();
+
+ MapGraphics.initPollutionLayer(monitoringMap, monitoringState.currentSubstanceId);
+ initMonitoring();
+ });
+}
+
+async function apiCall(url, options = {}) {
+ try {
+ const response = await fetch(url, {
+ headers: { 'Content-Type': 'application/json', ...options.headers },
+ ...options
+ });
+ if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
+ const text = await response.text();
+ return text ? JSON.parse(text) : { success: true };
+ } catch (error) {
+ console.error('API Error:', error);
+ return null;
+ }
+}
+
+// ЗАГРУЗКА ВЕЩЕСТВ ИЗ БД
+async function loadSubstances() {
+ const substances = await apiCall('/api/substances'); // Убедитесь, что этот эндпоинт есть в FastAPI
+ const select = document.getElementById('substance-select');
+
+ if (substances && select) {
+ select.innerHTML = '';
+ substances.forEach(sub => {
+ const option = document.createElement('option');
+ option.value = sub.id;
+ option.textContent = `${sub.name} (${sub.short_name})`;
+ select.appendChild(option);
+ });
+
+ if (substances.length > 0) {
+ monitoringState.currentSubstanceId = substances[0].id;
+ }
+
+ select.addEventListener('change', (e) => {
+ monitoringState.currentSubstanceId = parseInt(e.target.value);
+ loadSourcesFromDB(); // Перезагружаем маркеры
+ MapGraphics.refreshPollutionLayer(monitoringMap, monitoringState.currentSubstanceId); // Перерисовываем карту
+ });
+ }
+}
+
+async function loadSourcesFromDB() {
+ // Получаем ВСЕ источники (или можно сделать фильтрацию на бэке: /api/sources?substance_id=...)
+ const allSources = await apiCall('/api/sources');
+
+ if (allSources) {
+ // Оставляем только те источники, которые относятся к текущему выбранному веществу
+ monitoringState.sources = allSources
+ .filter(source => source.substance_id === monitoringState.currentSubstanceId)
+ .map(source => ({
+ ...source,
+ id: source.id,
+ lat: source.latitude,
+ lng: source.longitude,
+ type: source.type,
+ coordinates: source.coordinates,
+ emissionRate: source.emission_rate
+ }));
+
+ MapGraphics.drawSources(monitoringMap, monitoringState.sources);
+ updateSourcesList();
+ }
+}
+
+async function addSourceToDB(sourceData) {
+ return await apiCall('/api/sources', { method: 'POST', body: JSON.stringify(sourceData) });
+}
+
+async function deleteSourceFromDB(sourceId) {
+ return await apiCall(`/api/sources/${sourceId}`, { method: 'DELETE' });
+}
+
+function initMonitoring() {
+ setupMonitoringEventListeners();
+ loadSourcesFromDB();
+}
+
+function setupMonitoringEventListeners() {
+ const addSourceBtn = document.getElementById('add-source-btn');
+ if (addSourceBtn) {
+ addSourceBtn.addEventListener('click', () => {
+ if (!monitoringState.currentSubstanceId) {
+ alert("Сначала выберите или создайте вещество!");
+ return;
+ }
+
+ const typeSelect = document.getElementById('source-type');
+ const sourceType = typeSelect ? typeSelect.value : 'point';
+
+ if (MapGraphics.drawMode) {
+ MapGraphics.disableDrawing(monitoringMap);
+ addSourceBtn.textContent = 'Добавить источник на карту';
+ } else {
+ addSourceBtn.textContent = sourceType === 'point' ? 'Кликните на карту' : 'Рисуйте (Двойной клик - завершить)';
+
+ MapGraphics.enableDrawing(monitoringMap, sourceType, async (type, centerCoords, fullCoords) => {
+ const heightInput = document.getElementById('source-height');
+ const rateInput = document.getElementById('emission-rate');
+
+ await addSourceToDB({
+ name: `Источник ${Date.now() % 1000}`,
+ type: type,
+ latitude: centerCoords[0],
+ longitude: centerCoords[1],
+ coordinates: fullCoords,
+ height: heightInput ? parseFloat(heightInput.value) : 40,
+ emission_rate: rateInput ? parseFloat(rateInput.value.replace(',', '.')) : 3.7,
+ substance_id: monitoringState.currentSubstanceId // ПРИВЯЗЫВАЕМ К ВЕЩЕСТВУ
+ });
+
+ addSourceBtn.textContent = 'Добавить источник на карту';
+ loadSourcesFromDB();
+ MapGraphics.refreshPollutionLayer(monitoringMap, monitoringState.currentSubstanceId);
+ });
+ }
+ });
+ }
+
+ const toggleHeatmapBtn = document.getElementById('toggle-heatmap-btn');
+ if (toggleHeatmapBtn) {
+ toggleHeatmapBtn.addEventListener('click', () => {
+ monitoringState.isHeatmapVisible = !monitoringState.isHeatmapVisible;
+ if (monitoringState.isHeatmapVisible) {
+ monitoringMap.layers.add(MapGraphics.pollutionLayer);
+ MapGraphics.refreshPollutionLayer(monitoringMap, monitoringState.currentSubstanceId);
+ } else {
+ monitoringMap.layers.remove(MapGraphics.pollutionLayer);
+ }
+ });
+ }
+
+ document.querySelectorAll('.close').forEach(el => el.addEventListener('click', () => document.getElementById('confirm-modal').style.display = 'none'));
+ const cancelDeleteBtn = document.getElementById('cancel-delete');
+ if (cancelDeleteBtn) cancelDeleteBtn.addEventListener('click', () => document.getElementById('confirm-modal').style.display = 'none');
+ const confirmDeleteBtn = document.getElementById('confirm-delete');
+ if (confirmDeleteBtn) confirmDeleteBtn.addEventListener('click', confirmDeleteSource);
+}
+
+function updateSourcesList() {
+ const sourcesList = document.getElementById('sources-list');
+ if (!sourcesList) return;
+
+ sourcesList.innerHTML = monitoringState.sources.length === 0 ? 'Нет источников для этого вещества
' : '';
+
+ monitoringState.sources.forEach(source => {
+ const sourceEl = document.createElement('div');
+ sourceEl.className = 'source-item';
+ sourceEl.innerHTML = `
+ ${source.name} (${source.type})
+ Высота: ${source.height} м, выброс: ${source.emissionRate.toFixed(3)} г/с
+
+
+
+
`;
+ sourcesList.appendChild(sourceEl);
+ });
+}
+
+function requestDeleteSource(sourceId) {
+ monitoringState.sourceToDelete = monitoringState.sources.find(s => s.id === sourceId);
+ if (monitoringState.sourceToDelete) {
+ const modal = document.getElementById('confirm-modal');
+ if (modal) modal.style.display = 'block';
+ }
+}
+
+async function confirmDeleteSource() {
+ if (!monitoringState.sourceToDelete) return;
+ const result = await deleteSourceFromDB(monitoringState.sourceToDelete.id);
+ if (result && result.success !== false) {
+ await loadSourcesFromDB();
+ MapGraphics.refreshPollutionLayer(monitoringMap, monitoringState.currentSubstanceId);
+ }
+ const modal = document.getElementById('confirm-modal');
+ if (modal) modal.style.display = 'none';
+ monitoringState.sourceToDelete = null;
+}
+
+function flyToSource(sourceId) {
+ if (!monitoringMap) return;
+ const source = monitoringState.sources.find(s => s.id === sourceId);
+ if (source) {
+ monitoringMap.panTo([source.lat, source.lng], { duration: 1000, flying: true }).then(() => {
+ monitoringMap.setZoom(15, { duration: 500 });
+ if (source.placemark) source.placemark.balloon.open();
+ });
+ }
+}
+
+window.requestDeleteSource = requestDeleteSource;
+window.flyToSource = flyToSource;
+
+// ==========================================
+// АНИМАЦИЯ ПОГОДЫ
+// ==========================================
+let weatherData = { wind_speed: 3, wind_dir: 'n', prec_type: 0, prec_strength: 0 };
+let isWindOn = true;
+let isRainOn = true;
+
+function dirToText(dir) {
+ const map = { n:'С', ne:'СВ', e:'В', se:'ЮВ', s:'Ю', sw:'ЮЗ', w:'З', nw:'СЗ' };
+ return map[dir] || 'С';
+}
+
+async function refreshWeather() {
+ try {
+ const res = await fetch('https://api.weather.yandex.ru/v2/forecast?lat=55.7558&lon=37.6173&limit=1', {
+ headers: { 'X-Yandex-API-Key': window.appConfig.YWEATHER_API_KEY }
+ });
+ if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
+ const data = await res.json();
+ const f = data.fact;
+
+ weatherData = {
+ wind_speed: f.wind_speed, wind_dir: f.wind_dir, pressure_mm: f.pressure_mm,
+ humidity: f.humidity, visibility: f.visibility ? (f.visibility/1000).toFixed(1) : 10,
+ prec_type: f.prec_type || 0, prec_strength: f.prec_strength || 0
+ };
+
+ const panel = document.querySelector('.map-meta');
+ if (panel) panel.textContent = `Ветер: ${f.wind_speed} м/с, ${dirToText(f.wind_dir)} Давление: ${f.pressure_mm} мм рт. ст. Влажность: ${f.humidity}% Дальность видимости: ${weatherData.visibility} км`;
+ } catch (e) {
+ weatherData = { wind_speed: 3, wind_dir: 'n', pressure_mm: 764, humidity: 61, visibility: '10', prec_type: 0, prec_strength: 0 };
+ }
+}
+
+let canvas, ctx, particles = [], animationId;
+function startWeatherAnimation() {
+ if (!monitoringMap?.container) return;
+ canvas = document.createElement('canvas');
+ canvas.style.cssText = 'position:absolute; top:0; left:0; pointer-events:none; z-index:1000;';
+ const container = monitoringMap.container.getElement();
+ container.style.position = 'relative';
+ container.appendChild(canvas);
+
+ function resize() { canvas.width = container.clientWidth; canvas.height = container.clientHeight; }
+ resize(); window.addEventListener('resize', resize);
+ monitoringMap.events.add('boundschange', resize);
+
+ function animate() {
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ const ws = weatherData.wind_speed * 1.2;
+ const angle = { n:0, ne:45, e:90, se:135, s:180, sw:225, w:270, nw:315 }[weatherData.wind_dir] || 0;
+ const rad = angle * Math.PI / 180;
+
+ if (isWindOn) {
+ if (particles.length < 70) particles.push({ x: Math.random() * canvas.width, y: Math.random() * canvas.height, life: 1 });
+ particles.forEach((p, i) => {
+ p.x += Math.cos(rad) * ws; p.y += Math.sin(rad) * ws; p.life -= 0.01;
+ if (p.life > 0) {
+ ctx.strokeStyle = `rgba(52, 152, 219, ${p.life})`; ctx.lineWidth = 3;
+ ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(p.x - Math.cos(rad)*30, p.y - Math.sin(rad)*30); ctx.stroke();
+ } else particles.splice(i, 1);
+ });
+ }
+ if (isRainOn && weatherData.prec_strength > 0) {
+ for (let i = 0; i < 5 * weatherData.prec_strength; i++) particles.push({ x: Math.random() * canvas.width, y: -10, life: 1, isRain: true });
+ particles.forEach((p, i) => {
+ if (!p.isRain) return;
+ p.y += 10; p.life -= 0.02;
+ if (p.life > 0 && p.y < canvas.height) {
+ ctx.strokeStyle = weatherData.prec_type === 2 ? 'rgba(255,255,255,0.8)' : 'rgba(100,180,255,0.7)';
+ ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(p.x, p.y + 12); ctx.stroke();
+ } else if (p.y >= canvas.height) particles.splice(i, 1);
+ });
+ }
+ animationId = requestAnimationFrame(animate);
+ }
+ ctx = canvas.getContext('2d'); animate();
+}
+
+setTimeout(() => {
+ refreshWeather();
+ setInterval(refreshWeather, 600000);
+ if (monitoringMap) startWeatherAnimation();
+
+ const toggleWindBtn = document.getElementById('toggleWind');
+ if (toggleWindBtn) toggleWindBtn.addEventListener('click', () => { isWindOn = !isWindOn; toggleWindBtn.textContent = `Ветер: ${isWindOn ? 'Вкл' : 'Выкл'}`; });
+
+ const togglePrecipBtn = document.getElementById('togglePrecip');
+ if (togglePrecipBtn) togglePrecipBtn.addEventListener('click', () => { isRainOn = !isRainOn; togglePrecipBtn.textContent = `Осадки: ${isRainOn ? 'Вкл' : 'Выкл'}`; });
+}, 1500);
\ No newline at end of file
diff --git a/static/js/map-graphics.js b/static/js/map-graphics.js
new file mode 100644
index 0000000..59d81f9
--- /dev/null
+++ b/static/js/map-graphics.js
@@ -0,0 +1,144 @@
+window.MapGraphics = {
+ pollutionLayer: null,
+ windVectors: [],
+ sourceGeoObjects: [],
+ drawMode: null,
+ currentGeometry: [],
+ tempGeoObject: null,
+ currentSubstanceId: 1, // ID вещества по умолчанию
+
+ initPollutionLayer(map, substanceId = 1) {
+ this.currentSubstanceId = substanceId;
+ const tileUrlTemplate = `/api/simulation/tiles/${this.currentSubstanceId}/%z/%x/%y.png?t=` + Date.now();
+ this.pollutionLayer = new ymaps.Layer(tileUrlTemplate, {
+ tileTransparent: true,
+ zIndex: 5000,
+ minZoom: 9,
+ maxZoom: 19
+ });
+ map.layers.add(this.pollutionLayer);
+ },
+
+ refreshPollutionLayer(map, newSubstanceId = null) {
+ if (!this.pollutionLayer) return;
+ if (newSubstanceId) this.currentSubstanceId = newSubstanceId;
+
+ map.layers.remove(this.pollutionLayer);
+
+ const tileUrlTemplate = `/api/simulation/tiles/${this.currentSubstanceId}/%z/%x/%y.png?t=` + Date.now();
+ this.pollutionLayer = new ymaps.Layer(tileUrlTemplate, {
+ tileTransparent: true,
+ zIndex: 5000,
+ minZoom: 9,
+ maxZoom: 19
+ });
+
+ map.layers.add(this.pollutionLayer);
+ },
+
+ drawWindVectors(map, sources, windDirection) {
+ this.windVectors.forEach(pm => map.geoObjects.remove(pm));
+ this.windVectors = [];
+
+ sources.forEach(source => {
+ if (source.type !== 'point' && !source.latitude) return;
+
+ const plumeDir = (windDirection + 180) % 360;
+ const dirRad = (plumeDir * Math.PI) / 180;
+ const length = 1500;
+
+ const dLat = (length * Math.cos(dirRad)) / 111000;
+ const dLng = (length * Math.sin(dirRad)) / (111000 * Math.cos(source.lat * Math.PI / 180));
+
+ const polyline = new ymaps.Polyline([
+ [source.lat, source.lng],
+ [source.lat + dLat, source.lng + dLng]
+ ], {}, { strokeColor: '#3498db', strokeWidth: 3, strokeOpacity: 0.8 });
+
+ map.geoObjects.add(polyline);
+ this.windVectors.push(polyline);
+ });
+ },
+
+ drawSources(map, sources) {
+ this.sourceGeoObjects.forEach(obj => map.geoObjects.remove(obj));
+ this.sourceGeoObjects = [];
+
+ sources.forEach(source => {
+ let geoObj;
+ const balloonContent = `${source.name}
Тип: ${source.type}
Выброс: ${source.emissionRate} г/с`;
+
+ if (source.type === 'point' || !source.coordinates) {
+ geoObj = new ymaps.Placemark([source.lat, source.lng], { balloonContent }, { preset: 'islands#redIcon' });
+ } else if (source.type === 'line') {
+ geoObj = new ymaps.Polyline(source.coordinates, { balloonContent }, {
+ strokeColor: '#2c3e50',
+ strokeWidth: 4,
+ strokeOpacity: 0.3
+ });
+ } else if (source.type === 'polygon') {
+ geoObj = new ymaps.Polygon([source.coordinates], { balloonContent }, {
+ fillColor: '#2c3e50',
+ fillOpacity: 0.3,
+ strokeColor: '#2c3e50',
+ strokeWidth: 3,
+ strokeOpacity: 0.3
+ });
+ }
+
+ if (geoObj) {
+ map.geoObjects.add(geoObj);
+ this.sourceGeoObjects.push(geoObj);
+ source.placemark = geoObj;
+ }
+ });
+ },
+
+ enableDrawing(map, type, onFinishCallback) {
+ this.drawMode = type;
+ this.currentGeometry = [];
+ map.cursors.push('crosshair');
+
+ if (this._clickHandler) map.events.remove('click', this._clickHandler);
+
+ this._clickHandler = (e) => {
+ const coords = e.get('coords');
+ this.currentGeometry.push(coords);
+
+ if (this.tempGeoObject) map.geoObjects.remove(this.tempGeoObject);
+
+ if (type === 'point') {
+ onFinishCallback('point', coords, null);
+ this.disableDrawing(map);
+ }
+ else if (type === 'line') {
+ this.tempGeoObject = new ymaps.Polyline(this.currentGeometry, {}, { strokeColor: '#3498db', strokeWidth: 4, strokeOpacity: 0.8 });
+ map.geoObjects.add(this.tempGeoObject);
+ }
+ else if (type === 'polygon') {
+ this.tempGeoObject = new ymaps.Polygon([this.currentGeometry], {}, { fillColor: '#3498db', fillOpacity: 0.4, strokeColor: '#2980b9', strokeWidth: 3 });
+ map.geoObjects.add(this.tempGeoObject);
+ }
+ };
+
+ this._dblClickHandler = (e) => {
+ e.preventDefault();
+ if (type === 'line' || type === 'polygon') {
+ onFinishCallback(type, this.currentGeometry[0], this.currentGeometry);
+ this.disableDrawing(map);
+ }
+ };
+
+ map.events.add('click', this._clickHandler);
+ if (type !== 'point') map.events.add('dblclick', this._dblClickHandler);
+ },
+
+ disableDrawing(map) {
+ this.drawMode = null;
+ map.cursors.push('arrow');
+ if (this._clickHandler) map.events.remove('click', this._clickHandler);
+ if (this._dblClickHandler) map.events.remove('dblclick', this._dblClickHandler);
+ if (this.tempGeoObject) map.geoObjects.remove(this.tempGeoObject);
+ this.tempGeoObject = null;
+ }
+};
\ No newline at end of file
diff --git a/templates/enterprise.html b/templates/enterprise.html
new file mode 100644
index 0000000..e2d7573
--- /dev/null
+++ b/templates/enterprise.html
@@ -0,0 +1,204 @@
+
+
+
+
+ Система моделирования выбросов вредных веществ
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Статус: Загрузка...
+
+
+
+
Ошибка загрузки карты
+
Проверьте подключение к интернету и API-ключ Яндекс.Карт
+
+
+ +
+
+
Расчет рассеивания загрязнений...
+
+
0%
+
+
+
+
Радиус: 100 пикселей
+
+
+
+
+
+
+
+
Настройки ветра
+
+
+
2.4 м/с
+
+
+
180° (Юг)
+
+
+
+
+
+
+
+
+
+
+
+
+ 09:3009:4009:50
+ 10:0010:1010:20
+ Сейчас10:4010:50
+ 11:0011:1011:2011:30
+
+
+
+
+
+
+
+
+
+
Вы уверены, что хотите удалить этот источник?
+
+
+
+
+
diff --git a/templates/forecast.html b/templates/forecast.html
deleted file mode 100644
index 566549b..0000000
--- a/templates/forecast.html
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
- Title
-
-
-
-
-
\ No newline at end of file
diff --git a/templates/forecasting.html b/templates/forecasting.html
new file mode 100644
index 0000000..4ffd9a9
--- /dev/null
+++ b/templates/forecasting.html
@@ -0,0 +1,125 @@
+
+
+
+
+ Система моделирования выбросов вредных веществ
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Результаты предсказания с помощью модели
+
+
+
+
Прогнозируемый уровень концентрации (средний за год):
+
0.03 ПДК
+
+
+
Возможная ошибка прогнозирования:
+
1.05 %
+
+
+
+
+ Анализ факторов, влияющих на концентрацию вещества
+
+
+
+
+
Ветер (восточный, 19 м/с)
+
+
+
+
+
Класс устойчивости по Пасквиллу
+
+
+
+
+
+
+
Особенности выбранной местности
+
+
+
+
+
Предприятия, находящиеся рядом
+
+
+
+
+
+
diff --git a/templates/history.html b/templates/history.html
index 566549b..67a7e66 100644
--- a/templates/history.html
+++ b/templates/history.html
@@ -1,10 +1,124 @@
-
-
+
+
-
- Title
+
+ Система моделирования выбросов вредных веществ
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
СОДЕРЖАНИЕ В ВОЗДУХЕ, мг/м³
+
+
+
+
+
+ Декабрь 2024
+ Январь 2025
+ Февраль 2025
+ Март 2025
+ Апрель 2025
+ Май 2025
+ Июнь 2025
+ Июль 2025
+ Август 2025
+ Сейчас
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/templates/index.html b/templates/index.html
index 566549b..912f253 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -1,10 +1,134 @@
-
-
+
+
-
- Title
+
+ Система моделирования выбросов вредных веществ
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 09:3009:4009:50
+ 10:0010:1010:20
+ Сейчас10:4010:50
+ 11:0011:1011:2011:30
+
+
+
+
+
+
+
+
+
Вы уверены, что хотите удалить этот источник?
+
+
+
\ No newline at end of file
diff --git a/templates/layout.html b/templates/layout.html
new file mode 100644
index 0000000..66ced7d
--- /dev/null
+++ b/templates/layout.html
@@ -0,0 +1,31 @@
+
+
+
+
+
+ {% block title %}������� ������������� ��������{% endblock %}
+
+ {% block extra_css %}{% endblock %}
+
+
+
+
+
+ {% block content %}{% endblock %}
+
+
+ {% block scripts %}{% endblock %}
+
+
diff --git a/templates/login.html b/templates/login.html
new file mode 100644
index 0000000..2f285fe
--- /dev/null
+++ b/templates/login.html
@@ -0,0 +1,86 @@
+
+
+
+
+ Система моделирования выбросов вредных веществ
+
+
+
+
+
+
+
+
+
+
+
Уже зарегистрированы
в нашей системе?
+
+
+
+
+
+
Анкета для регистрации нового предприятия
+
+
+
+
+
+
+
+
+
+ Я даю согласие на обработку данных
+
+
+
+
+
+
+
diff --git a/templates/monitoring.html b/templates/monitoring.html
deleted file mode 100644
index 566549b..0000000
--- a/templates/monitoring.html
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
- Title
-
-
-
-
-
\ No newline at end of file
diff --git a/templates/recomendations.html b/templates/recomendations.html
deleted file mode 100644
index 566549b..0000000
--- a/templates/recomendations.html
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
- Title
-
-
-
-
-
\ No newline at end of file
diff --git a/templates/recommendations.html b/templates/recommendations.html
new file mode 100644
index 0000000..4e608dd
--- /dev/null
+++ b/templates/recommendations.html
@@ -0,0 +1,104 @@
+
+
+
+
+ Система моделирования выбросов вредных веществ
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 09:3009:4009:50
+ 10:0010:1010:20
+ Сейчас10:4010:50
+ 11:0011:1011:2011:30
+
+
+
+
+
+
+
\ No newline at end of file