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) - - - - -
-
Моделирование выбросов H2S
-
- - -
-
- -
-
-
-

Параметры ветра

-
- - -
-
- - -
-
- - -
- -
- -
-

Источник выброса

-
- - -
-
- - -
-
- - -
- -
- -
-

Список источников

-
-
- -
-

Результаты расчёта

-
-
-
- -
-
-
-
- - - - - - \ 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 @@ + + + + + Система моделирования выбросов вредных веществ + + + + + + + + +
+
+ Логотип +

Система моделирования выбросов вредных веществ

+
+ +
+ +
+ + +
+
+
+ Ветер: 2.4 м/с    + Направление: Юг    + Класс устойчивости: D +
+
+ +
+
+ Статус: Загрузка... +
+ +
+

Ошибка загрузки карты

+

Проверьте подключение к интернету и API-ключ Яндекс.Карт

+ +
+ + +
+
Расчет рассеивания загрязнений...
+
+
+
+
0%
+
+ +
+
Радиус: 100 пикселей
+ + + + +
+ +
+
Настройки ветра
+ + +
2.4 м/с
+ + +
180° (Юг)
+ +
+
+
С
+
В
+
Ю
+
З
+
+
+
180°
+
+ + + + +
+ +
+
+
+ < 20% ПДК +
+
+
+ 20-60% ПДК +
+
+
+ 60-80% ПДК +
+
+
+ > 80% ПДК +
+
+ +
+
+ 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 %
+
+
+
+ +

Анализ факторов, влияющих на концентрацию вещества

+ +
+
+
+
0.5%
+
+
Ветер (восточный, 19 м/с)
+
+ +
+
+
7.5%
+
+
Класс устойчивости по Пасквиллу
+
+ +
+
+
10.7%
+
+
Погодные условия
+
+ +
+
+
5.5%
+
+
Особенности выбранной местности
+
+ +
+
+
22.25%
+
+
Предприятия, находящиеся рядом
+
+
+
+
+ + 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 + + Система моделирования выбросов вредных веществ + + + + + +
+
+ Логотип +

Система моделирования выбросов вредных веществ

+
+ +
+ +
+ + + +
+
+
+ Ветер: 1,9 м/с, В Давление: 764 мм рт. ст. Влажность: 61% Дальность видимости: 61 км Изотермия: не наблюдается +
+
+ +
+
СОДЕРЖАНИЕ В ВОЗДУХЕ, мг/м³
+ +
+ +
+
+ Декабрь 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 + + Система моделирования выбросов вредных веществ + + + + + + + +
+
+ Логотип +

Система моделирования выбросов вредных веществ

+
+ + +
+
+ + +
+
+
+ Ветер: 1,9 м/с, В Давление: 764 мм рт. ст. Влажность: 61% Дальность видимости: 61 км Изотермия: не + наблюдается +
+
+ +
+ + + +
+ +
+ +
+
+ 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 @@ + + + + + Система моделирования выбросов вредных веществ + + + + + +
+
+ Логотип +

Система моделирования выбросов вредных веществ

+
+ +
+ +
+ +
+

Уже зарегистрированы
в нашей системе?

+
+ + + + + + +
+ Я не робот + reCAPTCHA +
+ + +
+
+ + +
+

Анкета для регистрации нового предприятия

+
+
+ + + + + + + + +
+
+ + + + + + + + +
+
+ + + + + + + + + + +
+
+ + 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 @@ + + + + + Система моделирования выбросов вредных веществ + + + + + + + + + + + +
+
+ Логотип +

Система моделирования выбросов вредных веществ

+
+ + +
+ +
+ + + + +
+
+
+ Ветер: 1,9 м/с, В Давление: 764 мм рт. ст. Влажность: 61% Дальность видимости: 61 км Изотермия: не наблюдается +
+
+ +
+ +
+
+ 09:3009:4009:50 + 10:0010:1010:20 + Сейчас10:4010:50 + 11:0011:1011:2011:30 +
+
+
+
+
+
+
+
+
+
+ + \ No newline at end of file