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..3671ece 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,7 +1,10 @@ - + + + - \ 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/alembic.ini b/alembic.ini new file mode 100644 index 0000000..dcebfc8 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,150 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s +file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d_%%(minute).2d_%%(second)d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..e0d0858 --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration with an async dbapi. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..3d0ca9d --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,93 @@ +import asyncio +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context +from backend.app.core.config import settings +from backend.app.models import Base + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config +config.set_main_option("sqlalchemy.url", + f'postgresql+asyncpg://{settings.database_username}:{settings.database_password}@' + f'{settings.database_hostname}:{settings.database_port}/{settings.database_name}') + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/2026_04_16_15_47_35_cc6d9b1ffbc7_create_initial_tables.py b/alembic/versions/2026_04_16_15_47_35_cc6d9b1ffbc7_create_initial_tables.py new file mode 100644 index 0000000..39139a3 --- /dev/null +++ b/alembic/versions/2026_04_16_15_47_35_cc6d9b1ffbc7_create_initial_tables.py @@ -0,0 +1,77 @@ +"""create initial tables + +Revision ID: cc6d9b1ffbc7 +Revises: +Create Date: 2026-04-16 15:47:35.818457 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = 'cc6d9b1ffbc7' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('simulation_params', + sa.Column('id', sa.SmallInteger(), nullable=False), + sa.Column('wind_speed', sa.Float(), nullable=False), + sa.Column('wind_direction', sa.Float(), nullable=False), + sa.Column('stability_class', sa.String(), nullable=False), + sa.Column('updated_at', sa.TIMESTAMP(), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('substances', + sa.Column('id', sa.SmallInteger(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('short_name', sa.String(), nullable=False), + sa.Column('mcl', sa.Float(), nullable=False), + sa.Column('density', sa.Float(), nullable=False), + sa.Column('settling_velocity', sa.Float(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_substances_id'), 'substances', ['id'], unique=False) + op.create_index(op.f('ix_substances_name'), 'substances', ['name'], unique=True) + op.create_index(op.f('ix_substances_short_name'), 'substances', ['short_name'], unique=True) + op.create_table('sources', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('type', sa.Enum('point', 'line', 'polygon', name='sourcetypeenum'), nullable=False), + sa.Column('latitude', sa.Float(), nullable=False), + sa.Column('longitude', sa.Float(), nullable=False), + sa.Column('height', sa.Float(), nullable=False), + sa.Column('emission_rate', sa.Float(), nullable=False), + sa.Column('substance_id', sa.SmallInteger(), nullable=False), + sa.Column('coordinates', sa.JSON(), nullable=False), + sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['substance_id'], ['substances.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_sources_created_at'), 'sources', ['created_at'], unique=False) + op.create_index(op.f('ix_sources_id'), 'sources', ['id'], unique=False) + op.create_index(op.f('ix_sources_name'), 'sources', ['name'], unique=False) + op.create_index(op.f('ix_sources_type'), 'sources', ['type'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_sources_type'), table_name='sources') + op.drop_index(op.f('ix_sources_name'), table_name='sources') + op.drop_index(op.f('ix_sources_id'), table_name='sources') + op.drop_index(op.f('ix_sources_created_at'), table_name='sources') + op.drop_table('sources') + op.drop_index(op.f('ix_substances_short_name'), table_name='substances') + op.drop_index(op.f('ix_substances_name'), table_name='substances') + op.drop_index(op.f('ix_substances_id'), table_name='substances') + op.drop_table('substances') + op.drop_table('simulation_params') + # ### end Alembic commands ### diff --git a/alembic/versions/2026_04_17_15_04_24_0b1885a08f9d_created_users_table.py b/alembic/versions/2026_04_17_15_04_24_0b1885a08f9d_created_users_table.py new file mode 100644 index 0000000..9d40c1e --- /dev/null +++ b/alembic/versions/2026_04_17_15_04_24_0b1885a08f9d_created_users_table.py @@ -0,0 +1,43 @@ +"""Created Users table + +Revision ID: 0b1885a08f9d +Revises: cc6d9b1ffbc7 +Create Date: 2026-04-17 15:04:24.913491 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = '0b1885a08f9d' +down_revision: Union[str, Sequence[str], None] = 'cc6d9b1ffbc7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('first_name', sa.String(), nullable=False), + sa.Column('last_name', sa.String(), nullable=False), + sa.Column('patronymic', sa.String(), nullable=True), + sa.Column('email', sa.String(), nullable=False), + sa.Column('role', sa.Enum('professor', 'student', 'admin', name='rolestypesenum'), nullable=False), + sa.Column('password', sa.String(), nullable=False), + sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('now()'), nullable=False), + sa.Column('group', sa.JSON(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('users') + # ### end Alembic commands ### diff --git a/alembic/versions/2026_04_19_16_36_0_8f2abe3a6d95_created_scenarios_table.py b/alembic/versions/2026_04_19_16_36_0_8f2abe3a6d95_created_scenarios_table.py new file mode 100644 index 0000000..81bac27 --- /dev/null +++ b/alembic/versions/2026_04_19_16_36_0_8f2abe3a6d95_created_scenarios_table.py @@ -0,0 +1,56 @@ +"""created Scenarios table + +Revision ID: 8f2abe3a6d95 +Revises: 0b1885a08f9d +Create Date: 2026-04-19 16:36:00.918844 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '8f2abe3a6d95' +down_revision: Union[str, Sequence[str], None] = '0b1885a08f9d' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('scenarios', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_scenarios_user_id'), 'scenarios', ['user_id'], unique=False) + op.add_column('sources', sa.Column('scenario_id', sa.Integer(), nullable=False)) + op.alter_column('sources', 'coordinates', + existing_type=postgresql.JSON(astext_type=sa.Text()), + nullable=True) + op.create_index(op.f('ix_sources_scenario_id'), 'sources', ['scenario_id'], unique=False) + op.create_index(op.f('ix_sources_substance_id'), 'sources', ['substance_id'], unique=False) + op.create_foreign_key(None, 'sources', 'scenarios', ['scenario_id'], ['id'], ondelete='CASCADE') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'sources', type_='foreignkey') + op.drop_index(op.f('ix_sources_substance_id'), table_name='sources') + op.drop_index(op.f('ix_sources_scenario_id'), table_name='sources') + op.alter_column('sources', 'coordinates', + existing_type=postgresql.JSON(astext_type=sa.Text()), + nullable=False) + op.drop_column('sources', 'scenario_id') + op.drop_index(op.f('ix_scenarios_user_id'), table_name='scenarios') + op.drop_index(op.f('ix_scenarios_id'), table_name='scenarios') + op.drop_table('scenarios') + # ### end Alembic commands ### diff --git a/alembic/versions/2026_05_16_15_02_27_f62ec9fcff81_added_sun_brightness_and_cloud_density_.py b/alembic/versions/2026_05_16_15_02_27_f62ec9fcff81_added_sun_brightness_and_cloud_density_.py new file mode 100644 index 0000000..f8e6d2d --- /dev/null +++ b/alembic/versions/2026_05_16_15_02_27_f62ec9fcff81_added_sun_brightness_and_cloud_density_.py @@ -0,0 +1,34 @@ +"""added sun_brightness and cloud_density column in simulation_params + +Revision ID: f62ec9fcff81 +Revises: 8f2abe3a6d95 +Create Date: 2026-05-16 15:02:27.131728 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f62ec9fcff81' +down_revision: Union[str, Sequence[str], None] = '8f2abe3a6d95' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('simulation_params', sa.Column('sun_brightness', sa.Float(), nullable=False)) + op.add_column('simulation_params', sa.Column('cloud_density', sa.SmallInteger(), nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('simulation_params', 'cloud_density') + op.drop_column('simulation_params', 'sun_brightness') + # ### end Alembic commands ### 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..42deb87 --- /dev/null +++ b/backend/app/app.py @@ -0,0 +1,17 @@ +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from .routers import pages, sources_api, simulation_params_api, layout, substances_api, users, auth, scenarios +from .core.config import STATIC_DIR + +app = FastAPI() + +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) +app.include_router(users.router) +app.include_router(auth.router) +app.include_router(scenarios.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/auth.py b/backend/app/core/auth.py new file mode 100644 index 0000000..d0517b3 --- /dev/null +++ b/backend/app/core/auth.py @@ -0,0 +1,128 @@ +import jwt +from jwt.exceptions import PyJWTError, InvalidTokenError +from datetime import datetime, timedelta, timezone +from fastapi.security import OAuth2PasswordBearer +from fastapi import HTTPException, status, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from .config import settings + +from ..database import get_db +from ..schemas.tokens import TokenData +from ..models.users import Users, RolesTypesEnum + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login") + +SECRET_KEY = settings.secret_key +ALGORITHM = settings.algorithm +ACCESS_TOKEN_EXPIRE_MINUTES = settings.access_token_expiration_minutes +REFRESH_TOKEN_EXPIRE_DAYS = settings.refresh_token_expiration_days + +TOKEN_TYPE_FIELD = "type" +ACCESS_TOKEN_TYPE = "access" +REFRESH_TOKEN_TYPE = "refresh" + + +def create_token(data: dict, token_type: str): + jwt_payload = {TOKEN_TYPE_FIELD: token_type, } + jwt_payload.update(data) + return jwt.encode(jwt_payload, SECRET_KEY, algorithm=ALGORITHM) + + +def create_access_token(data: dict): + to_encode = data.copy() + + expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + to_encode.update({"exp": expire}) + encoded_jwt = create_token(to_encode, ACCESS_TOKEN_TYPE) + + return encoded_jwt + + +def create_refresh_token(data: dict): + to_encode = data.copy() + + expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) + to_encode.update({"exp": expire}) + encoded_jwt = create_token(to_encode, REFRESH_TOKEN_TYPE) + + return encoded_jwt + + +def verify_token(token: str, credentials_exception): + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + + user_id: str = payload.get("user_id") + + if not user_id: + raise credentials_exception + token_data = TokenData(id=user_id) + + except PyJWTError: + raise credentials_exception + + return token_data + + +def get_current_token_payload(token: str = Depends(oauth2_scheme)): + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + except InvalidTokenError as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"invalid token error: {e}", + ) + return payload + + +def validate_token_type(payload: dict, token_type: str): + if (current_token_type := payload.get(TOKEN_TYPE_FIELD)) == token_type: + return True + + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"Invalid token type ({current_token_type!r}) received; {token_type!r} expected" + ) + + +async def get_user_by_token(token: str = Depends(oauth2_scheme), db: AsyncSession = Depends(get_db)): + credentials_exception = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid credentials", + headers={"WWW-Authenticate": "Bearer"}) + token = verify_token(token, credentials_exception) + + stmt = select(Users).where(Users.id == token.id) + result = await db.execute(stmt) + return result.scalars().first() + + +async def get_current_user(payload: dict = Depends(get_current_token_payload), + token: str = Depends(oauth2_scheme), + db: AsyncSession = Depends(get_db)): + validate_token_type(payload, ACCESS_TOKEN_TYPE) + return await get_user_by_token(token, db) + + +async def get_current_user_for_refresh(payload: dict = Depends(get_current_token_payload), + token: str = Depends(oauth2_scheme), + db: AsyncSession = Depends(get_db), ): + validate_token_type(payload, REFRESH_TOKEN_TYPE) + return await get_user_by_token(token, db) + + +def require_roles(*roles: RolesTypesEnum): + async def check_role(current_user: Users = Depends(get_current_user)): + if current_user.role not in roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Недостаточно прав" + ) + return current_user + + return check_role + + +def require_admin(): + return require_roles(RolesTypesEnum.admin) diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..10ec6d2 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,44 @@ +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 + refresh_token_expiration_days: int + + 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..8a0662a --- /dev/null +++ b/backend/app/core/emissions_calculation_math/math_numba.py @@ -0,0 +1,167 @@ +# 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, sun_brightness, cloud_density +): + # ---------- 1. Определение класса устойчивости (один раз) ---------- + # Переводим облачность из окт (0-8) в вещественное, если нужно + cloud_octas = float(cloud_density) + wind_speed = max(0.5, u) + + # Класс устойчивости: 0=A,1=B,2=C,3=D,4=E,5=F + if sun_brightness > 0.0: + # День + if sun_brightness > 70000.0: + # сильная инсоляция + if wind_speed < 2.0: + stab_class = 0 # A + elif wind_speed < 3.0: + stab_class = 1 # B (A-B округляем до B) + elif wind_speed < 5.0: + stab_class = 2 # C + else: + stab_class = 3 # D + elif sun_brightness > 35000.0: + # умеренная + if wind_speed < 2.0: + stab_class = 1 # B + elif wind_speed < 3.0: + stab_class = 2 # C + elif wind_speed < 5.0: + stab_class = 2 # C + else: + stab_class = 3 # D + else: + # слабая инсоляция + stab_class = 3 # D + else: + # Ночь + if cloud_octas <= 3.0: + # мало облаков + if wind_speed < 2.0: + stab_class = 5 # F + elif wind_speed < 3.0: + stab_class = 4 # E + elif wind_speed < 5.0: + stab_class = 4 # E + else: + stab_class = 3 # D + elif cloud_octas <= 7.0: + # облачно + if wind_speed < 2.0: + stab_class = 4 # E + else: + stab_class = 3 # D + else: + # сплошная облачность + stab_class = 3 # D + + # ---------- 2. Множители дисперсии в зависимости от класса ---------- + if stab_class == 0: # A + mult_sy = 2.2 + mult_sz = 2.0 + elif stab_class == 1: # B + mult_sy = 1.5 + mult_sz = 1.3 + elif stab_class == 2: # C + mult_sy = 1.2 + mult_sz = 1.1 + elif stab_class == 3: # D + mult_sy = 1.0 + mult_sz = 1.0 + elif stab_class == 4: # E + mult_sy = 0.8 + mult_sz = 0.7 + else: # F + mult_sy = 0.5 + mult_sz = 0.4 + + 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 + + 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 + + # ---------- 3. PUFF (радиальное пятно) ---------- + 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 + + # ---------- 4. PLUME (шлейф) с учётом класса устойчивости ---------- + plume_val = 0.0 + if downwind > 0: + sigma_y = src_sy0[k] + (mult_sy * 0.18) * downwind / np.sqrt(1.0 + 0.0001 * downwind) + 20.0 + sigma_z = src_sz0[k] + (mult_sz * 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/core/utils.py b/backend/app/core/utils.py new file mode 100644 index 0000000..0d37d79 --- /dev/null +++ b/backend/app/core/utils.py @@ -0,0 +1,11 @@ +from passlib.context import CryptContext +pwd_context = CryptContext(schemes=['bcrypt'], deprecated="auto") + + +def hashing(password: str): + len(password) + return pwd_context.hash(password) + + +def verify(plain_password, hashed_password): + return pwd_context.verify(plain_password, hashed_password) \ No newline at end of file diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..3d6405b --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,17 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker +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) + + +async def get_db(): + async with SessionLocal() as db: + yield db diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..9049d89 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,15 @@ +__all__ = ( + "Sources", + "SimulationParams", + "Substances", + "Base", + "Users", + "Scenarios" +) + +from .sources import Sources +from .simulation_params import SimulationParams +from .substances import Substances +from .base import Base +from .users import Users +from .scenarios import Scenarios diff --git a/backend/app/models/base.py b/backend/app/models/base.py new file mode 100644 index 0000000..fa2b68a --- /dev/null +++ b/backend/app/models/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/backend/app/models/scenarios.py b/backend/app/models/scenarios.py new file mode 100644 index 0000000..0f715cd --- /dev/null +++ b/backend/app/models/scenarios.py @@ -0,0 +1,26 @@ +from .base import Base +from typing import TYPE_CHECKING +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import Integer, String, TIMESTAMP, func, ForeignKey +from datetime import datetime + +if TYPE_CHECKING: + from .users import Users + from .sources import Sources + + +class Scenarios(Base): + __tablename__ = "scenarios" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String, nullable=False) + user_id: Mapped[int] = mapped_column(Integer, + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + index=True) + created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now(), nullable=False) + + source: Mapped[list["Sources"]] = relationship(back_populates="scenario", + cascade="all, delete-orphan", + passive_deletes=True) + user: Mapped["Users"] = relationship(back_populates="scenario") diff --git a/backend/app/models/simulation_params.py b/backend/app/models/simulation_params.py new file mode 100644 index 0000000..e58aa82 --- /dev/null +++ b/backend/app/models/simulation_params.py @@ -0,0 +1,22 @@ +from .base import Base +from sqlalchemy import String, SmallInteger, func, TIMESTAMP, Float +from sqlalchemy.orm import Mapped, mapped_column +from datetime import datetime + + +class SimulationParams(Base): + __tablename__ = "simulation_params" + + id: Mapped[int] = mapped_column(SmallInteger, primary_key=True, nullable=False) + wind_speed: Mapped[float] = mapped_column(Float, nullable=False) + wind_direction: Mapped[float] = mapped_column(Float, nullable=False) # Тут будет измеряться вградусах + stability_class: Mapped[str] = mapped_column(String, nullable=False) + updated_at: Mapped[datetime] = mapped_column(TIMESTAMP, nullable=False, server_default=func.now()) + """ + Илюха, кароч, тут 2 параметра яркость Солнца в люксах и облачность в октах. + Если захочешь поменять ОБЯЗАТЕЛЬНО после правок пишешь в консоли alembic revision "имя ревизии" --autogenerate + Проверяешь правильность миграции и после этого делаешь alembic upgrade head + НЕ ТРОГАЙ СУЩЕСТВУЮЩИЕ РЕВИЗИИ!!! + """ + sun_brightness: Mapped[float] = mapped_column(Float, nullable=False) + cloud_density: Mapped[int] = mapped_column(SmallInteger, nullable=False) \ No newline at end of file diff --git a/backend/app/models/sources.py b/backend/app/models/sources.py new file mode 100644 index 0000000..63ba037 --- /dev/null +++ b/backend/app/models/sources.py @@ -0,0 +1,53 @@ +from .base import Base +from sqlalchemy import String, Integer, func, TIMESTAMP, Float, JSON, ForeignKey, SmallInteger, Enum as SQLEnum +from sqlalchemy.orm import Mapped, mapped_column, relationship +from enum import Enum +from datetime import datetime +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .substances import Substances + from .scenarios import Scenarios + + +class SourceTypeEnum(str, Enum): + point = "point" + line = "line" + polygon = "polygon" + + +class Sources(Base): + __tablename__ = "sources" + + id: Mapped[int] = mapped_column(Integer, + primary_key=True, + index=True) + name: Mapped[str] = mapped_column(String, + nullable=False, + index=True) + type: Mapped[SourceTypeEnum] = mapped_column(SQLEnum(SourceTypeEnum), + default=SourceTypeEnum.point, + nullable=False, + index=True) + latitude: Mapped[float] = mapped_column(Float, nullable=False) + longitude: Mapped[float] = mapped_column(Float, nullable=False) + height: Mapped[float] = mapped_column(Float, nullable=False) + emission_rate: Mapped[float] = mapped_column(Float, nullable=False) + substance_id: Mapped[int] = mapped_column(SmallInteger, + ForeignKey("substances.id", + ondelete="CASCADE"), + nullable=False, + index=True) + scenario_id: Mapped[int] = mapped_column(Integer, + ForeignKey("scenarios.id", + ondelete="CASCADE"), + nullable=False, + index=True) + coordinates: Mapped[list | None] = mapped_column(JSON) + created_at: Mapped[datetime] = mapped_column(TIMESTAMP, + server_default=func.now(), + index=True, + nullable=False) + + scenario: Mapped["Scenarios"] = relationship(back_populates="source", passive_deletes=True) + substance: Mapped["Substances"] = relationship(back_populates="source") diff --git a/backend/app/models/substances.py b/backend/app/models/substances.py new file mode 100644 index 0000000..332c455 --- /dev/null +++ b/backend/app/models/substances.py @@ -0,0 +1,23 @@ +from sqlalchemy import String, SmallInteger, Float +from sqlalchemy.orm import Mapped, mapped_column, relationship +from typing import TYPE_CHECKING +from .base import Base + +if TYPE_CHECKING: + from .sources import Sources + + +class Substances(Base): + __tablename__ = "substances" + + id: Mapped[int] = mapped_column(SmallInteger, index=True, primary_key=True) + name: Mapped[str] = mapped_column(String, nullable=False, unique=True, index=True) + short_name: Mapped[str] = mapped_column(String, nullable=False, unique=True, index=True) + mcl: Mapped[float] = mapped_column(Float, nullable=False) # MCL = ПДК + density: Mapped[float] = mapped_column(Float, nullable=False) + settling_velocity: Mapped[float] = mapped_column(Float, nullable=False) # скорость оседания + # hazard_level = Column(Integer, nullable=False) - может понадобиться если будем пилить справочную информацию + # custom_color=Column(String, default="#E74C3C")-если захочется шлейф задавать кастомным цветом для каждого вещества + + + source: Mapped[list["Sources"]] = relationship(back_populates="substance") diff --git a/backend/app/models/users.py b/backend/app/models/users.py new file mode 100644 index 0000000..6f43472 --- /dev/null +++ b/backend/app/models/users.py @@ -0,0 +1,33 @@ +from .base import Base + +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import Integer, String, func, TIMESTAMP, JSON, Enum as SQLEnum +from enum import Enum +from datetime import datetime +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .scenarios import Scenarios + + +class RolesTypesEnum(str, Enum): + professor = "professor", + student = "student", + admin = "admin" + + +class Users(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + first_name: Mapped[str] = mapped_column(String, nullable=False) + last_name: Mapped[str] = mapped_column(String, nullable=False) + patronymic: Mapped[str | None] = mapped_column(String, nullable=True) + email: Mapped[str] = mapped_column(String, unique=True) + role: Mapped[RolesTypesEnum] = mapped_column(SQLEnum(RolesTypesEnum), default=RolesTypesEnum.student, + nullable=False) + password: Mapped[str] = mapped_column(String, nullable=False) + created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now()) + group: Mapped[list] = mapped_column(JSON, nullable=False) + + scenario: Mapped[list["Scenarios"]] = relationship(back_populates="user") 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/scenarios.py b/backend/app/repositories/scenarios.py new file mode 100644 index 0000000..f5d49ee --- /dev/null +++ b/backend/app/repositories/scenarios.py @@ -0,0 +1,47 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload +from sqlalchemy import select, Sequence + +from ..models.scenarios import Scenarios +from ..schemas.scenarios import ScenariosCreate + + +class ScenariosRepository: + def __init__(self, db: AsyncSession): + self.db = db + + async def get_all_scenarios(self) -> Sequence[Scenarios]: + stmt = select(Scenarios).options(selectinload(Scenarios.user)) + result = await self.db.execute(stmt) + return result.scalars().all() + + async def get_scenario_by_user_id(self, user_id: int) -> Sequence[Scenarios]: + stmt = select(Scenarios).where(Scenarios.user_id == user_id).options(selectinload(Scenarios.user)) + result = await self.db.execute(stmt) + return result.scalars().all() + + async def get_scenario_by_user_ids(self, user_ids: list[int]) -> Sequence[Scenarios]: + stmt = select(Scenarios).where(Scenarios.user_id.in_(user_ids)).options(selectinload(Scenarios.user)) + result = await self.db.execute(stmt) + return result.scalars().all() + + async def get_scenario_by_id(self, scenario_id: int) -> Scenarios: + stmt = select(Scenarios).where(Scenarios.id == scenario_id).options(selectinload(Scenarios.user)) + result = await self.db.execute(stmt) + return result.scalars().first() + + async def create(self, schema: ScenariosCreate) -> Scenarios: + scenario = Scenarios(**schema.model_dump()) + self.db.add(scenario) + await self.db.commit() + await self.db.refresh(scenario) + return scenario + + async def delete(self, scenario_id: int) -> bool: + scenario = await self.get_scenario_by_id(scenario_id) + if not scenario: + return False + + await self.db.delete(scenario) + await self.db.commit() + return True 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..5dc7564 --- /dev/null +++ b/backend/app/repositories/sources.py @@ -0,0 +1,59 @@ +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: int) -> 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 + + async def get_sources_by_scenario_id(self, scenario_id: int) -> Sequence[Sources]: + stmt = select(Sources).where(Sources.scenario_id == scenario_id) + result = await self.db.execute(stmt) + return result.scalars().all() + + async def get_source_by_id(self, source_id: int) -> Sources | None: + stmt = select(Sources).where(Sources.id == source_id) + result = await self.db.execute(stmt) + return result.scalars().first() + + async def get_sources_by_substance_internal(self, substance_id: int) -> Sequence[Sources]: + stmt = select(Sources).where(Sources.substance_id == substance_id) + result = await self.db.execute(stmt) + return result.scalars().all() diff --git a/backend/app/repositories/substance.py b/backend/app/repositories/substance.py new file mode 100644 index 0000000..8e82faa --- /dev/null +++ b/backend/app/repositories/substance.py @@ -0,0 +1,27 @@ +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/repositories/users.py b/backend/app/repositories/users.py new file mode 100644 index 0000000..46596f6 --- /dev/null +++ b/backend/app/repositories/users.py @@ -0,0 +1,60 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from typing import Sequence + +from ..models.users import Users, RolesTypesEnum +from ..schemas.users import UserCreate + + +class UserRepository: + def __init__(self, db: AsyncSession): + self.db = db + + async def get_users(self): + stmt = select(Users) + result = await self.db.execute(stmt) + + return result.scalars().all() + + async def get_by_id(self, user_id: int): + stmt = select(Users).where(Users.id == user_id) + result = await self.db.execute(stmt) + + return result.scalars().first() + + async def get_by_email(self, user_email: str): + stmt = select(Users).where(Users.email == user_email) + result = await self.db.execute(stmt) + return result.scalars().first() + + async def create_user(self, user_schema: UserCreate): + user_data = user_schema.model_dump() + user = Users(**user_data) + self.db.add(user) + await self.db.commit() + await self.db.refresh(user) + return user + + async def delete_user(self, user_id: int) -> bool: + stmt = select(Users).where(Users.id == user_id) + result = await self.db.execute(stmt) + user = result.scalars().first() + + if not user: + return False + await self.db.delete(user) + await self.db.commit() + + return True + + async def get_students_in_groups(self, groups: list[str]) -> Sequence[Users]: + """ + Здесь мы получим список студентов, у которых есть общая группа с другим пользователем + Используется только для профессоров, для получения ими доступа к данным их студентов + """ + stmt = select(Users).where(Users.role == RolesTypesEnum.student) + result = await self.db.execute(stmt) + students = result.scalars().all() + + professor_groups = set(groups) + return [student for student in students if professor_groups.intersection(student.group)] 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/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..2204044 --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,37 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from fastapi.security.oauth2 import OAuth2PasswordRequestForm + +from ..schemas.tokens import Token +from ..database import get_db +from ..services.auth_service import AuthService + +router = APIRouter(tags=["Authentification"]) + + +@router.post("/login", response_model=Token) +async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)): + auth_service = AuthService(db) + token = await auth_service.login(form_data) + + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid Email or Password", + headers={"WWW-Authenticate": "Bearer"} + ) + + return token + + +@router.post("/refresh", response_model=Token, response_model_exclude_none=True) +async def refresh_token(refresh_token: str, + db: AsyncSession = Depends(get_db), ): + auth_service = AuthService(db) + token = await auth_service.refresh_access_token(refresh_token) + + if not token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid credentials", + headers={"WWW-Authenticate": "Bearer"}) + return token diff --git a/backend/app/routers/layout.py b/backend/app/routers/layout.py new file mode 100644 index 0000000..5bdaf21 --- /dev/null +++ b/backend/app/routers/layout.py @@ -0,0 +1,57 @@ +from fastapi import APIRouter, Depends, Response, status, Query +from sqlalchemy.ext.asyncio import AsyncSession +from io import BytesIO +from typing import Optional +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, + scenario_id: Optional[int] = Query(None), + wind_speed: Optional[float] = Query(3.0), + wind_direction: Optional[float] = Query(180.0), + temperature: Optional[float] = Query(20.0), + pressure: Optional[float] = Query(1013.0), + db: AsyncSession = Depends(get_db) +): + service = LayoutService(db) + image = await service.render_tile( + substance_id=substance_id, + tx=x, + ty=y, + tz=z, + scenario_id=scenario_id, + wind_speed=wind_speed, + wind_direction=wind_direction, + temperature=temperature, + pressure=pressure, + ) + buf = BytesIO() + image.save(buf, format="PNG") + return Response( + content=buf.getvalue(), + media_type="image/png", + headers={ + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache" + } + ) + + +@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"} diff --git a/backend/app/routers/pages.py b/backend/app/routers/pages.py new file mode 100644 index 0000000..911271f --- /dev/null +++ b/backend/app/routers/pages.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter, Request, Depends +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)): + simulation_params_service = SimulationParamsService(db) + + simulation_params = await simulation_params_service.get_simulation_params() + + template = templates.TemplateResponse("index.html", { + "request": request, + "simulation_params": simulation_params, + }) + return template + diff --git a/backend/app/routers/scenarios.py b/backend/app/routers/scenarios.py new file mode 100644 index 0000000..994a480 --- /dev/null +++ b/backend/app/routers/scenarios.py @@ -0,0 +1,53 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from typing import List + +from ..database import get_db +from ..core.auth import get_current_user +from ..schemas.scenarios import ScenariosResponse, ScenariosCreate +from ..models.users import Users +from ..services.scenarios import ScenarioService + +router = APIRouter( + prefix="/scenarios", + tags=["Scenarios"], +) + + +@router.get("/", response_model=List[ScenariosResponse]) +async def get_scenarios(db: AsyncSession = Depends(get_db), current_user: Users = Depends(get_current_user)): + service = ScenarioService(db) + return await service.get_scenarios(current_user) + + +@router.get("/{id}", response_model=ScenariosResponse) +async def get_scenario_by_id( + id: int, + db: AsyncSession = Depends(get_db), + current_user: Users = Depends(get_current_user) +): + service = ScenarioService(db) + return await service.get_scenario_by_id(id, current_user) + + +@router.post("/", response_model=ScenariosResponse, status_code=status.HTTP_201_CREATED) +async def create_scenario( + new_scenario: ScenariosCreate, + db: AsyncSession = Depends(get_db), + current_user: Users = Depends(get_current_user) +): + service = ScenarioService(db) + return await service.create_scenario(new_scenario, current_user) + + +@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_scenario( + id: int, + db: AsyncSession = Depends(get_db), + current_user: Users = Depends(get_current_user) +): + service = ScenarioService(db) + await service.delete_scenario(id, current_user) + + return None + diff --git a/backend/app/routers/simulation_params_api.py b/backend/app/routers/simulation_params_api.py new file mode 100644 index 0000000..13b07bc --- /dev/null +++ b/backend/app/routers/simulation_params_api.py @@ -0,0 +1,23 @@ +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..b37ebc0 --- /dev/null +++ b/backend/app/routers/sources_api.py @@ -0,0 +1,48 @@ +from fastapi import APIRouter, HTTPException, status, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from typing import List +from fastapi.security import HTTPBearer +from ..services.source_service import SourceService +from ..core.emissions_calculation_math.state import pollution_state +from ..database import get_db +from ..schemas.sources import SourcesCreate, SourcesResponse +from ..core.auth import get_current_user + +http_bearer = HTTPBearer(auto_error=False) + +router = APIRouter(tags=['sources'], prefix="/api/sources",dependencies=[Depends(http_bearer)]) + + +@router.get("/", response_model=List[SourcesResponse]) +async def get_all_sources(db: AsyncSession = Depends(get_db), current_user: int = Depends(get_current_user)): + source_service = SourceService(db) + result = await source_service.get_all_sources(current_user) + + return result + + +@router.post("/", status_code=status.HTTP_201_CREATED) +async def create_source(new_source: SourcesCreate, + db: AsyncSession = Depends(get_db), + current_user: int = Depends(get_current_user)): + source_service = SourceService(db) + result = await source_service.add_source(new_source, current_user) + 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),current_user: int = Depends(get_current_user)): + source_service = SourceService(db) + result = await source_service.delete_source(id, current_user) + + if not result: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, + detail="Пользователь с таким id не существует") + + pollution_state.invalidate_sources() + + return None 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/routers/users.py b/backend/app/routers/users.py new file mode 100644 index 0000000..29e1d5d --- /dev/null +++ b/backend/app/routers/users.py @@ -0,0 +1,71 @@ +from fastapi import APIRouter, status, HTTPException, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from ..schemas.users import UserResponse, UserCreate +from ..services.user_service import UserService +from ..database import get_db +from ..core.auth import require_admin, get_current_user +from ..models.users import Users, RolesTypesEnum + +router = APIRouter(prefix="/users", + tags=["users"]) + + +@router.post("/", status_code=status.HTTP_201_CREATED, response_model=UserResponse) +async def create_user(new_user: UserCreate, db: AsyncSession = Depends(get_db)): + user_service = UserService(db) + exists = await user_service.get_user_by_email(new_user.email) + if exists: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"User with email {new_user.email!r} already exists" + ) + created_user = await user_service.create_user(new_user) + return created_user + + +@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_user(id: int, db: AsyncSession = Depends(get_db), current_user: Users = Depends(get_current_user)): + if current_user.role != RolesTypesEnum.admin and current_user.id != id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You only can delete your own account" + ) + + user_service = UserService(db) + user = await user_service.delete_user(id) + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"user with id {id} does not exist" + ) + + return None + + +@router.get("/emails/{email}", response_model=UserResponse) +async def get_user_by_email(email: str, db: AsyncSession = Depends(get_db), current_user: Users = Depends(require_admin())): + user_service = UserService(db) + + user = await user_service.get_user_by_email(email) + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"user with id {email} does not exist" + ) + return user + + +@router.get("/{id}", response_model=UserResponse) +async def get_user_by_id(id: int, db: AsyncSession = Depends(get_db), current_user: Users = Depends(require_admin())): + user_service = UserService(db) + + user = await user_service.get_user_by_id(id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"user with id {id} does not exist" + ) + return user 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/scenarios.py b/backend/app/schemas/scenarios.py new file mode 100644 index 0000000..4566182 --- /dev/null +++ b/backend/app/schemas/scenarios.py @@ -0,0 +1,20 @@ +from pydantic import BaseModel +from .users import UserForScenarioInfo +from datetime import datetime + + +class ScenariosBase(BaseModel): + name: str + + +class ScenariosResponse(ScenariosBase): + id: int + user: UserForScenarioInfo + created_at: datetime + + class Config: + from_attributes = True + + +class ScenariosCreate(ScenariosBase): + user_id: int diff --git a/backend/app/schemas/simulation_params.py b/backend/app/schemas/simulation_params.py new file mode 100644 index 0000000..2727359 --- /dev/null +++ b/backend/app/schemas/simulation_params.py @@ -0,0 +1,26 @@ +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 + + """ + Для Илюхи: яркость зависит от синуса угла поверхности Земли к Солнцу, вместо времени суток + (дефолтное значение - 20000 -тень в полдень) + плотность облаков измеряется в октах, где 0 - безоблачно, а 8 - максимальная плотность + """ + sun_brightness: Annotated[float, Field(gt=0, lt=120000)] = 20000 + cloud_density: Annotated[int, Field(ge=0, le=8)] = 0 + + +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..f591f39 --- /dev/null +++ b/backend/app/schemas/sources.py @@ -0,0 +1,30 @@ +from pydantic import BaseModel, Field +from pydantic_extra_types.coordinate import Longitude, Latitude +from typing import Annotated, Optional, List +from datetime import datetime + +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 + scenario_id: int + coordinates: Optional[List[List[float]]] = None + + +class SourcesCreate(SourcesBase): + pass + + +class SourcesResponse(SourcesBase): + id: int + created_at: datetime + + class Config: + from_attributes = True \ No newline at end of file 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/schemas/tokens.py b/backend/app/schemas/tokens.py new file mode 100644 index 0000000..429acc9 --- /dev/null +++ b/backend/app/schemas/tokens.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel +from ..models.users import RolesTypesEnum + + +class Token(BaseModel): + access_token: str + refresh_token: str | None = None + token_type: str = "Bearer" + + +class TokenData(BaseModel): + id: int | None = None + role: RolesTypesEnum | None = None diff --git a/backend/app/schemas/users.py b/backend/app/schemas/users.py new file mode 100644 index 0000000..2647d8d --- /dev/null +++ b/backend/app/schemas/users.py @@ -0,0 +1,38 @@ +from pydantic import BaseModel, EmailStr, model_validator +from typing import Optional +from ..models.users import RolesTypesEnum + + +class UserBase(BaseModel): + first_name: str + last_name: str + patronymic: Optional[str] = None + group: list[str] + + +class UserCreate(UserBase): + password: str + email: EmailStr + role: RolesTypesEnum + + @model_validator(mode="after") + def validate_group(self) -> "UserBase": + if self.role == RolesTypesEnum.student and len(self.group) != 1: + raise ValueError("Student can only be in one group") + + if len(self.group) == 0: + raise ValueError("Group field required") + + return self + + +class UserResponse(UserBase): + role: RolesTypesEnum + + class Config: + from_attributes = True + + +class UserForScenarioInfo(UserBase): + 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/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..22a1889 --- /dev/null +++ b/backend/app/services/auth_service.py @@ -0,0 +1,73 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from fastapi.security.oauth2 import OAuth2PasswordRequestForm +from datetime import datetime, timezone +from fastapi import HTTPException, status +from ..repositories.users import UserRepository +from ..core.utils import verify +from ..core.auth import (create_access_token, + create_refresh_token, + REFRESH_TOKEN_TYPE, + get_current_token_payload, + validate_token_type) +from ..schemas.tokens import Token + + +""" + Убрать вызовы HTTPResponses когда сделаю кастомные исключениzя +""" + +class AuthService: + def __init__(self, db: AsyncSession): + self.repository = UserRepository(db) + + async def __validate_user(self, user_credentials): + user = await self.repository.get_by_email(user_credentials.username) + + if user is None: + return None + + if not verify(user_credentials.password, user.password): + return None + + return user + + async def login(self, user_credentials: OAuth2PasswordRequestForm): + user = await self.__validate_user(user_credentials) + if not user: + return None + + access_token_data = {"user_id": user.id, + "user_role": user.role, + "iat": datetime.now(timezone.utc)} + + refresh_token_data = {"user_id": user.id, + "iat": datetime.now(timezone.utc)} + + access_token = create_access_token(access_token_data) + refresh_token = create_refresh_token(refresh_token_data) + + return Token(access_token=access_token, refresh_token=refresh_token, token_type="bearer") + + async def refresh_access_token(self, refresh_token: str, ): + payload = get_current_token_payload(refresh_token) + validate_token_type(payload, REFRESH_TOKEN_TYPE) + user_id = payload.get("user_id") + + if not user_id: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid credentials", + headers={"WWW-Authenticate": "Bearer"}) + + user = await self.repository.get_by_id(user_id) + + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid credentials", + headers={"WWW-Authenticate": "Bearer"}) + + access_token_data = {"user_id": user.id, + "user_role": user.role, + "iat": datetime.now(timezone.utc)} + + access_token = create_access_token(access_token_data) + return Token(access_token=access_token, token_type="bearer") diff --git a/backend/app/services/layout_service.py b/backend/app/services/layout_service.py new file mode 100644 index 0000000..a29d37f --- /dev/null +++ b/backend/app/services/layout_service.py @@ -0,0 +1,198 @@ +import numpy as np +from PIL import Image +from sqlalchemy.ext.asyncio import AsyncSession +import time +import logging + +from .source_service import SourceService +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") + +R_DRY_AIR = 287.058 + + +class LayoutService: + def __init__(self, db: AsyncSession): + self.db = db + self.source_service = SourceService(db) + self.substance_service = SubstanceService(db) + + def get_tile_bounds(self, tx: int, ty: int, zoom: int): + 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 + + def _air_density(self, temperature_c: float, pressure_hpa: float) -> float: + + T_k = float(temperature_c) + 273.15 + P_pa = float(pressure_hpa) * 100.0 # гПа → Па + return P_pa / (R_DRY_AIR * T_k) + + def _air_viscosity_sutherland(self, temperature_c: float) -> float: + + T = float(temperature_c) + 273.15 + mu0 = 1.716e-5 + T0 = 273.15 + S = 111.0 + return mu0 * ((T / T0) ** 1.5) * (T0 + S) / (T + S) + + def _compute_weather_factors( + self, + temperature: float, + pressure: float + ) -> tuple[float, float, float]: + + rho = self._air_density(temperature, pressure) + rho_ref = self._air_density(20.0, 1013.25) + + mu = self._air_viscosity_sutherland(temperature) + mu_ref = self._air_viscosity_sutherland(20.0) + + density_ratio = rho_ref / rho + visc_ratio = mu / mu_ref + + kH = 0.35 + height_factor = float(np.clip(density_ratio ** kH, 0.85, 1.30)) + + kSigma_rho = 0.20 + kSigma_mu = 0.10 + sigma_factor = float(np.clip( + (density_ratio ** kSigma_rho) * (visc_ratio ** (-kSigma_mu)), + 0.90, 1.20 + )) + + settling_factor = float(np.clip(mu_ref / mu, 0.75, 1.35)) + + logger.debug( + f"WeatherFactors T={temperature}°C P={pressure}hPa → " + f"rho={rho:.3f} mu={mu:.2e} | " + f"H×{height_factor:.3f} σ×{sigma_factor:.3f} vs×{settling_factor:.3f}" + ) + + return height_factor, sigma_factor, settling_factor + + def _cache_key(self, substance_id: int, scenario_id) -> str: + return '{}_{}'.format(substance_id, scenario_id or 'none') + + async def _get_prepared_sources( + self, + substance_id: int, + scenario_id: int | None = None + ): + + current_time = time.time() + + if not isinstance(pollution_state.cached_sources, dict): + pollution_state.cached_sources = {} + pollution_state.sources_time = {} + + key = self._cache_key(substance_id, scenario_id) + + if (key in pollution_state.cached_sources and + (current_time - pollution_state.sources_time.get(key, 0) < 2)): + return pollution_state.cached_sources[key] + + all_sources = await self.source_service.get_sources_by_substance_internal(substance_id) + + sources_db = ( + [s for s in all_sources if s.scenario_id == scenario_id] + if scenario_id is not None + else list(all_sources) + ) + + substance = await self.substance_service.get_substance_by_id(substance_id) + mcl = substance.mcl if substance else 0.008 + + if not sources_db: + empty = tuple(np.array([], dtype=np.float32) for _ in range(7)) + pollution_state.cached_sources[key] = (empty, mcl) + pollution_state.sources_time[key] = current_time + return pollution_state.cached_sources[key] + + prepared_data = discretize_sources(sources_db) + + pollution_state.cached_sources[key] = (prepared_data, mcl) + pollution_state.sources_time[key] = current_time + return pollution_state.cached_sources[key] + + async def render_tile( + self, + substance_id: int, + tx: int, + ty: int, + tz: int, + scenario_id: int | None = None, + wind_speed: float = 3.0, + wind_direction: float = 180.0, + temperature: float = 20.0, # °C + pressure: float = 1013.0, # гПа + sun_brightness: float = 20000, + cloud_density: int = 0 + ) -> Image.Image: + + 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 + + u = float(wind_speed) + wind_math_rad = np.radians((270.0 - float(wind_direction)) % 360.0) + + height_factor, sigma_factor, settling_factor = self._compute_weather_factors( + temperature, pressure + ) + + prepared_data, mcl = await self._get_prepared_sources(substance_id, scenario_id) + + src_xs, src_ys, src_rates, src_heights, src_sy0, src_sz0, src_settling = prepared_data + + if len(src_xs) == 0: + return Image.new("RGBA", (256, 256), (0, 0, 0, 0)) + + BUFFER = 200_000 # метры + 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), (0, 0, 0, 0)) + + s_xs = src_xs[mask] + s_ys = src_ys[mask] + s_rates = src_rates[mask] + + s_heights_eff = src_heights[mask].astype(np.float32) * height_factor + s_sy0_eff = src_sy0[mask].astype(np.float32) * sigma_factor + s_sz0_eff = src_sz0[mask].astype(np.float32) * sigma_factor + s_settling_eff = src_settling[mask].astype(np.float32) * settling_factor + + 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) + + conc_flat = calculate_concentration_chunk( + xv.ravel(), yv.ravel(), + s_xs, s_ys, s_rates, s_heights_eff, + s_sy0_eff, s_sz0_eff, s_settling_eff, + u, wind_math_rad, sun_brightness, cloud_density + ) + + grid_conc = conc_flat.reshape((res, res)) + + img_data = colorize_tile_numpy(grid_conc, pdk=mcl, alpha_bg=110) + + return Image.fromarray(img_data, 'RGBA') diff --git a/backend/app/services/scenarios.py b/backend/app/services/scenarios.py new file mode 100644 index 0000000..2603dd9 --- /dev/null +++ b/backend/app/services/scenarios.py @@ -0,0 +1,88 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from fastapi import HTTPException, status +from ..repositories.scenarios import ScenariosRepository +from ..models.users import Users, RolesTypesEnum +from ..models.scenarios import Scenarios +from ..repositories.users import UserRepository +from ..schemas.scenarios import ScenariosCreate + +"""убрать вызов исключений из сервисов, когда будут готовы кастомные исключения""" + + +class ScenarioService: + def __init__(self, db: AsyncSession): + self.repository = ScenariosRepository(db) + self.user_repository = UserRepository(db) + + async def __check_access(self, scenario: Scenarios, current_user) -> None: + if current_user.role == RolesTypesEnum.admin: + return None + + if scenario.user_id == current_user.id: + return None + + if current_user.role == RolesTypesEnum.professor: + students = await self.user_repository.get_students_in_groups(current_user.group) + if scenario.user_id in {student.id for student in students}: + return None + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough rights to access this scenario" + ) + + async def get_scenarios(self, current_user: Users) -> list[Scenarios]: + match current_user.role: + case RolesTypesEnum.admin: + return list(await self.repository.get_all_scenarios()) + + case RolesTypesEnum.student: + return list(await self.repository.get_scenario_by_user_id(current_user.id)) + + case RolesTypesEnum.professor: + own = list(await self.repository.get_scenario_by_user_id(current_user.id)) + + students = await self.user_repository.get_students_in_groups(current_user.group) + student_ids = [student.id for student in students] + + students_scenarios = ( + list(await self.repository.get_scenario_by_user_ids(student_ids)) + if student_ids else [] + ) + return own + students_scenarios + + case _: + + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Unknown role" + ) + + async def get_scenario_by_id(self, scenario_id: int, current_user: Users): + scenario = await self.repository.get_scenario_by_id(scenario_id) + if not scenario: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Scenario not found" + ) + + await self.__check_access(scenario, current_user) + return scenario + + async def create_scenario(self, scenario_schema: ScenariosCreate, current_user: Users): + if current_user.role == RolesTypesEnum.student and scenario_schema.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Can not create scenarios on other user behalf" + ) + result = await self.repository.create(scenario_schema) + return result + + async def delete_scenario(self, scenario_id: int, current_user: Users): + scenario = await self.repository.get_scenario_by_id(scenario_id) + if not scenario: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Scenario not found" + ) + await self.__check_access(scenario, current_user) + return await self.repository.delete(scenario_id) 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..e9ba74d --- /dev/null +++ b/backend/app/services/source_service.py @@ -0,0 +1,62 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from fastapi import HTTPException, status + +from ..repositories.sources import SourcesRepository +from ..schemas.sources import SourcesCreate +from ..models.users import Users +from .scenarios import ScenariosRepository + +""" + Удалить HTTPException когда кастомные искл. +""" + + +class SourceService: + def __init__(self, db: AsyncSession): + self.repository = SourcesRepository(db) + self.scenario_repository = ScenariosRepository(db) + + async def __check_scenario_ownership( + self, scenario_id: int, current_user: Users + ): + scenario = await self.scenario_repository.get_scenario_by_id(scenario_id) + if not scenario: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, + detail="Scenario not found") + + if scenario.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough rights to access this scenario" + ) + + async def add_source(self, source_schema: SourcesCreate, current_user: Users): + await self.__check_scenario_ownership(source_schema.scenario_id, current_user) + result = await self.repository.add_source(source_schema) + return result + + async def delete_source(self, source_id: int, current_user: Users): + source = await self.repository.get_source_by_id(source_id) + if not source: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Source not found" + ) + + await self.__check_scenario_ownership(source.scenario_id, current_user) + + result = await self.repository.del_source(source_id) + return result + + async def get_all_sources(self, current_user: Users): + result = await self.repository.get_all_sources() + return [source for source in result if source.scenario_id in + {scenario.id for scenario in await self.scenario_repository.get_scenario_by_user_id(current_user.id)}] + + async def get_sources_by_substance(self, substance_id: int, current_user: Users): + result = await self.repository.get_sources_by_substance(substance_id) + return [source for source in result if source.scenario_id in + {scenario.id for scenario in await self.scenario_repository.get_scenario_by_user_id(current_user.id)}] + + async def get_sources_by_substance_internal(self, substance_id: int): + return await self.repository.get_sources_by_substance_internal(substance_id) diff --git a/backend/app/services/substance_service.py b/backend/app/services/substance_service.py new file mode 100644 index 0000000..8cd3885 --- /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/backend/app/services/user_service.py b/backend/app/services/user_service.py new file mode 100644 index 0000000..dff9cc8 --- /dev/null +++ b/backend/app/services/user_service.py @@ -0,0 +1,33 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from ..repositories.users import UserRepository +from ..schemas.users import UserCreate +from ..core.utils import hashing + + +class UserService: + def __init__(self, db: AsyncSession): + self.repository = UserRepository(db) + + async def get_users(self): + result = await self.repository.get_users() + return result + + async def get_user_by_id(self, id: int): + result = await self.repository.get_by_id(id) + return result + + async def get_user_by_email(self, email: str): + result = await self.repository.get_by_email(email) + return result + + async def create_user(self, user_schema: UserCreate): + hashed_password = hashing(user_schema.password) + user_data = user_schema.model_dump() + user_data["password"] = hashed_password + modified_schema = UserCreate(**user_data) + result = await self.repository.create_user(modified_schema) + return result + + async def delete_user(self, id: int): + result = await self.repository.delete_user(id) + 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/pytest.ini b/pytest.ini new file mode 100644 index 0000000..2d371ae --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +asyncio_mode = auto +asyncio_default_fixture_loop_scope = function \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4e09372 Binary files /dev/null and b/requirements.txt differ diff --git a/run.txt b/run.txt new file mode 100644 index 0000000..2a4f849 --- /dev/null +++ b/run.txt @@ -0,0 +1,3 @@ +.venv\Scripts\Activate.ps1 +alembic upgrade head +uvicorn backend.app.app:app --host 127.0.0.1 --port 8001 \ No newline at end of file diff --git a/static/css/base.css b/static/css/base.css new file mode 100644 index 0000000..e3ce5cd --- /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: 1000px) { + .container { + grid-template-columns: 1fr; + padding: 16px; + } + + #map { + height: 520px; + } +} \ No newline at end of file diff --git a/static/css/kudadym.css b/static/css/kudadym.css new file mode 100644 index 0000000..dfff065 --- /dev/null +++ b/static/css/kudadym.css @@ -0,0 +1,1185 @@ +/* ============================================================= + kudadym.css — стили приложения КудаДым + ============================================================= */ + +/* ── Сброс и базовые стили ───────────────────────────────────*/ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: 'Segoe UI', Arial, sans-serif; + font-size: 14px; + background: #f1f5f9; + color: #1e293b; + height: 100vh; + overflow: hidden; + display: flex; + flex-direction: column; +} + +/* ── Шапка ───────────────────────────────────────────────────*/ +.kuda-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px; + height: 56px; + background: #0f172a; + color: #fff; + flex-shrink: 0; + z-index: 100; + box-shadow: 0 2px 8px rgba(0,0,0,0.25); +} + +.logo { + display: flex; + align-items: center; + gap: 10px; +} + +.logo h1 { + font-size: 20px; + font-weight: 700; + color: #22c55e; + letter-spacing: 0.5px; +} + +/* ── Панель веществ ──────────────────────────────────────────*/ +.pollutants-bar { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.pollutants-bar span { + padding: 5px 12px; + border-radius: 20px; + background: #1e293b; + color: #94a3b8; + cursor: pointer; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.5px; + transition: background 0.2s, color 0.2s, transform 0.1s; + border: 1px solid #334155; + user-select: none; +} + +.pollutants-bar span:hover { + background: #334155; + color: #e2e8f0; + transform: translateY(-1px); +} + +.pollutants-bar span.active { + background: #22c55e; + color: #fff; + border-color: #22c55e; + box-shadow: 0 2px 8px rgba(34,197,94,0.35); +} + +/* ── Основной макет ──────────────────────────────────────────*/ +.kuda-main { + display: flex; + flex: 1; + overflow: hidden; +} + +/* ── Левая панель ────────────────────────────────────────────*/ +.left-panel { + width: 280px; + min-width: 260px; + background: #fff; + border-right: 1px solid #e2e8f0; + display: flex; + flex-direction: column; + gap: 0; + overflow-y: auto; + overflow-x: hidden; + z-index: 10; + flex-shrink: 0; +} + +.left-panel::-webkit-scrollbar { width: 4px; } +.left-panel::-webkit-scrollbar-track { background: #f8fafc; } +.left-panel::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; } + +/* ── Поиск ───────────────────────────────────────────────────*/ +.search-bar { + display: flex; + gap: 6px; + padding: 12px; + border-bottom: 1px solid #f1f5f9; +} + +.search-bar input { + flex: 1; + padding: 8px 12px; + border: 1px solid #e2e8f0; + border-radius: 8px; + font-size: 13px; + outline: none; + background: #f8fafc; + color: #1e293b; + transition: border-color 0.2s; +} + +.search-bar input:focus { + border-color: #22c55e; + background: #fff; +} + +.search-bar button { + padding: 8px 12px; + background: #22c55e; + color: #fff; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 14px; + transition: background 0.2s; +} + +.search-bar button:hover { background: #16a34a; } + +/* ── Тоггл режима симулятора ─────────────────────────────────*/ +.simulator-mode-block { + display: flex; + flex-direction: column; + align-items: center; + padding: 16px 12px; + gap: 8px; + border-bottom: 1px solid #f1f5f9; + background: #f8fafc; +} + +.mode-title { + font-size: 11px; + font-weight: 600; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.8px; +} + +.mode-subtitle { + font-size: 11px; + font-weight: 600; + color: #22c55e; + text-transform: uppercase; + letter-spacing: 0.8px; +} + +/* Большой переключатель */ +.big-green-toggle { + position: relative; + display: inline-block; + width: 64px; + height: 32px; + cursor: pointer; +} + +.big-green-toggle input { + opacity: 0; + width: 0; + height: 0; +} + +.big-slider { + position: absolute; + inset: 0; + background: #cbd5e1; + border-radius: 32px; + transition: background 0.3s; +} + +.big-slider::before { + content: ''; + position: absolute; + width: 24px; + height: 24px; + left: 4px; + top: 4px; + background: #fff; + border-radius: 50%; + transition: transform 0.3s; + box-shadow: 0 1px 4px rgba(0,0,0,0.2); +} + +.big-green-toggle input:checked + .big-slider { + background: #22c55e; +} + +.big-green-toggle input:checked + .big-slider::before { + transform: translateX(32px); +} + +.simulator-active { + background: #f0fdf4; +} + +/* ── Блок авторизации ────────────────────────────────────────*/ +.login-block { + padding: 12px; + border-bottom: 1px solid #f1f5f9; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 10px; +} + +.auth-title { + font-size: 14px; + font-weight: 700; + color: #1e293b; + margin-bottom: 4px; +} + +.auth-error { + background: #fee2e2; + color: #dc2626; + border: 1px solid #fca5a5; + border-radius: 6px; + padding: 7px 10px; + font-size: 12px; + line-height: 1.4; +} + +.required { color: #e74c3c; font-size: 11px; } +.optional { color: #94a3b8; font-size: 11px; } + +.btn-auth-primary { + width: 100%; + padding: 10px; + background: #22c55e; + color: #fff; + border: none; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s, transform 0.1s; +} + +.btn-auth-primary:hover { background: #16a34a; transform: translateY(-1px); } +.btn-auth-primary:disabled { background: #86efac; cursor: not-allowed; transform: none; } + +.btn-auth-link { + background: none; + border: none; + color: #22c55e; + font-size: 12px; + cursor: pointer; + text-align: center; + padding: 4px; + text-decoration: underline; + transition: color 0.2s; +} + +.btn-auth-link:hover { color: #16a34a; } + +/* Группы */ +.group-row { + display: flex; + gap: 6px; + align-items: center; + margin-bottom: 6px; +} + +.group-row .group-input { + flex: 1; + padding: 7px 10px; + border: 1px solid #e2e8f0; + border-radius: 6px; + font-size: 13px; + outline: none; + background: #f8fafc; + transition: border-color 0.2s; +} + +.group-row .group-input:focus { border-color: #22c55e; background: #fff; } + +.btn-remove-group { + background: #fee2e2; + color: #dc2626; + border: none; + border-radius: 6px; + width: 26px; + height: 26px; + cursor: pointer; + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background 0.15s; +} + +.btn-remove-group:hover { background: #dc2626; color: #fff; } + +.btn-add-group { + background: none; + border: 1px dashed #cbd5e1; + color: #64748b; + border-radius: 6px; + padding: 6px 10px; + font-size: 12px; + cursor: pointer; + width: 100%; + transition: border-color 0.2s, color 0.2s; + margin-top: 2px; +} + +.btn-add-group:hover { border-color: #22c55e; color: #22c55e; } + +/* ── Сценарии (левая панель) ─────────────────────────────────*/ +.scenarios-block { + padding: 12px; + border-bottom: 1px solid #f1f5f9; +} + +.scenarios-block h3 { + font-size: 13px; + font-weight: 700; + color: #475569; + margin-bottom: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.scenario-buttons { + display: flex; + gap: 6px; + margin-bottom: 8px; +} + +.scenario-btn { + flex: 1; + padding: 7px 4px; + background: #f1f5f9; + border: 1px solid #e2e8f0; + border-radius: 7px; + font-size: 11px; + font-weight: 600; + color: #475569; + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.scenario-btn:hover { + background: #22c55e; + color: #fff; + border-color: #22c55e; +} + +/* ── Выпадающий список сценариев ─────────────────────────────*/ +.scenarios-dropdown { + margin-top: 8px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + overflow: hidden; + box-shadow: 0 4px 16px rgba(0,0,0,0.10); +} + +.scenarios-dropdown-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; + font-size: 13px; + font-weight: 600; + color: #475569; +} + +.btn-close-dropdown { + background: none; + border: none; + cursor: pointer; + color: #94a3b8; + font-size: 14px; + padding: 2px 6px; + border-radius: 4px; + transition: background 0.15s, color 0.15s; +} + +.btn-close-dropdown:hover { + background: #fee2e2; + color: #e74c3c; +} + +/* Прокручиваемый список */ +.scenarios-list-inner { + max-height: 320px; + overflow-y: auto; + padding: 6px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.scenarios-list-inner::-webkit-scrollbar { width: 5px; } +.scenarios-list-inner::-webkit-scrollbar-track { + background: #f1f5f9; + border-radius: 10px; +} +.scenarios-list-inner::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 10px; +} +.scenarios-list-inner::-webkit-scrollbar-thumb:hover { + background: #94a3b8; +} + +/* Карточка сценария */ +.scenario-list-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 10px 12px; + transition: border-color 0.15s, box-shadow 0.15s, background 0.15s; +} + +.scenario-list-item:hover { + border-color: #22c55e; + box-shadow: 0 2px 8px rgba(34,197,94,0.10); + background: #f0fdf4; +} + +/* Левая кликабельная часть карточки */ +.scenario-item-body { + flex: 1; + min-width: 0; + cursor: pointer; +} + +.scenario-item-name { + font-size: 13px; + font-weight: 600; + color: #1e293b; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 4px; +} + +.scenario-item-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + font-size: 11px; + color: #64748b; +} + +.sc-owner, .sc-date { + display: flex; + align-items: center; + gap: 3px; +} + +/* Кнопки действий в карточке */ +.scenario-item-actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.sc-open-btn, +.sc-delete-btn { + border: none; + border-radius: 6px; + width: 30px; + height: 30px; + cursor: pointer; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, transform 0.1s; +} + +.sc-open-btn { + background: #dcfce7; + color: #16a34a; +} + +.sc-open-btn:hover { + background: #22c55e; + color: #fff; + transform: scale(1.08); +} + +.sc-delete-btn { + background: #fee2e2; + color: #dc2626; +} + +.sc-delete-btn:hover { + background: #dc2626; + color: #fff; + transform: scale(1.08); +} + +/* ── Список источников в левой панели ────────────────────────*/ +.sources-list { + flex: 1; + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.sources-list::-webkit-scrollbar { width: 4px; } +.sources-list::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; } + +.source-item { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 8px; + padding: 10px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + transition: border-color 0.15s; +} + +.source-item:hover { border-color: #22c55e; } + +.source-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} + +.source-info strong { + font-size: 13px; + color: #1e293b; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.source-info small { color: #64748b; font-size: 11px; } + +.source-actions { + display: flex; + flex-direction: column; + gap: 4px; + flex-shrink: 0; +} + +.small-btn { + width: 28px; + height: 28px; + border: none; + border-radius: 6px; + background: #f1f5f9; + cursor: pointer; + font-size: 13px; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s; +} + +.small-btn:hover { background: #e2e8f0; } +.small-btn.danger { background: #fee2e2; color: #dc2626; } +.small-btn.danger:hover { background: #dc2626; color: #fff; } + +/* ── Секция карты ────────────────────────────────────────────*/ +.map-section { + flex: 1; + position: relative; + overflow: hidden; +} + +.map { + width: 100%; + height: 100%; +} + +/* ── Правый оверлей сценария ─────────────────────────────────*/ +.scenario-overlay { + position: absolute; + top: 0; + right: 0; + width: 340px; + height: 100%; + background: #fff; + border-left: 1px solid #e2e8f0; + display: flex; + flex-direction: column; + z-index: 50; + box-shadow: -4px 0 16px rgba(0,0,0,0.08); + overflow: hidden; +} + +.scenario-header { + padding: 14px 16px; + background: #0f172a; + color: #fff; + flex-shrink: 0; +} + +.scenario-header h3 { + font-size: 13px; + font-weight: 600; + color: #22c55e; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Collapse-секции ─────────────────────────────────────────*/ +.collapse-section { + border-bottom: 1px solid #f1f5f9; + flex-shrink: 0; +} + +.collapse-header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + cursor: pointer; + background: #f8fafc; + user-select: none; + transition: background 0.15s; +} + +.collapse-header:hover { background: #f0fdf4; } + +.collapse-icon { + font-size: 10px; + color: #94a3b8; + transition: transform 0.2s; + display: inline-block; +} + +.collapse-section.expanded .collapse-icon { + transform: rotate(90deg); +} + +.collapse-title { + flex: 1; + font-size: 13px; + font-weight: 600; + color: #374151; +} + +.collapse-badge { + background: #22c55e; + color: #fff; + border-radius: 10px; + padding: 1px 7px; + font-size: 11px; + font-weight: 700; + min-width: 22px; + text-align: center; +} + +.collapse-content { + display: none; + padding: 12px 16px; + overflow-y: auto; + max-height: 400px; +} + +.collapse-content::-webkit-scrollbar { width: 4px; } +.collapse-content::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; } + +.collapse-section.expanded .collapse-content { + display: block; +} + +/* ── Форма источника ─────────────────────────────────────────*/ +.source-form-container { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 14px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 5px; +} + +.form-group label { + font-size: 11px; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.4px; +} + +.form-group input[type="text"], +.form-group input[type="number"], +.form-group input[type="email"], +.form-group input[type="password"], +.form-group select { + padding: 8px 10px; + border: 1px solid #e2e8f0; + border-radius: 7px; + font-size: 13px; + color: #1e293b; + background: #f8fafc; + outline: none; + transition: border-color 0.2s, background 0.2s; + width: 100%; +} + +.form-group input:focus, +.form-group select:focus { + border-color: #22c55e; + background: #fff; +} + +.form-row-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +/* Тип источника */ +.source-type-buttons { + display: flex; + gap: 4px; +} + +.mini-btn { + flex: 1; + padding: 6px 4px; + border: 1px solid #e2e8f0; + border-radius: 6px; + background: #f8fafc; + color: #475569; + font-size: 11px; + cursor: pointer; + transition: all 0.15s; + text-align: center; +} + +.mini-btn:hover { border-color: #22c55e; color: #22c55e; } +.mini-btn.active { background: #22c55e; color: #fff; border-color: #22c55e; } +.source-type-btn.active { background: #22c55e; color: #fff; border-color: #22c55e; } + +/* Статус рисования */ +.drawing-status { + background: #fef3c7; + border: 1px solid #fcd34d; + border-radius: 7px; + padding: 8px 10px; + font-size: 12px; + color: #92400e; +} + +/* Кнопки формы */ +.form-buttons { + display: flex; + gap: 6px; +} + +.btn-draw { + flex: 1; + padding: 9px; + background: #22c55e; + color: #fff; + border: none; + border-radius: 7px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; +} + +.btn-draw:hover { background: #16a34a; } + +.btn-cancel { + flex: 1; + padding: 9px; + background: #fee2e2; + color: #dc2626; + border: 1px solid #fca5a5; + border-radius: 7px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; +} + +.btn-cancel:hover { background: #dc2626; color: #fff; } + +/* ── Список источников в оверлее ─────────────────────────────*/ +.sources-list-overlay { + border-top: 1px solid #f1f5f9; + padding-top: 10px; +} + +.sources-header { + font-size: 11px; + font-weight: 700; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; +} + +.overlay-source-item { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 6px; + padding: 8px 10px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 7px; + margin-bottom: 6px; + transition: border-color 0.15s; +} + +.overlay-source-item:hover { border-color: #22c55e; } + +.overlay-source-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.overlay-source-info strong { + font-size: 12px; + color: #1e293b; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.overlay-source-info small { font-size: 11px; color: #64748b; } + +.overlay-source-actions { flex-shrink: 0; } + +.overlay-delete-btn { + width: 26px; + height: 26px; + border: none; + border-radius: 6px; + background: #fee2e2; + color: #dc2626; + cursor: pointer; + font-size: 13px; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s; +} + +.overlay-delete-btn:hover { background: #dc2626; color: #fff; } + +/* ── Погода ──────────────────────────────────────────────────*/ +.weather-controls, +.model-controls { + display: flex; + flex-direction: column; + gap: 10px; +} + +.form-group input[type="range"] { + width: 100%; + accent-color: #22c55e; + cursor: pointer; +} + +.compass { + width: 36px; + height: 36px; + border: 2px solid #e2e8f0; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-top: 6px; + background: #f8fafc; +} + +.compass-arrow { + font-size: 18px; + color: #22c55e; + transition: transform 0.3s; + line-height: 1; +} + +.apply-btn { + width: 100%; + padding: 9px; + background: #3b82f6; + color: #fff; + border: none; + border-radius: 7px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; +} + +.apply-btn:hover { background: #2563eb; } + +/* ── Параметры модели ────────────────────────────────────────*/ +.checkbox-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.checkbox-group label { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: #374151; + cursor: pointer; +} + +.checkbox-group input[type="checkbox"] { + accent-color: #22c55e; + width: 15px; + height: 15px; + cursor: pointer; +} + +.run-simulation-btn { + width: 100%; + padding: 10px; + background: linear-gradient(135deg, #22c55e, #16a34a); + color: #fff; + border: none; + border-radius: 8px; + font-size: 13px; + font-weight: 700; + cursor: pointer; + transition: opacity 0.2s, transform 0.1s; + box-shadow: 0 2px 8px rgba(34,197,94,0.30); +} + +.run-simulation-btn:hover { + opacity: 0.9; + transform: translateY(-1px); +} + +/* ── Таймлайн ────────────────────────────────────────────────*/ +.timeline-block { + position: absolute; + bottom: 16px; + left: 50%; + transform: translateX(-50%); + background: rgba(15,23,42,0.90); + border-radius: 12px; + padding: 10px 18px; + z-index: 20; + min-width: 460px; + backdrop-filter: blur(6px); + box-shadow: 0 4px 16px rgba(0,0,0,0.20); +} + +.time-ticks { + display: flex; + justify-content: space-between; + margin-bottom: 4px; +} + +.time-ticks span { + font-size: 10px; + color: #94a3b8; + font-weight: 500; +} + +.timeline-slider input[type="range"] { + width: 100%; + accent-color: #22c55e; + cursor: pointer; + height: 4px; +} + +.current-time { + text-align: center; + font-size: 13px; + font-weight: 700; + color: #22c55e; + margin-top: 4px; +} + +/* ── Адаптив ─────────────────────────────────────────────────*/ +@media (max-width: 900px) { + .left-panel { + width: 240px; + min-width: 220px; + } + + .scenario-overlay { + width: 300px; + } + + .timeline-block { + min-width: 320px; + left: 10px; + right: 10px; + transform: none; + } +} + +@media (max-width: 640px) { + .left-panel { + width: 200px; + min-width: 180px; + } + + .scenario-overlay { + width: 260px; + } + + .kuda-header { + padding: 0 12px; + } + + .logo h1 { + font-size: 16px; + } +} + +/* ── Кнопка "назад к сценариям" ─────────────────────────────*/ +.back-to-scenarios-btn { + display: flex; + align-items: center; + gap: 6px; + width: calc(100% - 24px); + margin: 10px 12px 4px; + padding: 8px 12px; + background: #f1f5f9; + border: 1px solid #e2e8f0; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + color: #475569; + cursor: pointer; + transition: background 0.15s, color 0.15s; + flex-shrink: 0; +} +.back-to-scenarios-btn:hover { + background: #e2e8f0; + color: #1e293b; +} + +/* ── Блок выхода из аккаунта ────────────────────────────────*/ +.logout-block { + padding: 10px 12px; + border-bottom: 1px solid #f1f5f9; + display: flex; + flex-direction: column; + gap: 6px; +} +.user-info-label { + font-size: 12px; + font-weight: 600; + color: #475569; + padding: 4px 0; +} +.btn-logout { + width: 100%; + padding: 8px; + background: #fee2e2; + color: #dc2626; + border: 1px solid #fca5a5; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} +.btn-logout:hover { + background: #dc2626; + color: #fff; +} + + +/* ── Заголовок панели источников ─────────────────────────────*/ +.sources-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + background: #f8fafc; + border-bottom: 1px solid #f1f5f9; + margin-bottom: 8px; + position: sticky; + top: 0; + z-index: 1; + flex-shrink: 0; +} +.sources-panel-title { + font-size: 12px; + font-weight: 700; + color: #475569; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 180px; +} +.sources-panel-count { + background: #22c55e; + color: #fff; + border-radius: 10px; + padding: 1px 8px; + font-size: 11px; + font-weight: 700; + flex-shrink: 0; +} +.source-category-badge { + display: inline-block; + padding: 1px 6px; + border-radius: 4px; + font-size: 10px; + margin-left: 6px; + background: #e2e8f0; + color: #475569; +} + +/* ── Селект роли ─────────────────────────────────────────────*/ +.role-select { + padding: 8px 10px; + border: 1px solid #e2e8f0; + border-radius: 7px; + font-size: 13px; + color: #1e293b; + background: #f8fafc; + outline: none; + width: 100%; + cursor: pointer; + transition: border-color 0.2s, background 0.2s; +} + +.role-select:focus { + border-color: #22c55e; + background: #fff; +} + +/* Визуальное выделение выбранной роли */ +.role-select option[value="professor"] { color: #7c3aed; } +.role-select option[value="student"] { color: #0369a1; } \ No newline at end of file diff --git a/static/fonts/Actay-Regular.woff2 b/static/fonts/Actay-Regular.woff2 new file mode 100644 index 0000000..22c3696 Binary files /dev/null and b/static/fonts/Actay-Regular.woff2 differ diff --git a/static/fonts/Actay-RegularItalic.woff2 b/static/fonts/Actay-RegularItalic.woff2 new file mode 100644 index 0000000..1882a34 Binary files /dev/null and b/static/fonts/Actay-RegularItalic.woff2 differ diff --git a/static/fonts/ActayCondensed-Thin.woff2 b/static/fonts/ActayCondensed-Thin.woff2 new file mode 100644 index 0000000..d72d4df Binary files /dev/null and b/static/fonts/ActayCondensed-Thin.woff2 differ diff --git a/static/fonts/ActayCondensed-ThinItalic.woff2 b/static/fonts/ActayCondensed-ThinItalic.woff2 new file mode 100644 index 0000000..b8d71f2 Binary files /dev/null and b/static/fonts/ActayCondensed-ThinItalic.woff2 differ diff --git a/static/fonts/ActayWide-Bold.woff2 b/static/fonts/ActayWide-Bold.woff2 new file mode 100644 index 0000000..c540b43 Binary files /dev/null and b/static/fonts/ActayWide-Bold.woff2 differ diff --git a/static/fonts/ActayWide-BoldItalic.woff2 b/static/fonts/ActayWide-BoldItalic.woff2 new file mode 100644 index 0000000..05ea067 Binary files /dev/null and b/static/fonts/ActayWide-BoldItalic.woff2 differ diff --git a/static/img/logo.png b/static/img/logo.png new file mode 100644 index 0000000..2f3cf60 Binary files /dev/null and b/static/img/logo.png differ 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/main-kudadym.js b/static/js/main-kudadym.js new file mode 100644 index 0000000..6da916f --- /dev/null +++ b/static/js/main-kudadym.js @@ -0,0 +1,1282 @@ +/* ============================================================= + main-kudadym.js + ============================================================= */ + +var monitoringMap = null; +var isDrawingMode = false; +var currentDrawingType = null; +var drawingPoints = []; +var tempLineObject = null; +var tempPolygonObject = null; +var isSimulatorMode = false; +var isLoggedIn = false; + +var appState = { + currentSubstanceId: null, + currentSubstanceName: null, + currentScenarioId: null, + currentScenarioName: null, + substances: [], + sources: [], + currentTimeIndex: 5 +}; + +var timeLabels = [ + '14:00','14:30','15:00','15:30', + '16:00','16:30','17:00','17:30', + '18:00','18:30' +]; + +// ── Токен ──────────────────────────────────────────────────── +function getToken() { return localStorage.getItem('access_token'); } +function setToken(t) { localStorage.setItem('access_token', t); } +function clearToken() { localStorage.removeItem('access_token'); } +function isAuthenticated() { return !!getToken(); } + +function getUserIdFromToken() { + var token = getToken(); + if (!token) return null; + try { + var parts = token.split('.'); + if (parts.length < 2) return null; + return JSON.parse(atob(parts[1])).user_id || null; + } catch(e) { return null; } +} + +function getUserNameFromToken() { + var token = getToken(); + if (!token) return null; + try { + var payload = JSON.parse(atob(token.split('.')[1])); + return payload.first_name || payload.email || 'Пользователь'; + } catch(e) { return null; } +} + +// ── Точка входа ────────────────────────────────────────────── +document.addEventListener('DOMContentLoaded', function() { + console.log('=== DOMContentLoaded ==='); + loadSubstancesFromDB(); + initYandexMap(); + initSimulatorLoginToggle(); + initAuthForms(); + initEventListeners(); + initOverlaySourceHandlers(); + initWeatherHandlers(); + initModelHandlers(); + initCollapseHandlers(); + console.log('=== Инициализация завершена ==='); +}); + +function el(id) { + var e = document.getElementById(id); + if (!e) console.warn('Не найден: #' + id); + return e; +} + +// ── API ────────────────────────────────────────────────────── +function apiCall(url, options) { + options = options || {}; + var headers = { 'Content-Type': 'application/json' }; + var token = getToken(); + if (token) headers['Authorization'] = 'Bearer ' + token; + if (options.headers) { + Object.keys(options.headers).forEach(function(k) { + headers[k] = options.headers[k]; + }); + } + var fetchOptions = {}; + Object.keys(options).forEach(function(k) { fetchOptions[k] = options[k]; }); + fetchOptions.headers = headers; + + return fetch(url, fetchOptions).then(function(response) { + if (response.status === 401) { + clearToken(); + isLoggedIn = false; + console.warn('401 на ' + url); + if (isSimulatorMode) { + var sb = el('scenarios-block'); + var lb = el('login-block'); + if (sb) sb.style.display = 'none'; + if (lb) lb.style.display = 'block'; + } + return null; + } + if (!response.ok) { + return response.text().then(function(txt) { + console.error('API ' + response.status + ' на ' + url + ':', txt); + return null; + }); + } + return response.text().then(function(txt) { + if (!txt) return { success: true }; + try { return JSON.parse(txt); } + catch(e) { return null; } + }); + }).catch(function(err) { + console.error('fetch error ' + url + ':', err); + return null; + }); +} + +function apiLogin(email, password) { + var body = new URLSearchParams({ username: email, password: password }); + return fetch('/login', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString() + }).then(function(r) { + if (!r.ok) { console.error('Логин статус:', r.status); return null; } + return r.json(); + }).catch(function(e) { console.error('apiLogin:', e); return null; }); +} + +function apiRegister(userData) { + return fetch('/users/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(userData) + }).then(function(r) { + if (!r.ok) { + return r.text().then(function(t) { + console.error('Регистрация:', t); return null; + }); + } + return r.json(); + }).catch(function(e) { console.error('apiRegister:', e); return null; }); +} + +// ── Погода → бэкенд ────────────────────────────────────────── +function applyWeatherToBackend() { + var windSpeed = parseFloat((el('wind-speed') || {value: 3}).value); + var windDir = parseFloat((el('wind-direction') || {value: 180}).value); + var temp = parseFloat((el('temperature') || {value: 20}).value); + var pressure = parseFloat((el('pressure') || {value: 1013}).value); + var sunBright = parseFloat((el('sun-brightness') || {value: 20000}).value); // добавлено + var cloudDens = parseInt( (el('cloud-density') || {value: 0}).value, 10); // добавлено + + var params = { + wind_speed: windSpeed, + wind_direction: windDir, + temperature: temp, + pressure: pressure, + sun_brightness: sunBright, // добавлено + cloud_density: cloudDens // добавлено + }; + + console.log('Применяю погоду к тайлам:', params); + + if (monitoringMap && window.MapGraphics && appState.currentScenarioId) { + MapGraphics.refreshPollutionLayer( + monitoringMap, + appState.currentSubstanceId, + appState.currentScenarioId, + params + ); + + if (MapGraphics.drawWindVectors && appState.sources.length > 0) { + MapGraphics.drawWindVectors(monitoringMap, appState.sources, windDir); + } + } +} + +// ── Яндекс.Карта ───────────────────────────────────────────── +function initYandexMap() { + if (typeof ymaps === 'undefined') { + setTimeout(initYandexMap, 300); + return; + } + ymaps.ready(function() { + if (!document.getElementById('map')) return; + monitoringMap = new ymaps.Map('map', { + center: [55.978746, 37.204738], + zoom: 13, + controls: ['zoomControl'] + }); + monitoringMap.events.add('click', onMapClick); + monitoringMap.events.add('dblclick', onMapDoubleClick); + console.log('Карта готова'); + }); +} + +// ── Рисование ──────────────────────────────────────────────── +function onMapClick(e) { + if (!isDrawingMode) return; + var coords = e.get('coords'); + drawingPoints.push(coords); + if (currentDrawingType === 'point') finishDrawing(); + else if (currentDrawingType === 'line') updateTempLine(); + else if (currentDrawingType === 'polygon') updateTempPolygon(); +} + +function onMapDoubleClick(e) { + if (!isDrawingMode) return; + if (currentDrawingType === 'line' || currentDrawingType === 'polygon') { + e.preventDefault(); + finishDrawing(); + } +} + +function updateTempLine() { + if (tempLineObject) monitoringMap.geoObjects.remove(tempLineObject); + if (drawingPoints.length >= 2) { + tempLineObject = new ymaps.Polyline(drawingPoints, {}, { + strokeColor: '#00c853', strokeWidth: 4, strokeOpacity: 0.8 + }); + monitoringMap.geoObjects.add(tempLineObject); + } +} + +function updateTempPolygon() { + if (tempPolygonObject) monitoringMap.geoObjects.remove(tempPolygonObject); + if (drawingPoints.length >= 3) { + var closed = drawingPoints.concat([drawingPoints[0]]); + tempPolygonObject = new ymaps.Polygon([closed], {}, { + fillColor: '#00c853', fillOpacity: 0.3, + strokeColor: '#00c853', strokeWidth: 3 + }); + monitoringMap.geoObjects.add(tempPolygonObject); + } +} + +function clearTempDrawings() { + if (!monitoringMap) return; + if (tempLineObject) { + monitoringMap.geoObjects.remove(tempLineObject); + tempLineObject = null; + } + if (tempPolygonObject) { + monitoringMap.geoObjects.remove(tempPolygonObject); + tempPolygonObject = null; + } +} + +function resetDrawingMode() { + isDrawingMode = false; + currentDrawingType = null; + drawingPoints = []; + clearTempDrawings(); + + var startBtn = el('start-drawing-overlay-btn'); + var cancelBtn = el('cancel-drawing-overlay-btn'); + var statusDiv = el('drawing-status-overlay'); + if (startBtn) startBtn.style.display = 'block'; + if (cancelBtn) cancelBtn.style.display = 'none'; + if (statusDiv) statusDiv.style.display = 'none'; + if (monitoringMap) monitoringMap.cursors.push('arrow'); +} + +function finishDrawing() { + if (drawingPoints.length === 0) { resetDrawingMode(); return; } + + if (!appState.currentScenarioId) { + alert('Сначала откройте или создайте сценарий'); + resetDrawingMode(); + return; + } + + var nameEl = el('overlay-source-name'); + var name = nameEl ? nameEl.value.trim() : ''; + if (!name) { alert('Введите название источника'); return; } + + var height = parseFloat((el('overlay-source-height') || {value:0}).value); + var emissionRate = parseFloat((el('overlay-source-emission') || {value:0}).value); + var category = (el('overlay-source-category') || {value:'industrial'}).value; + var substanceId = parseInt((el('overlay-source-substance') || {value:0}).value); + + if (!height || height <= 0) { alert('Высота должна быть положительным числом'); return; } + if (!emissionRate || emissionRate <= 0) { alert('Интенсивность должна быть положительным числом'); return; } + + var sourceData = { + name: name, + type: currentDrawingType, + height: height, + emission_rate: emissionRate, + substance_id: substanceId, + scenario_id: appState.currentScenarioId, + category: category, + latitude: drawingPoints[0][0], + longitude: drawingPoints[0][1], + coordinates: currentDrawingType !== 'point' ? drawingPoints : null + }; + + apiCall('/api/sources/', { + method: 'POST', + body: JSON.stringify(sourceData) + }).then(function(result) { + if (result) { + if (nameEl) nameEl.value = ''; + resetDrawingMode(); + loadSourcesForCurrentScenario(); + if (monitoringMap && window.MapGraphics && appState.currentScenarioId) { + MapGraphics.refreshPollutionLayer( + monitoringMap, + appState.currentSubstanceId, + appState.currentScenarioId + ); + } + } else { + alert('Ошибка при добавлении источника'); + } + }); +} + +// ── Поиск ──────────────────────────────────────────────────── +function searchAddress(query) { + if (!monitoringMap || typeof ymaps === 'undefined') return; + ymaps.geocode(query, { results: 1 }).then(function(res) { + if (res.geoObjects.getLength() > 0) { + var first = res.geoObjects.get(0); + var coords = first.geometry.getCoordinates(); + monitoringMap.setCenter(coords, 16); + if (window._searchPm) monitoringMap.geoObjects.remove(window._searchPm); + window._searchPm = new ymaps.Placemark(coords, + { balloonContent: first.getAddressLine() }); + monitoringMap.geoObjects.add(window._searchPm); + window._searchPm.balloon.open(); + } else { + alert('Ничего не найдено: ' + query); + } + }); +} + +// ── Вещества ───────────────────────────────────────────────── +function loadSubstancesFromDB() { + var bar = el('pollutants-bar'); + fetch('/api/substances').then(function(r) { + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.json(); + }).then(function(substances) { + if (!substances || substances.length === 0) { + if (bar) bar.innerHTML = 'Нет данных'; + return; + } + appState.substances = substances; + var def = null; + for (var i = 0; i < substances.length; i++) { + if (substances[i].short_name === 'CO') { def = substances[i]; break; } + } + if (!def) def = substances[0]; + appState.currentSubstanceId = def.id; + appState.currentSubstanceName = def.short_name; + renderPollutantsBar(substances, def.id); + fillSubstanceSelect(substances, def.id); + }).catch(function(e) { + console.error('Вещества:', e); + if (bar) bar.innerHTML = 'Ошибка'; + }); +} + +function renderPollutantsBar(substances, activeId) { + var bar = el('pollutants-bar'); + if (!bar) return; + bar.innerHTML = ''; + substances.forEach(function(s) { + var span = document.createElement('span'); + span.textContent = s.short_name; + span.title = s.name; + if (s.id === activeId) span.classList.add('active'); + span.addEventListener('click', function() { + bar.querySelectorAll('span').forEach(function(x) { x.classList.remove('active'); }); + span.classList.add('active'); + appState.currentSubstanceId = s.id; + appState.currentSubstanceName = s.short_name; + var sel = el('overlay-source-substance'); + if (sel) sel.value = s.id; + if (appState.currentScenarioId && window.MapGraphics) { + MapGraphics.refreshPollutionLayer(monitoringMap, s.id, appState.currentScenarioId); + } + if (appState.currentScenarioId) loadSourcesForCurrentScenario(); + }); + bar.appendChild(span); + }); +} + +function fillSubstanceSelect(substances, selectedId) { + var sel = el('overlay-source-substance'); + if (!sel) return; + sel.innerHTML = ''; + substances.forEach(function(s) { + var opt = document.createElement('option'); + opt.value = s.id; + opt.text = s.short_name + ' — ' + s.name; + opt.selected = (s.id === selectedId); + sel.appendChild(opt); + }); +} + +// ── Тоггл режима симулятора ─────────────────────────────────── +function initSimulatorLoginToggle() { + var toggle = el('simulator-mode'); + var modeBlock = el('mode-block'); + var loginBlock = el('login-block'); + var scenBlock = el('scenarios-block'); + + if (!toggle) { console.error('#simulator-mode не найден!'); return; } + + toggle.addEventListener('change', function() { + if (toggle.checked) { + if (modeBlock) modeBlock.classList.add('simulator-active'); + isSimulatorMode = true; + + if (isAuthenticated()) { + isLoggedIn = true; + if (loginBlock) loginBlock.style.display = 'none'; + if (scenBlock) scenBlock.style.display = 'block'; + showLogoutBlock(); + updateUIBasedOnMode(); + } else { + var fl = el('form-login'); + var fr = el('form-register'); + if (fl) fl.style.display = 'block'; + if (fr) fr.style.display = 'none'; + if (loginBlock) loginBlock.style.display = 'block'; + if (scenBlock) scenBlock.style.display = 'none'; + isLoggedIn = false; + } + } else { + if (modeBlock) modeBlock.classList.remove('simulator-active'); + if (loginBlock) loginBlock.style.display = 'none'; + if (scenBlock) scenBlock.style.display = 'none'; + + var overlay = el('scenario-overlay'); + if (overlay) overlay.style.display = 'none'; + + hideLogoutBlock(); + isSimulatorMode = false; + isLoggedIn = false; + appState.currentScenarioId = null; + appState.sources = []; + + if (window.MapGraphics && monitoringMap) { + MapGraphics.refreshPollutionLayer(monitoringMap, appState.currentSubstanceId, null); + if (MapGraphics.clearSources) MapGraphics.clearSources(monitoringMap); + } + + showScenariosView(); + } + }); +} + +// ── Выход / вход UI ────────────────────────────────────────── +function showLogoutBlock() { + var logoutBlock = el('logout-block'); + var userLabel = el('user-info-label'); + if (!logoutBlock) return; + + var name = 'Пользователь'; + try { + var token = getToken(); + if (token) { + var payload = JSON.parse(atob(token.split('.')[1])); + if (payload.first_name) name = payload.first_name; + } + } catch(e) {} + + if (userLabel) userLabel.textContent = '👤 ' + name; + logoutBlock.style.display = 'block'; +} + +function hideLogoutBlock() { + var logoutBlock = el('logout-block'); + if (logoutBlock) logoutBlock.style.display = 'none'; +} + +// ── Авторизация ─────────────────────────────────────────────── +function initAuthForms() { + var goReg = el('go-register-btn'); + var goLog = el('go-login-btn'); + var addGrp = el('add-group-btn'); + var logBtn = el('login-submit-btn'); + var regBtn = el('register-submit-btn'); + var pwdFld = el('login-password'); + var logOut = el('logout-btn'); + var roleEl = el('reg-role'); + + if (goReg) goReg.addEventListener('click', function() { + var fl = el('form-login'); var fr = el('form-register'); + if (fl) fl.style.display = 'none'; + if (fr) fr.style.display = 'block'; + clearAuthErrors(); + }); + + if (goLog) goLog.addEventListener('click', function() { + var fl = el('form-login'); var fr = el('form-register'); + if (fr) fr.style.display = 'none'; + if (fl) fl.style.display = 'block'; + clearAuthErrors(); + }); + + if (roleEl) { + roleEl.addEventListener('change', function() { + updateGroupsUIForRole(roleEl.value); + }); + updateGroupsUIForRole(roleEl.value); + } + + if (addGrp) { + addGrp.addEventListener('click', function() { + var container = el('groups-container'); + if (!container) return; + var row = document.createElement('div'); + row.className = 'group-row'; + row.innerHTML = + '' + + ''; + row.querySelector('.btn-remove-group').addEventListener('click', function() { + row.remove(); + }); + container.appendChild(row); + }); + } + + if (logBtn) logBtn.addEventListener('click', handleLogin); + if (regBtn) regBtn.addEventListener('click', handleRegister); + if (pwdFld) pwdFld.addEventListener('keydown', function(e) { + if (e.key === 'Enter') handleLogin(); + }); + if (logOut) logOut.addEventListener('click', handleLogout); +} + +function updateGroupsUIForRole(role) { + var addGrpBtn = el('add-group-btn'); + var groupsLabel = el('groups-label'); + var groupsBlock = el('groups-block'); + + if (role === 'professor') { + if (addGrpBtn) addGrpBtn.style.display = 'block'; + if (groupsLabel) groupsLabel.innerHTML = + 'Группы для ведения (необязательно)'; + } else { + if (addGrpBtn) addGrpBtn.style.display = 'none'; + if (groupsLabel) groupsLabel.innerHTML = + 'Группа *'; + + var container = el('groups-container'); + if (container) { + var rows = container.querySelectorAll('.group-row'); + for (var i = 1; i < rows.length; i++) { + rows[i].remove(); + } + } + } +} + +function handleLogout() { + if (!confirm('Выйти из аккаунта?')) return; + + clearToken(); + isLoggedIn = false; + appState.currentScenarioId = null; + appState.sources = []; + + var overlay = el('scenario-overlay'); + var scenBlock = el('scenarios-block'); + var srcList = el('sources-list'); + var backBtn = el('back-to-scenarios-btn'); + var loginBlock = el('login-block'); + var fl = el('form-login'); + var fr = el('form-register'); + + if (overlay) overlay.style.display = 'none'; + if (srcList) srcList.style.display = 'none'; + if (backBtn) backBtn.style.display = 'none'; + if (scenBlock) scenBlock.style.display = 'none'; + + if (fl) fl.style.display = 'block'; + if (fr) fr.style.display = 'none'; + if (loginBlock) loginBlock.style.display = 'block'; + + hideLogoutBlock(); + clearAuthErrors(); + + if (window.MapGraphics && monitoringMap) { + MapGraphics.refreshPollutionLayer(monitoringMap, appState.currentSubstanceId, null); + if (MapGraphics.clearSources) MapGraphics.clearSources(monitoringMap); + } + + console.log('Выход выполнен'); +} + +function handleLogin() { + var emailEl = el('login-email'); + var passEl = el('login-password'); + var errEl = el('login-error'); + var btn = el('login-submit-btn'); + + var email = emailEl ? emailEl.value.trim() : ''; + var password = passEl ? passEl.value : ''; + + if (!email || !password) { showAuthError(errEl, 'Введите email и пароль'); return; } + if (btn) { btn.disabled = true; btn.textContent = 'Вход…'; } + + apiLogin(email, password).then(function(tokenData) { + if (btn) { btn.disabled = false; btn.textContent = 'Войти'; } + if (!tokenData || !tokenData.access_token) { + showAuthError(errEl, 'Неверный email или пароль'); + return; + } + setToken(tokenData.access_token); + isLoggedIn = true; + + var lb = el('login-block'); + var sb = el('scenarios-block'); + if (lb) lb.style.display = 'none'; + if (sb) sb.style.display = 'block'; + + showLogoutBlock(); + clearAuthErrors(); + updateUIBasedOnMode(); + console.log('Вход выполнен!'); + }); +} + +function handleRegister() { + var errEl = el('register-error'); + var btn = el('register-submit-btn'); + + var firstName = (el('reg-firstname') || {value:''}).value.trim(); + var lastName = (el('reg-lastname') || {value:''}).value.trim(); + var patronymic = (el('reg-patronymic') || {value:''}).value.trim(); + var email = (el('reg-email') || {value:''}).value.trim(); + var password = (el('reg-password') || {value:''}).value; + var passConf = (el('reg-password-confirm') || {value:''}).value; + var roleEl = el('reg-role'); + var role = roleEl ? roleEl.value : 'student'; + + var groups = []; + document.querySelectorAll('.group-input').forEach(function(inp) { + var v = inp.value.trim(); + if (v) groups.push(v); + }); + + if (!firstName || !lastName) { + showAuthError(errEl, 'Введите имя и фамилию'); return; + } + if (!email) { + showAuthError(errEl, 'Введите email'); return; + } + if (!password) { + showAuthError(errEl, 'Введите пароль'); return; + } + if (password !== passConf) { + showAuthError(errEl, 'Пароли не совпадают'); return; + } + + if (role === 'student') { + if (groups.length === 0) { + showAuthError(errEl, 'Укажите вашу группу'); return; + } + if (groups.length > 1) { + showAuthError(errEl, 'Студент может быть только в одной группе'); return; + } + } + + if (groups.length === 0) { + if (role === 'professor') { + groups = ['']; + } + } + + if (btn) { btn.disabled = true; btn.textContent = 'Регистрация…'; } + + apiRegister({ + first_name: firstName, + last_name: lastName, + patronymic: patronymic || null, + email: email, + password: password, + role: role, + group: groups + }).then(function(result) { + if (btn) { btn.disabled = false; btn.textContent = 'Зарегистрироваться'; } + + if (!result) { + showAuthError(errEl, 'Ошибка регистрации. Возможно, email уже занят.'); + return; + } + + return apiLogin(email, password).then(function(tokenData) { + if (tokenData && tokenData.access_token) { + setToken(tokenData.access_token); + isLoggedIn = true; + var lb = el('login-block'); + var sb = el('scenarios-block'); + if (lb) lb.style.display = 'none'; + if (sb) sb.style.display = 'block'; + showLogoutBlock(); + clearAuthErrors(); + updateUIBasedOnMode(); + alert('Добро пожаловать, ' + firstName + '!'); + } else { + var fl = el('form-login'); var fr = el('form-register'); + if (fr) fr.style.display = 'none'; + if (fl) fl.style.display = 'block'; + var le = el('login-email'); + if (le) le.value = email; + showAuthError(el('login-error'), 'Аккаунт создан. Войдите в систему.'); + } + }); + }); +} + +function showAuthError(elArg, msg) { + if (!elArg) return; + elArg.textContent = msg; + elArg.style.display = 'block'; +} + +function clearAuthErrors() { + ['login-error','register-error'].forEach(function(id) { + var e = el(id); + if (e) { e.textContent = ''; e.style.display = 'none'; } + }); +} + +// ── Сценарии ────────────────────────────────────────────────── +function onOpenScenarios() { + var dropdown = el('scenarios-dropdown'); + var listEl = el('scenarios-list'); + if (!dropdown || !listEl) return; + + if (dropdown.style.display === 'block') { + dropdown.style.display = 'none'; + return; + } + + listEl.innerHTML = '

Загрузка…

'; + dropdown.style.display = 'block'; + + apiCall('/scenarios/').then(function(scenarios) { + if (!scenarios) { + listEl.innerHTML = '

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

'; + return; + } + if (scenarios.length === 0) { + listEl.innerHTML = '

Нет доступных сценариев

'; + return; + } + + listEl.innerHTML = ''; + scenarios.forEach(function(sc) { + if (!sc.id) { console.warn('Сценарий без id:', sc); return; } + + var item = document.createElement('div'); + item.className = 'scenario-list-item'; + + var dateStr = sc.created_at + ? new Date(sc.created_at).toLocaleDateString('ru-RU', { + day: '2-digit', month: '2-digit', year: 'numeric' + }) + : '—'; + var owner = sc.user + ? (sc.user.last_name + ' ' + sc.user.first_name) + : ''; + + item.innerHTML = + '
' + + '
' + escapeHtml(sc.name) + '
' + + '
' + + (owner ? '👤 ' + escapeHtml(owner) + '' : '') + + '📅 ' + dateStr + '' + + '
' + + '
' + + '
' + + '' + + '' + + '
'; + + (function(scenario, itemEl) { + itemEl.querySelector('.sc-open-btn').addEventListener('click', function(e) { + e.stopPropagation(); + dropdown.style.display = 'none'; + openScenario(scenario); + }); + itemEl.querySelector('.sc-delete-btn').addEventListener('click', function(e) { + e.stopPropagation(); + deleteScenario(scenario.id, scenario.name, itemEl); + }); + itemEl.querySelector('.scenario-item-body').addEventListener('click', function() { + dropdown.style.display = 'none'; + openScenario(scenario); + }); + })(sc, item); + + listEl.appendChild(item); + }); + }); +} + +function deleteScenario(scenarioId, scenarioName, itemEl) { + if (!confirm('Удалить сценарий «' + scenarioName + '»?\nВсе источники будут удалены.')) return; + + apiCall('/scenarios/' + scenarioId, { method: 'DELETE' }).then(function(res) { + if (res !== null) { + if (appState.currentScenarioId === scenarioId) { + appState.currentScenarioId = null; + appState.currentScenarioName = null; + appState.sources = []; + var overlay = el('scenario-overlay'); + if (overlay) overlay.style.display = 'none'; + showScenariosView(); + if (window.MapGraphics && monitoringMap) { + MapGraphics.refreshPollutionLayer(monitoringMap, appState.currentSubstanceId, null); + if (MapGraphics.clearSources) MapGraphics.clearSources(monitoringMap); + } + } + if (itemEl && itemEl.parentNode) itemEl.parentNode.removeChild(itemEl); + var listEl = el('scenarios-list'); + if (listEl && listEl.children.length === 0) { + listEl.innerHTML = '

Нет доступных сценариев

'; + } + } else { + alert('Ошибка при удалении сценария'); + } + }); +} + +function onAddScenario() { + var userId = getUserIdFromToken(); + if (!userId) { alert('Войдите снова'); return; } + + var name = prompt('Название нового сценария:', generateScenarioName()); + if (!name || !name.trim()) return; + + apiCall('/scenarios/', { + method: 'POST', + body: JSON.stringify({ name: name.trim(), user_id: userId }) + }).then(function(result) { + if (!result || !result.id) { alert('Ошибка при создании сценария'); return; } + openScenario(result); + }); +} + +function openScenario(sc) { + console.log('Открываю сценарий:', sc); + if (!sc || !sc.id) { console.error('Невалидный сценарий:', sc); return; } + + appState.currentScenarioId = sc.id; + appState.currentScenarioName = sc.name; + + var nameEl = el('scenario-name'); + if (nameEl) nameEl.textContent = sc.name; + + var overlay = el('scenario-overlay'); + if (overlay) overlay.style.display = 'flex'; + + var firstSection = document.querySelector('#scenario-overlay .collapse-section'); + if (firstSection && !firstSection.classList.contains('expanded')) { + firstSection.classList.add('expanded'); + } + + showSourcesView(); + + if (monitoringMap && window.MapGraphics) { + MapGraphics.refreshPollutionLayer( + monitoringMap, + appState.currentSubstanceId, + appState.currentScenarioId + ); + } + + loadSourcesForCurrentScenario(); +} + +function showScenariosView() { + var scenBlock = el('scenarios-block'); + var srcList = el('sources-list'); + var backBtn = el('back-to-scenarios-btn'); + if (scenBlock) scenBlock.style.display = 'block'; + if (srcList) srcList.style.display = 'none'; + if (backBtn) backBtn.style.display = 'none'; +} + +function showSourcesView() { + var scenBlock = el('scenarios-block'); + var srcList = el('sources-list'); + var backBtn = el('back-to-scenarios-btn'); + if (scenBlock) scenBlock.style.display = 'none'; + if (srcList) srcList.style.display = 'block'; + if (backBtn) backBtn.style.display = 'flex'; +} + +// ── Источники ───────────────────────────────────────────────── +function loadSourcesForCurrentScenario() { + if (!appState.currentScenarioId) return; + + apiCall('/api/sources/').then(function(allSources) { + if (!allSources) return; + + appState.sources = allSources + .filter(function(s) { + return s.scenario_id === appState.currentScenarioId && + s.substance_id === appState.currentSubstanceId; + }) + .map(function(s) { + return { + id: s.id, + name: s.name || ('Источник ' + s.id), + type: s.type, + lat: s.latitude, + lng: s.longitude, + coordinates: s.coordinates, + emissionRate: s.emission_rate, + height: s.height, + category: s.category || 'industrial', + substance_id: s.substance_id, + scenario_id: s.scenario_id + }; + }); + + console.log('Источников:', appState.sources.length); + + if (monitoringMap && window.MapGraphics && MapGraphics.drawSources) { + MapGraphics.drawSources(monitoringMap, appState.sources); + } + + updateSourcesLeftPanel(); + updateOverlaySourcesList(); + }); +} + +window.deleteSource = function(sourceId) { + if (!confirm('Удалить источник?')) return; + apiCall('/api/sources/' + sourceId, { method: 'DELETE' }).then(function(res) { + if (res !== null) { + loadSourcesForCurrentScenario(); + if (window.MapGraphics && appState.currentScenarioId) { + MapGraphics.refreshPollutionLayer( + monitoringMap, + appState.currentSubstanceId, + appState.currentScenarioId + ); + } + } else { + alert('Ошибка при удалении'); + } + }); +}; + +window.flyToSource = function(sourceId) { + var s = null; + for (var i = 0; i < appState.sources.length; i++) { + if (appState.sources[i].id === sourceId) { s = appState.sources[i]; break; } + } + if (!s || !monitoringMap) return; + var center = s.type === 'point' + ? [s.lat, s.lng] + : (s.coordinates && s.coordinates[0]); + if (!center) return; + monitoringMap.panTo(center, { duration: 800 }); + setTimeout(function() { monitoringMap.setZoom(15, { duration: 500 }); }, 400); +}; + +// ── Левая панель: список источников ────────────────────────── +function updateSourcesLeftPanel() { + var container = el('sources-list'); + if (!container) return; + + var header = + '
' + + '📌 ' + + escapeHtml(appState.currentScenarioName || 'Сценарий') + + '' + + '' + appState.sources.length + '' + + '
'; + + if (appState.sources.length === 0) { + container.innerHTML = header + + '

' + + 'Нет источников.
Добавьте источник в правой панели.

'; + return; + } + + var typeNames = { point: 'Точечный', line: 'Линейный', polygon: 'Площадной' }; + var categoryNames = { industrial: '🏭', domestic: '🏠', transport: '🚗' }; + var html = header; + + appState.sources.forEach(function(src) { + html += + '
' + + '
' + + '' + escapeHtml(src.name) + '' + + '
' + + '' + (typeNames[src.type]||src.type) + + ' · ' + src.emissionRate + ' г/с' + + '' + + (categoryNames[src.category]||'') + '' + + '
' + + 'Высота: ' + src.height + ' м' + + '
' + + '
' + + '' + + '' + + '
' + + '
'; + }); + + container.innerHTML = html; +} + +// ── Правый оверлей: список источников ──────────────────────── +function updateOverlaySourcesList() { + var container = el('overlay-sources-container'); + var countBadge = el('sources-count'); + if (!container) return; + if (countBadge) countBadge.textContent = appState.sources.length; + + if (appState.sources.length === 0) { + container.innerHTML = + '

' + + 'Нет источников

'; + return; + } + + var typeNames = { point: 'Точечный', line: 'Линейный', polygon: 'Площадной' }; + var categoryIcons = { industrial: '🏭', domestic: '🏠', transport: '🚗' }; + + container.innerHTML = ''; + appState.sources.forEach(function(src) { + var div = document.createElement('div'); + div.className = 'overlay-source-item'; + div.innerHTML = + '
' + + '' + escapeHtml(src.name) + '' + + '' + (typeNames[src.type]||src.type) + + ' · ' + src.emissionRate + ' г/с' + + ' · ' + (categoryIcons[src.category]||'') + '' + + 'Высота: ' + src.height + ' м' + + '
' + + '
' + + '' + + '
'; + container.appendChild(div); + }); +} + +// ── UI ──────────────────────────────────────────────────────── +function updateUIBasedOnMode() { + var isFullMode = isSimulatorMode && isLoggedIn; + var overlay = el('scenario-overlay'); + var scenBlock = el('scenarios-block'); + + if (isFullMode) { + if (appState.currentScenarioId) { + showSourcesView(); + if (overlay) overlay.style.display = 'flex'; + } else { + showScenariosView(); + if (overlay) overlay.style.display = 'none'; + } + } else { + if (scenBlock) scenBlock.style.display = 'none'; + var srcList = el('sources-list'); + if (srcList) srcList.style.display = 'none'; + if (overlay) overlay.style.display = 'none'; + } +} + +// ── Общие обработчики ───────────────────────────────────────── +function initEventListeners() { + var searchBtn = el('search-btn'); + var searchInput = el('search-input'); + var timeSlider = el('time-slider'); + var timeDisplay = el('current-time-display'); + var btnOpen = el('btn-open'); + var btnAdd = el('btn-add'); + var btnCompare = el('btn-compare'); + var btnClose = el('scenarios-dropdown-close'); + var backBtn = el('back-to-scenarios-btn'); + + if (searchBtn) searchBtn.addEventListener('click', function() { + var q = searchInput ? searchInput.value.trim() : ''; + if (q) searchAddress(q); + }); + if (searchInput) searchInput.addEventListener('keypress', function(e) { + if (e.key === 'Enter') { var q = searchInput.value.trim(); if (q) searchAddress(q); } + }); + if (timeSlider) timeSlider.addEventListener('input', function() { + appState.currentTimeIndex = parseInt(timeSlider.value); + if (timeDisplay) timeDisplay.textContent = timeLabels[appState.currentTimeIndex]; + }); + + if (btnOpen) btnOpen.addEventListener('click', onOpenScenarios); + if (btnAdd) btnAdd.addEventListener('click', onAddScenario); + if (btnCompare) btnCompare.addEventListener('click', function() { + alert('Сравнение — в разработке'); + }); + if (btnClose) btnClose.addEventListener('click', function() { + var dd = el('scenarios-dropdown'); + if (dd) dd.style.display = 'none'; + }); + + if (backBtn) backBtn.addEventListener('click', function() { + appState.currentScenarioId = null; + appState.currentScenarioName = null; + appState.sources = []; + + var overlay = el('scenario-overlay'); + if (overlay) overlay.style.display = 'none'; + + if (window.MapGraphics && monitoringMap) { + MapGraphics.refreshPollutionLayer(monitoringMap, appState.currentSubstanceId, null); + if (MapGraphics.clearSources) MapGraphics.clearSources(monitoringMap); + } + showScenariosView(); + }); +} + +// ── Collapse ────────────────────────────────────────────────── +function initCollapseHandlers() { + document.querySelectorAll('.collapse-header').forEach(function(header) { + header.addEventListener('click', function() { + var section = header.closest('.collapse-section'); + if (section) section.classList.toggle('expanded'); + }); + }); + var first = document.querySelector('.collapse-section'); + if (first) first.classList.add('expanded'); +} + +// ── Оверлей: тип источника + рисование ─────────────────────── +function initOverlaySourceHandlers() { + document.querySelectorAll('#sources-content .source-type-btn').forEach(function(btn) { + btn.addEventListener('click', function() { + document.querySelectorAll('#sources-content .source-type-btn') + .forEach(function(b) { b.classList.remove('active'); }); + btn.classList.add('active'); + }); + }); + + var startBtn = el('start-drawing-overlay-btn'); + var cancelBtn = el('cancel-drawing-overlay-btn'); + var statusDiv = el('drawing-status-overlay'); + var statusText = el('drawing-status-text-overlay'); + + if (startBtn) startBtn.addEventListener('click', function() { + if (!appState.currentScenarioId) { + alert('Сначала откройте или создайте сценарий'); return; + } + var activeBtn = document.querySelector('#sources-content .source-type-btn.active'); + if (!activeBtn) { alert('Выберите тип источника'); return; } + + currentDrawingType = activeBtn.dataset.type; + isDrawingMode = true; + drawingPoints = []; + clearTempDrawings(); + if (monitoringMap) monitoringMap.cursors.push('crosshair'); + + var hints = { + point: 'Точечный — кликните на карте', + line: 'Линейный — клики для точек, двойной клик завершает', + polygon: 'Площадной — клики для контура, двойной клик завершает' + }; + if (statusText) statusText.textContent = hints[currentDrawingType] || ''; + if (statusDiv) statusDiv.style.display = 'block'; + startBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'block'; + }); + + if (cancelBtn) cancelBtn.addEventListener('click', resetDrawingMode); +} + +// ── Погода ──────────────────────────────────────────────────── +function initWeatherHandlers() { + // Вспомогательная функция для остальных слайдеров + function bindSlider(sliderId, valueId, fmt) { + var s = el(sliderId); + var v = el(valueId); + if (s && v) { + s.addEventListener('input', function() { + v.textContent = fmt ? fmt(s.value) : s.value; + }); + } + } + + // Старые слайдеры (работают как раньше) + bindSlider('wind-speed', 'wind-speed-value', function(v) { return parseFloat(v).toFixed(1); }); + bindSlider('temperature', 'temperature-value'); + bindSlider('pressure', 'pressure-value'); + bindSlider('calc-radius', 'calc-radius-value'); + + // Яркость солнца – прямая привязка + var sunSlider = el('sun-brightness'); + var sunValue = el('sun-brightness-value'); + if (sunSlider && sunValue) { + sunSlider.addEventListener('input', function() { + sunValue.textContent = this.value; + }); + } + + // Облачность – прямая привязка + var cloudSlider = el('cloud-density'); + var cloudValue = el('cloud-density-value'); + if (cloudSlider && cloudValue) { + cloudSlider.addEventListener('input', function() { + cloudValue.textContent = this.value; + }); + } + + // Направление ветра и компас (без изменений) + var windDir = el('wind-direction'); + var compass = el('compass-arrow'); + if (windDir && compass) { + var rotate = function() { + compass.style.transform = 'rotate(' + windDir.value + 'deg)'; + }; + windDir.addEventListener('change', rotate); + rotate(); + } + + // Кнопка «Применить и пересчитать» + var applyBtn = el('apply-weather-btn'); + if (applyBtn) { + applyBtn.addEventListener('click', function() { + if (!appState.currentScenarioId) { + alert('Сначала откройте сценарий'); + return; + } + applyBtn.disabled = true; + applyBtn.textContent = 'Применяю…'; + applyWeatherToBackend(); + setTimeout(function() { + applyBtn.disabled = false; + applyBtn.textContent = 'Применить и пересчитать'; + }, 500); + }); + } +} + +function initModelHandlers() { + var runBtn = el('run-simulation-btn'); + if (runBtn) runBtn.addEventListener('click', function() { + if (!appState.currentScenarioId) { + alert('Сначала откройте или создайте сценарий'); return; + } + runBtn.disabled = true; + runBtn.textContent = '⏳ Расчёт…'; + applyWeatherToBackend(); + setTimeout(function() { + runBtn.disabled = false; + runBtn.textContent = '🚀 Запустить расчёт'; + }, 500); + }); +} + +// ── Утилиты ─────────────────────────────────────────────────── +function escapeHtml(str) { + if (!str) return ''; + return String(str) + .replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} + +function generateScenarioName() { + var date = new Date().toLocaleDateString('ru-RU'); + var key = 'sc_n_' + date; + var n = parseInt(localStorage.getItem(key) || '0') + 1; + localStorage.setItem(key, n); + return 'Сценарий ' + date + ' №' + n; +} \ 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..9ac92cd --- /dev/null +++ b/static/js/map-graphics.js @@ -0,0 +1,157 @@ +window.MapGraphics = { + pollutionLayer: null, + windVectors: [], + sourceGeoObjects: [], + currentSubstanceId: null, + currentScenarioId: null, + + // Текущие погодные параметры + weatherParams: { + wind_speed: 3.0, + wind_direction: 180.0, + temperature: 20.0, + pressure: 1013.0, + sun_brightness: 20000, // добавлено + cloud_density: 0 // добавлено + }, + + _buildTileUrlTemplate: function() { + var subId = this.currentSubstanceId || 1; + var sid = this.currentScenarioId || ''; + var wp = this.weatherParams; + var ts = Date.now(); + + return '/api/simulation/tiles/' + subId + + '/%z/%x/%y.png' + + '?scenario_id=' + sid + + '&wind_speed=' + wp.wind_speed + + '&wind_direction=' + wp.wind_direction + + '&temperature=' + wp.temperature + + '&pressure=' + wp.pressure + + '&sun_brightness=' + wp.sun_brightness + // добавлено + '&cloud_density=' + wp.cloud_density + // добавлено + '&t=' + ts; + }, + + setWeatherParams: function(params) { + if (params.wind_speed !== undefined) this.weatherParams.wind_speed = params.wind_speed; + if (params.wind_direction !== undefined) this.weatherParams.wind_direction = params.wind_direction; + if (params.temperature !== undefined) this.weatherParams.temperature = params.temperature; + if (params.pressure !== undefined) this.weatherParams.pressure = params.pressure; + if (params.sun_brightness !== undefined) this.weatherParams.sun_brightness = params.sun_brightness; // добавлено + if (params.cloud_density !== undefined) this.weatherParams.cloud_density = params.cloud_density; // добавлено + }, + + refreshPollutionLayer: function(map, newSubstanceId, newScenarioId, weatherParams) { + if (newSubstanceId !== undefined && newSubstanceId !== null) { + this.currentSubstanceId = newSubstanceId; + } + if (newScenarioId !== undefined) { + this.currentScenarioId = newScenarioId || null; + } + if (weatherParams) { + this.setWeatherParams(weatherParams); + } + + if (this.pollutionLayer) { + map.layers.remove(this.pollutionLayer); + this.pollutionLayer = null; + } + + if (!this.currentScenarioId) return; + + var tileUrl = this._buildTileUrlTemplate(); + this.pollutionLayer = new ymaps.Layer(tileUrl, { + tileTransparent: true, + zIndex: 5000, + minZoom: 9, + maxZoom: 19 + }); + map.layers.add(this.pollutionLayer); + }, + + clearSources: function(map) { + this.sourceGeoObjects.forEach(function(obj) { + map.geoObjects.remove(obj); + }); + this.sourceGeoObjects = []; + }, + + drawSources: function(map, sources) { + this.clearSources(map); + var self = this; + + sources.forEach(function(source) { + var geoObj; + var balloonContent = + '' + source.name + '
' + + 'Тип: ' + source.type + '
' + + 'Выброс: ' + source.emissionRate + ' г/с
' + + 'Высота: ' + source.height + ' м'; + + if (source.type === 'point' || !source.coordinates) { + geoObj = new ymaps.Placemark( + [source.lat, source.lng], + { balloonContent: balloonContent }, + { preset: 'islands#redIcon' } + ); + } else if (source.type === 'line') { + geoObj = new ymaps.Polyline( + source.coordinates, + { balloonContent: balloonContent }, + { strokeColor: '#2c3e50', strokeWidth: 4, strokeOpacity: 0.7 } + ); + } else if (source.type === 'polygon') { + geoObj = new ymaps.Polygon( + [source.coordinates], + { balloonContent: balloonContent }, + { + fillColor: '#2c3e50', + fillOpacity: 0.25, + strokeColor: '#2c3e50', + strokeWidth: 3, + strokeOpacity: 0.7 + } + ); + } + + if (geoObj) { + map.geoObjects.add(geoObj); + self.sourceGeoObjects.push(geoObj); + source.placemark = geoObj; + } + }); + }, + + drawWindVectors: function(map, sources, windDirection) { + this.windVectors.forEach(function(pm) { + map.geoObjects.remove(pm); + }); + this.windVectors = []; + + var self = this; + sources.forEach(function(source) { + if (!source.lat || !source.lng) return; + + var plumeDir = (windDirection + 180) % 360; + var dirRad = (plumeDir * Math.PI) / 180; + var length = 1500; + + var dLat = (length * Math.cos(dirRad)) / 111000; + var dLng = (length * Math.sin(dirRad)) / + (111000 * Math.cos(source.lat * Math.PI / 180)); + + var 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); + self.windVectors.push(polyline); + }); + } +}; \ No newline at end of file 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/history.html b/templates/history.html deleted file mode 100644 index 566549b..0000000 --- a/templates/history.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - - Title - - - - - \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 566549b..1e7c7dd 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,10 +1,367 @@ - - + + - - Title + + КудаДым — Моделирование выбросов + + + + + + +
+ +
+ Загрузка веществ… +
+
+ +
+ + +
+
+ + + + + +
+
+ 14:0014:3015:0015:30 + 16:0016:3017:0017:30 + 18:0018:30 +
+
+ +
+
16:30
+
+
+
+ \ No newline at end of file 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