From a15b8190939c6b5cb9e5e671e693d86d57698802 Mon Sep 17 00:00:00 2001 From: axxxiomatic Date: Tue, 23 Dec 2025 13:18:12 +0300 Subject: [PATCH 01/13] =?UTF-8?q?=D0=94=D0=BE=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=D0=BE=20=D0=B2=D0=B5=D0=B1-=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5,=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=B2=D0=B8?= =?UTF-8?q?=D0=B7=D1=83=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=B1=D1=80=D0=BE=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 + .gitignore | 1 + ...ss_manager.iml => EcologicalPredictor.iml} | 2 +- .idea/inspectionProfiles/Project_Default.xml | 24 - .idea/misc.xml | 4 +- .idea/modules.xml | 2 +- LICENSE | 30 + README.md | 4 + __pycache__/database.cpython-312.pyc | Bin 0 -> 8022 bytes app.py | 206 +++- database.py | 187 +++ instance/h2s_simulation.db | Bin 0 -> 16384 bytes natasha.html | 724 ----------- requirements.txt | 3 + static/css/base.css | 276 +++++ static/css/enterprise.css | 460 +++++++ static/css/forecasting.css | 208 ++++ static/css/history.css | 152 +++ static/css/index.css | 83 ++ static/css/login.css | 87 ++ static/css/recommendations.css | 105 ++ static/img/logo_white.png | Bin 0 -> 86103 bytes static/js/enterprise.js | 1041 ++++++++++++++++ static/js/history.js | 110 ++ static/js/main.js | 1060 +++++++++++++++++ templates/enterprise.html | 203 ++++ templates/forecast.html | 10 - templates/forecasting.html | 125 ++ templates/history.html | 124 +- templates/index.html | 93 +- templates/layout.html | 31 + templates/login.html | 86 ++ templates/monitoring.html | 10 - templates/recomendations.html | 10 - templates/recommendations.html | 101 ++ 35 files changed, 4764 insertions(+), 801 deletions(-) create mode 100644 .env.example create mode 100644 .gitignore rename .idea/{nonlinear_process_manager.iml => EcologicalPredictor.iml} (81%) delete mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 __pycache__/database.cpython-312.pyc create mode 100644 database.py create mode 100644 instance/h2s_simulation.db delete mode 100644 natasha.html create mode 100644 requirements.txt create mode 100644 static/css/base.css create mode 100644 static/css/enterprise.css create mode 100644 static/css/forecasting.css create mode 100644 static/css/history.css create mode 100644 static/css/index.css create mode 100644 static/css/login.css create mode 100644 static/css/recommendations.css create mode 100644 static/img/logo_white.png create mode 100644 static/js/enterprise.js create mode 100644 static/js/history.js create mode 100644 static/js/main.js create mode 100644 templates/enterprise.html delete mode 100644 templates/forecast.html create mode 100644 templates/forecasting.html create mode 100644 templates/layout.html create mode 100644 templates/login.html delete mode 100644 templates/monitoring.html delete mode 100644 templates/recomendations.html create mode 100644 templates/recommendations.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f2b0760 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +YMAPS_API_KEY= +YMAPS_LANG= +YWEATHER_API_KEY= \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2eea525 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/.idea/nonlinear_process_manager.iml b/.idea/EcologicalPredictor.iml similarity index 81% rename from .idea/nonlinear_process_manager.iml rename to .idea/EcologicalPredictor.iml index 2c80e12..6cb8b9a 100644 --- a/.idea/nonlinear_process_manager.iml +++ b/.idea/EcologicalPredictor.iml @@ -4,7 +4,7 @@ - + \ No newline at end of file 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..4675d00 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,7 +1,7 @@ - - + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml index 1906244..365e9b8 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -2,7 +2,7 @@ - + \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..01f875e --- /dev/null +++ b/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2025 National Institute of Electronic Technology (MIET), Sub-Institute SPINTECH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +1. The above copyright notice, this list of conditions, and the following + disclaimer must be included in all copies or substantial portions of the + Software. + +2. All modifications made to the original source code must be clearly + documented and attributed to the modifying author or organization. However, + the underlying intellectual property and copyright of the original Software + remains with MIET, Sub-Institute SPINTECH. + +3. The names "MIET", "National Institute of Electronic Technology", + "SPINTECH", and any related logos shall not be used to endorse or promote + products derived from this Software without specific prior written + permission from MIET, Sub-Institute SPINTECH. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. 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/__pycache__/database.cpython-312.pyc b/__pycache__/database.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3154897c301a07c6ad9b7158645d88616ddd217e GIT binary patch literal 8022 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 literal 0 HcmV?d00001 diff --git a/app.py b/app.py index 5596b44..ac2d08b 100644 --- a/app.py +++ b/app.py @@ -1,16 +1,204 @@ -# This is a sample Python script. +import database as db +import os +import webbrowser +from threading import Timer +from flask import Flask, render_template, request, jsonify +from dotenv import load_dotenv -# 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. +load_dotenv() +app = Flask(__name__) -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. +YMAPS_API_KEY = os.getenv('YMAPS_API_KEY') +YMAPS_LANG = os.getenv('YMAPS_LANG') +YWEATHER_API_KEY = os.getenv('YWEATHER_API_KEY') +required_vars = ['YMAPS_API_KEY', 'YMAPS_LANG', 'YWEATHER_API_KEY'] +missing_vars = [var for var in required_vars if not os.getenv(var)] +if missing_vars: + raise ValueError(f"Отсутствуют обязательные переменные окружения: {', '.join(missing_vars)}") + +# Если папка instance для хранения БД не создана, она создаётся +if not os.path.exists('instance'): + os.makedirs('instance') + +@app.context_processor +def inject_config(): + """Внедряет конфигурацию во все шаблоны""" + return { + 'config': { + 'YMAPS_API_KEY ': YMAPS_API_KEY, + 'YMAPS_LANG': YMAPS_LANG, + 'YWEATHER_API_KEY': YWEATHER_API_KEY, + } + } + +# Страницы сайта +@app.route('/') +def index(): + """Страница мониторинга""" + try: + sources = db.get_sources() + params = db.get_simulation_params() + + return render_template('index.html', + sources=sources, + wind_speed=params['wind_speed'], + wind_direction=params['wind_direction'], + stability_class=params['stability_class']) + + except Exception as e: + print(f"Ошибка пути до страницы мониторинга: {e}") + return "Ошибка загрузки страницы", 500 + +@app.route('/history') +def history(): + """Страница истории""" + try: + return render_template('history.html') + except Exception as e: + print(f"Ошибка пути до страницы истории: {e}") + return "Ошибка загрузки страницы", 500 + +@app.route('/forecasting') +def forecasting(): + """Страница прогнозирования""" + try: + return render_template('forecasting.html') + except Exception as e: + print(f"Ошибка пути до страницы прогнозирования: {e}") + return "Ошибка загрузки страницы", 500 + +@app.route('/recommendations') +def recommendations(): + """Страница рекомендаций""" + try: + return render_template('recommendations.html') + except Exception as e: + print(f"Ошибка пути до страницы рекомендаций: {e}") + return "Ошибка загрузки страницы", 500 + +@app.route('/enterprise') +def enterprise(): + """Страница режима предприятия""" + try: + return render_template('enterprise.html') + except Exception as e: + print(f"Ошибка пути до страницы режима предприятия: {e}") + return "Ошибка загрузки страницы", 500 + +@app.route('/login') +def login(): + """Страница авторизации""" + try: + return render_template('login.html') + except Exception as e: + print(f"Ошибка пути до страницы авторизации: {e}") + return "Ошибка загрузки страницы", 500 + +# API-маршруты +@app.route('/api/sources', methods=['GET']) +def get_sources_api(): + """GET-запрос на получение списка источников выбросов""" + try: + sources = db.get_sources() + return jsonify(sources) + except Exception as e: + print(f"Ошибка получения источников выбросов: {e}") + return jsonify({'error': 'failed to get emission sources list'}), 500 + +@app.route('/api/sources', methods=['POST']) +def add_source_api(): + """POST-запрос для добавления нового источника выбросов""" + try: + data = request.json + source_id = db.add_source( + name=data.get('name', 'Новый источник'), + source_type=data.get('type', 'point'), + latitude=data.get('latitude'), + longitude=data.get('longitude'), + height=data.get('height', 40.0), + emission_rate=data.get('emission_rate', 3.7) + ) + return jsonify({'success': True, 'source_id': source_id}) + except Exception as e: + print(f"Ошибка добавления источника выбросов: {e}") + return jsonify({'error': 'failed to add emission source'}), 500 + +@app.route('/api/sources/', methods=['DELETE']) +def delete_source_api(source_id): + """DELETE-запрос на удаление источника выбросов""" + try: + db.delete_source(source_id) + return jsonify({'success': True}) + except Exception as e: + print(f"Ошибка удаления источника выбросов: {e}") + return jsonify({'error': 'failed to delete emission source'}), 500 + +@app.route('/api/params', methods=['GET']) +def get_params_api(): + """GET-запрос на получение параметров моделирования""" + try: + params = db.get_simulation_params() + return jsonify(params) + except Exception as e: + print(f"Ошибка получения параметров моделирования: {e}") + return jsonify({'error': 'failed to get simulation parameters'}), 500 + +@app.route('/api/params', methods=['POST']) +def update_params_api(): + """POST-запрос на обновление параметров моделирования""" + try: + data = request.json + db.update_simulation_params( + wind_speed=data.get('wind_speed'), + wind_direction=data.get('wind_direction'), + stability_class=data.get('stability_class') + ) + return jsonify({'success': True}) + except Exception as e: + print(f"Ошибка обновления параметров моделирования: {e}") + return jsonify({'error': 'failed to update simulation parameters'}), 500 + +@app.errorhandler(404) +def not_found(error): + return "Page not found", 404 + +@app.errorhandler(500) +def internal_error(error): + return "Internal server error", 500 -# Press the green button in the gutter to run the script. if __name__ == '__main__': - print_hi('PyCharm') + try: + # Инициализация БД при запуске + print("Инициализация базы данных...") + db.init_db() + print("База данных инициализирована успешно") + + # Определение хоста и порта + HOST = '127.0.0.1' + PORT = 5000 + URL = f"http://{HOST}:{PORT}" + + # Функция для открытия браузера + def open_browser(): + webbrowser.open_new(URL) + + print("Цифровая платформа предсказания выбросов") + print(f"Сервер запускается по адресу: {URL}") + print("Нажмите CTRL + C, чтобы остановить сервер") + + # Браузер открывается через 1 секунду в отдельном потоке, что даёт серверу время на запуск + Timer(1, open_browser).start() + + # Запуск сервера Flask + app.run( + host=HOST, + port=PORT, + debug=True, + use_reloader=False + ) -# See PyCharm help at https://www.jetbrains.com/help/pycharm/ + except Exception as e: + print(f"Failed to start server: {e}") + print(f"Make sure no other application is using port {PORT}") diff --git a/database.py b/database.py new file mode 100644 index 0000000..cc890ac --- /dev/null +++ b/database.py @@ -0,0 +1,187 @@ +import sqlite3 +import os + +def get_db_path(): + """Получение пути к базе данных""" + return 'instance/h2s_simulation.db' + +def init_db(): + """Инициализация базы данных с тестовым содержанием""" + try: + db_path = get_db_path() + + # Если директории базы данных instance нет, она создаётся + os.makedirs(os.path.dirname(db_path), exist_ok=True) + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Создание таблицы источников + cursor.execute(''' + CREATE TABLE IF NOT EXISTS sources ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + height REAL NOT NULL, + emission_rate REAL NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # Создание таблицы параметров моделирования + cursor.execute(''' + CREATE TABLE IF NOT EXISTS simulation_params ( + id INTEGER PRIMARY KEY, + wind_speed REAL NOT NULL, + wind_direction TEXT NOT NULL, + stability_class TEXT NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # Проверка наличия данных в таблице с помощью курсора + cursor.execute("SELECT COUNT(*) FROM sources") + if cursor.fetchone()[0] == 0: + # Если данных нет, добавляется тестовый набор + test_sources = [ + ('Источник 1', 'point', 55.7558, 37.6173, 40.0, 3.7), + ('Источник 2', 'point', 55.7600, 37.6250, 35.0, 2.5), + ('Источник 3', 'point', 55.7500, 37.6100, 45.0, 4.2) + ] + + cursor.executemany(''' + INSERT INTO sources (name, type, latitude, longitude, height, emission_rate) + VALUES (?, ?, ?, ?, ?, ?) + ''', test_sources) + + # Добавление параметров по умолчанию + cursor.execute(''' + INSERT OR REPLACE INTO simulation_params (id, wind_speed, wind_direction, stability_class) + VALUES (1, 2.4, '0', 'C') + ''') + + conn.commit() + conn.close() + print("База данных успешно инициализирована") + + except Exception as e: + print(f"Ошибка инициализации базы данных: {e}") + raise + +def get_sources(): + """Получение всех источников выбросов из базы данных""" + try: + conn = sqlite3.connect(get_db_path()) + cursor = conn.cursor() + + cursor.execute(''' + SELECT id, name, type, latitude, longitude, height, emission_rate + FROM sources + ORDER BY created_at DESC + ''') + + sources = [] + for row in cursor.fetchall(): + sources.append({ + 'id': row[0], + 'name': row[1], + 'type': row[2], + 'latitude': row[3], + 'longitude': row[4], + 'height': row[5], + 'emission_rate': row[6] + }) + + conn.close() + return sources + except Exception as e: + print(f"Ошибка получения источников выбросов из базы данных: {e}") + return [] + +def get_simulation_params(): + """Получение параметров моделирования""" + try: + conn = sqlite3.connect(get_db_path()) + cursor = conn.cursor() + + cursor.execute(''' + SELECT wind_speed, wind_direction, stability_class + FROM simulation_params + WHERE id = 1 + ''') + + row = cursor.fetchone() + conn.close() + + if row: + return { + 'wind_speed': row[0], + 'wind_direction': row[1], + 'stability_class': row[2] + } + else: + # Значения по умолчанию + return { + 'wind_speed': 2.4, + 'wind_direction': '0', + 'stability_class': 'C' + } + except Exception as e: + print(f"Ошибка получения параметров моделирования из базы данных: {e}") + return { + 'wind_speed': 2.4, + 'wind_direction': '0', + 'stability_class': 'C' + } + +def add_source(name, source_type, latitude, longitude, height, emission_rate): + """Добавление нового источника""" + try: + conn = sqlite3.connect(get_db_path()) + cursor = conn.cursor() + + cursor.execute(''' + INSERT INTO sources (name, type, latitude, longitude, height, emission_rate) + VALUES (?, ?, ?, ?, ?, ?) + ''', (name, source_type, latitude, longitude, height, emission_rate)) + + conn.commit() + source_id = cursor.lastrowid + conn.close() + return source_id + except Exception as e: + print(f"Ошибка добавления источника выбросов в базу данных: {e}") + raise + +def delete_source(source_id): + """Удаление источника""" + try: + conn = sqlite3.connect(get_db_path()) + cursor = conn.cursor() + + cursor.execute('DELETE FROM sources WHERE id = ?', (source_id,)) + + conn.commit() + conn.close() + except Exception as e: + print(f"Ошибка удаления источника выбросов из базы данных: {e}") + raise + +def update_simulation_params(wind_speed, wind_direction, stability_class): + """Обновление параметров моделирования""" + try: + conn = sqlite3.connect(get_db_path()) + cursor = conn.cursor() + + cursor.execute(''' + INSERT OR REPLACE INTO simulation_params (id, wind_speed, wind_direction, stability_class) + VALUES (1, ?, ?, ?) + ''', (wind_speed, wind_direction, stability_class)) + + conn.commit() + conn.close() + except Exception as e: + print(f"Ошибка обновления параметров моделирования в базе данных: {e}") + raise diff --git a/instance/h2s_simulation.db b/instance/h2s_simulation.db new file mode 100644 index 0000000000000000000000000000000000000000..e2b24630a6da753a8b132bd17c6eaa01f97d8a4d GIT binary patch literal 16384 zcmeI2eM}Q)7{Kp9DW9z}6(>$_f(}2J_uckfyyLJaYvB-DqklM7X_XqjY>UD+mnfnb zb$&B-Zn`Zd&Y0Zc({q=c& z&+|UVg=S4PIox(^cA+!h>c%LfAA+KYfME!NSiz$Yc*r9N{PO&BR6fze(Tcn|e?=6S z5FB|JQO{IIfCC)R05kv%Km*VKGyn}i1JD3801fW6`BCUS?sj6QEq) zLy$ki`{D~{086r%rPOr5NCQjiYS|M*Z?fE_MZM1P)RVi|X74Sld4&aYUT9*j-7zQE z-5buH?*P>c3fT$L+1unCr~O|i-E(jLdf)YkW}n8U8Kgd;cB_X+)kGCX#YbLNm8ud#ehyh0!t`wc2soetXaE|32L77? zX;K)XjZvvk6;;2fVNt{4`Xdd?>JQf+tgprxnqrFz9R+T|*q)-Uf7>jGpDf5}Z~i=v z|7KKM$%XcNBu+Ak1eu7l7(sC~%i)Y9Dg#M|q`g2B>Uel+eUTupql2<6b%Xh>yF-al zZKeJ|0_R9lqLhIIt?vZx4sR(MB8VGmZv$XFe|BxIb1!znABZy?Nl2tJkfvF!FT$XK zwbI5yLEQ8pGRsmE&sT3dGXL~`eE^W=NLnJ4fh5IrIy}n8O_?T$Tc#}pz-T^tYjsuX zPs;;A4_GLR;pMR?5C zsJ{-fT*w08Fn;^2v>)$$_1E)2l4GhIsy$q~%)U z$({Z{T+flL6sHWNS-B34PgRxcHna)i`m4V;R?dv)cdmCIn)~6S0HBVeXlXWO=wUP|H38Ce6Lom?4PU_HO~e62AH!hX3H^ z+5n)IqqWj7WgyLvlyB=HyUtJfs7w$y^U{H~uoC_UZ;uNAvYsWsrVM1Xw7lde-^Fh* z>=ML{hVcj5!b|vL56}HNXxZ2RAOm(v#!xRXsGELBGjg{vKCatW$mfCxD+YoHkxKql z-iqXni$Vg3G)L1?ta6BymSlWcxpH>&kTWfUSogSS$Ag5L zLwrEAYs=Qtj10V8wRGYap@LX*XGWCNOol&C0zHA^2)*=b525MgUTth_{o}!O&|1%a z-#l;N>25>AIZ)CRB@I>v>Or&it@T=TN?GevK|I$KSyjEiE0DlQlB0BhDaG^v)OPln zs{_v68EFG+wXdwI{#FSH8kb%gqzq(OQZAU5a&(pYQvhTHjgwFSf<8kt=>x+MM&Sag zB}h5DpIx2sTZAB z5MCibP*M!g?7M~>d#~F_y-+f% zf6q{mKe@{`<9n7&xG0FVIjaw}MJPeR8+1}XWuH2NmM_lDxrx`V9|uu7G+lv2B86iF zC~)xih)&Wd14+GJ2DZN2emUZbARb3Sgn`SJfdmP*v`HO?5DE$Mbwp!hM+XEuX25$* zlfOHS?``E5=p%$JP%E0ic+S@DFFz;5ybiqYMkKvM|*d)NM)M5R~nB*-Aq^o zN-=oPfwq1Dx$uk4bN`QO?jqm^4rl-xfCiueXaE|32A~0G02+V>paEzA8hCjOM4-X3 zsP9V{lf3u;7ZJ_HmuG=+CC~sg01ZF`&;T?54L}3X05kv%Km*Xg|Hwcb8iJ@a8cGPJ MItHHH&9@2u1|U`bi2wiq literal 0 HcmV?d00001 diff --git a/natasha.html b/natasha.html deleted file mode 100644 index 7b4bbcb..0000000 --- a/natasha.html +++ /dev/null @@ -1,724 +0,0 @@ - - - - - Моделирование выбросов H2S (версия 1.12) - - - - -
-
Моделирование выбросов H2S
-
- - -
-
- -
-
-
-

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

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

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

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

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

-
-
- -
-

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

-
-
-
- -
-
-
-
- - - - - - \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2b8928e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask>=2.2.3 +Werkzeug==2.2.2 +python-dotenv~=1.2.1 \ No newline at end of file diff --git a/static/css/base.css b/static/css/base.css new file mode 100644 index 0000000..ba1f771 --- /dev/null +++ b/static/css/base.css @@ -0,0 +1,276 @@ +:root { + --accent: #f07a2a; + --header-bg: #2f4b5b; + --panel-bg: #eef6fb; + --card-bg: linear-gradient(180deg,#f7fbfe,#e8f0f4); + --muted: #2f4350; + --text-dark: #1a2a38; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + height: 100%; + font-family: "Segoe UI", Roboto, Arial, sans-serif; + background: #f3f6f8; + color: var(--muted); +} + +a { + text-decoration: none; + color: inherit; +} + +.header { + background: linear-gradient(180deg, #2f4b5b, #27424f); + padding: 18px 28px; + color: white; +} + +.title-container { + display: flex; + align-items: center; + gap: 16px; +} + +.site-title { + font-size: 24px; + font-weight: 700; + margin: 0; + color: white; +} + +.title-underline { + flex-grow: 1; + height: 3px; + background: rgba(255,255,255,0.3); + margin-left: 20px; +} + +.navbar { + margin-top: 16px; + display: flex; + gap: 32px; +} + + .navbar a { + color: rgba(255,255,255,0.9); + font-size: 18px; + font-weight: 600; + padding: 6px 0; + position: relative; + } + + .navbar a:hover, .navbar a.active { + color: var(--accent); + } + + .navbar a.active::after { + content: ''; + position: absolute; + bottom: -8px; + left: 0; + width: 100%; + height: 3px; + background: var(--accent); + border-radius: 2px; + } + +.container { + display: grid; + grid-template-columns: 360px 1fr; + gap: 24px; + padding: 24px 32px; + max-width: 1920px; + margin: 0 auto; +} + +.panel { + background: var(--panel-bg); + padding: 24px; + border-radius: 14px; + border: 6px solid rgba(255,255,255,0.6); + box-shadow: 0 4px 20px rgba(0,0,0,0.08); + color: var(--text-dark); +} + + .panel h2 { + font-size: 19px; + font-weight: 700; + margin-bottom: 20px; + color: #222; + } + +.select { + width: 100%; + padding: 16px; + border-radius: 10px; + border: none; + background: #dfeaf1; + font-size: 17px; + font-weight: 500; + color: #333; +} + +.form-row { + margin: 20px 0; +} + + .form-row label { + font-size: 16px; + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + } + +.btn { + width: 100%; + padding: 16px; + margin-top: 16px; + background: linear-gradient(180deg, #ffa64d, #f07a2a); + color: white; + border: none; + border-radius: 16px; + font-size: 16px; + font-weight: 700; + cursor: pointer; + box-shadow: 0 6px 16px rgba(240,122,42,0.3); + transition: all 0.2s; +} + + .btn:hover { + transform: translateY(-2px); + box-shadow: 0 10px 20px rgba(240,122,42,0.4); + background: linear-gradient(180deg, #ffb766, #f58c3b); + } + +.legend { + margin-top: 30px; +} + + .legend h3 { + font-size: 17px; + margin-bottom: 16px; + font-weight: 700; + } + + .legend .row { + display: flex; + align-items: center; + gap: 14px; + margin: 12px 0; + font-size: 15.5px; + font-weight: 500; + } + +.swatch { + width: 36px; + height: 20px; + border-radius: 6px; + box-shadow: inset 0 1px 3px rgba(0,0,0,0.2); +} + + .swatch.ok { + background: #2ab34a; + } + + .swatch.warn { + background: #d8f135; + } + + .swatch.pdq { + background: #f0c12b; + } + + .swatch.danger { + background: #e44b2f; + } + +.card { + background: var(--card-bg); + border-radius: 20px; + padding: 20px; + border: 6px solid rgba(255,255,255,0.6); + box-shadow: 0 8px 25px rgba(0,0,0,0.1); + display: flex; + flex-direction: column; +} + +.map-header { + font-size: 14px; + color: var(--muted); + padding-bottom: 10px; + border-bottom: 1px solid rgba(0,0,0,0.08); +} + +#map { + height: 720px; + border-radius: 16px; + overflow: hidden; + border: 5px solid rgba(0,0,0,0.05); + margin-top: 16px; + box-shadow: inset 0 4px 10px rgba(0,0,0,0.08); +} + +.timeline-wrap { + margin-top: 20px; + text-align: center; +} + +.time-ticks { + display: flex; + justify-content: space-between; + font-size: 13.5px; + color: #123; + padding: 0 12px; + font-weight: 500; +} + +.timeline { + position: relative; + height: 20px; + background: #213642; + border-radius: 12px; + margin: 10px 24px; +} + +.handle { + position: absolute; + top: -16px; + left: 50%; + transform: translateX(-50%); + pointer-events: none; +} + + .handle .triangle { + width: 0; + height: 0; + border-left: 12px solid transparent; + border-right: 12px solid transparent; + border-bottom: 16px solid var(--accent); + margin-bottom: 6px; + } + + .handle .dot { + width: 22px; + height: 22px; + background: var(--accent); + border-radius: 50%; + box-shadow: 0 6px 12px rgba(240,122,42,0.5); + } + +@media (max-width: 1100px) { + .container { + grid-template-columns: 1fr; + padding: 16px; + } + + #map { + height: 560px; + } +} diff --git a/static/css/enterprise.css b/static/css/enterprise.css new file mode 100644 index 0000000..238ab0e --- /dev/null +++ b/static/css/enterprise.css @@ -0,0 +1,460 @@ +html, body { + height: 100vh; + overflow: hidden; + margin: 0; + padding: 0; +} + +.container { + display: grid; + grid-template-columns: 380px 1fr; + gap: 24px; + height: calc(100vh - 120px); + overflow: hidden; + padding: 0 24px 24px 24px; +} + +.panel { + height: 100%; + overflow-y: auto; + background: #eef6fb; + padding: 24px; + border-radius: 12px; + border: 6px solid rgba(255,255,255,0.6); +} + +.card { + height: 100%; + border-radius: 18px; + padding: 12px; + border: 6px solid rgba(255,255,255,0.6); + background: linear-gradient(180deg,#f7fbfe,#e8f0f4); + position: relative; + display: flex; + flex-direction: column; +} + +#map { + width: 100%; + flex: 1; + border-radius: 14px; + border: 4px solid rgba(0,0,0,0.04); + min-height: 500px; + position: relative; + z-index: 1; +} + +.input-pill { + width: 100%; + padding: 14px 18px; + background: #c8d6e0; + border-radius: 30px; + border: none; + text-align: center; + font-size: 17px; + font-weight: 600; + color: #2c3e50; + margin-bottom: 15px; +} + +.add-source { + margin-top: 20px; + background: linear-gradient(180deg, #95a5a6, #7f8c8d); + width: 100%; + padding: 12px; + color: white; + border: none; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + font-size: 14px; +} + +.add-source:hover { + background: linear-gradient(180deg, #a0b0b1, #95a5a6); +} + +.enterprise-source-item { + margin-top: 15px; + background: white; + padding: 14px; + border-radius: 12px; + display: flex; + justify-content: space-between; + align-items: center; + font-size: 15px; + box-shadow: 0 3px 10px rgba(0,0,0,0.08); +} + +.btn-delete { + background: #e74c3c; + color: white; + border: none; + padding: 8px 16px; + border-radius: 20px; + font-size: 13px; + cursor: pointer; +} + +.enterprise-legend-item { + display: flex; + align-items: center; + gap: 12px; + margin: 12px 0; + font-size: 15px; +} + +.swatch { + width: 20px; + height: 10px; + border-radius: 2px; +} + +.swatch.ok { background-color: #00FF00; } +.swatch.warn { background-color: #FFFF00; } +.swatch.pdq { background-color: #FFA500; } +.swatch.danger { background-color: #FF0000; } + +.select.styled { + width: 100%; + padding: 14px 18px; + background: #c8d6e0; + border-radius: 30px; + border: none; + text-align: center; + font-size: 17px; + font-weight: 600; + color: #2c3e50; + margin-bottom: 15px; + cursor: pointer; + position: relative; +} + +.select.styled .arrow { + position: absolute; + right: 18px; +} + +.results-panel { + margin-top: 20px; + padding: 15px; + background: white; + border-radius: 8px; + font-size: 13px; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} + +.calculation-status { + padding: 10px; + background: #fff3cd; + border: 1px solid #ffeaa7; + border-radius: 4px; + margin: 10px 0; + font-size: 13px; +} + +.legend { + position: absolute; + bottom: 120px; + right: 20px; + background: white; + padding: 10px; + border-radius: 5px; + box-shadow: 0 2px 5px rgba(0,0,0,0.2); + z-index: 1000; +} + +.legend-item { + display: flex; + align-items: center; + margin: 5px 0; + font-size: 12px; +} + +.legend-color { + width: 20px; + height: 10px; + margin-right: 5px; + border-radius: 2px; +} + +.modal { + display: none; + position: fixed; + z-index: 2000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0,0,0,0.5); +} + +.modal-content { + background-color: white; + margin: 15% auto; + padding: 20px; + border-radius: 5px; + width: 350px; + box-shadow: 0 3px 9px rgba(0,0,0,0.2); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; +} + +.modal-title { + margin: 0; + font-size: 18px; +} + +.close { + font-size: 24px; + cursor: pointer; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + margin-top: 20px; +} + +.modal-btn { + padding: 8px 15px; + margin-left: 10px; + background: linear-gradient(180deg, #4a90e2, #357ABD); + color: white; + border: none; + border-radius: 8px; + font-weight: 600; + cursor: pointer; +} + +.heatmap-controls { + position: absolute; + top: 70px; + right: 20px; + background: white; + padding: 10px; + border-radius: 5px; + box-shadow: 0 2px 5px rgba(0,0,0,0.2); + z-index: 1001; +} + +.heatmap-controls label { + display: flex; + align-items: center; + font-size: 12px; + margin: 5px 0; + cursor: pointer; +} + +.heatmap-controls input[type="range"] { + width: 120px; + margin-left: 10px; +} + +.heatmap-intensity { + font-size: 11px; + color: #666; + margin-top: 5px; +} + +.loading-indicator { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(255, 255, 255, 0.95); + padding: 20px; + border-radius: 10px; + box-shadow: 0 4px 20px rgba(0,0,0,0.2); + z-index: 1002; + text-align: center; + display: none; + min-width: 250px; +} + +.progress-bar { + width: 200px; + height: 10px; + background: #eee; + border-radius: 5px; + margin: 15px auto; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, #4CAF50, #8BC34A, #FFC107, #FF9800, #FF5722); + width: 0%; + transition: width 0.3s ease; +} + +.map-header { + background: rgba(255, 255, 255, 0.95); + padding: 10px 15px; + border-radius: 8px; + margin-bottom: 10px; + font-size: 14px; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + z-index: 999; + position: relative; +} + +.test-button { + margin-top: 10px; + padding: 5px 10px; + background: #4a90e2; + color: white; + border: none; + border-radius: 3px; + cursor: pointer; + font-size: 11px; +} + +.error-message { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(255, 0, 0, 0.1); + border: 2px solid red; + padding: 20px; + border-radius: 10px; + z-index: 1003; + text-align: center; + display: none; +} + +.debug-info { + position: absolute; + bottom: 10px; + left: 10px; + background: rgba(0, 0, 0, 0.7); + color: white; + padding: 5px 10px; + border-radius: 3px; + font-size: 10px; + z-index: 1004; + display: none; +} + +.wind-controls { + position: absolute; + top: 70px; + left: 20px; + background: white; + padding: 10px; + border-radius: 5px; + box-shadow: 0 2px 5px rgba(0,0,0,0.2); + z-index: 1001; + width: 180px; +} + +.wind-direction-display { + width: 100px; + height: 100px; + margin: 10px auto; + position: relative; + border: 2px solid #ddd; + border-radius: 50%; + background: #f9f9f9; +} + +.wind-direction-arrow { + position: absolute; + top: 50%; + left: 50%; + width: 2px; + height: 40px; + background: #3498db; + transform-origin: 50% 0; + transition: transform 0.3s ease; +} + +.wind-direction-label { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 12px; + color: #666; +} + +.wind-compass-points { + position: absolute; + width: 100%; + height: 100%; +} + +.compass-point { + position: absolute; + font-size: 10px; + color: #888; +} + +.north { top: 2px; left: 50%; transform: translateX(-50%); } +.east { top: 50%; right: 2px; transform: translateY(-50%); } +.south { bottom: 2px; left: 50%; transform: translateX(-50%); } +.west { top: 50%; left: 2px; transform: translateY(-50%); } + +.wind-effect-intensity { + display: flex; + align-items: center; + margin-top: 10px; +} + +.wind-effect-intensity label { + flex: 1; +} + +.timeline-wrap { + margin-top: 20px; + background: rgba(255, 255, 255, 0.9); + padding: 10px 15px; + border-radius: 8px; + position: relative; +} + +.time-ticks { + display: flex; + justify-content: space-between; + font-size: 11px; + color: #666; + margin-bottom: 5px; +} + +.timeline { + height: 4px; + background: #e0e0e0; + border-radius: 2px; + position: relative; +} + +.handle { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 1; +} + +.dot { + width: 12px; + height: 12px; + background: #4a90e2; + border-radius: 50%; + box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.2); +} + +.triangle { + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-bottom: 8px solid #4a90e2; + margin: 0 auto 2px auto; +} \ No newline at end of file diff --git a/static/css/forecasting.css b/static/css/forecasting.css new file mode 100644 index 0000000..42899e7 --- /dev/null +++ b/static/css/forecasting.css @@ -0,0 +1,208 @@ +.styled { + background: #c8d6e0; + padding: 16px; + border-radius: 12px; + font-weight: 600; + color: #2c3e50; + position: relative; + font-size: 16px; +} + + .styled .arrow { + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + font-size: 18px; + } + +.date-select { + position: relative; +} + +.calendar-icon { + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + font-size: 20px; + pointer-events: none; +} + +.checkbox-row label { + display: flex; + align-items: center; + gap: 10px; + font-size: 16px; + cursor: pointer; +} + +.checkbox-row input[type="checkbox"] { + width: 20px; + height: 20px; + accent-color: #f07a2a; +} + +.period-tabs { + display: flex; + gap: 8px; + margin: 16px 0; + flex-wrap: wrap; +} + +.tab { + padding: 10px 18px; + background: #d0dbe3; + border: none; + border-radius: 10px; + font-weight: 600; + color: #555; + cursor: pointer; + transition: all 0.2s; +} + + .tab.active { + background: linear-gradient(180deg, #ffa64d, #f07a2a); + color: white; + box-shadow: 0 4px 12px rgba(240,122,42,0.4); + } + +.update-btn { + margin-top: 32px; + background: linear-gradient(180deg, #95a5a6, #7f8c8d) !important; +} + + .update-btn:hover { + background: linear-gradient(180deg, #a0b0b1, #95a5a6) !important; + } + +.forecast-card { + padding: 32px; + display: flex; + flex-direction: column; + gap: 40px; +} + +.results-block { + text-align: center; +} + +.results-title { + font-size: 22px; + font-weight: 700; + color: #2c3e50; + margin-bottom: 28px; +} + +.forecast-values { + display: flex; + flex-direction: column; + gap: 20px; + align-items: center; +} + +.value-item { + display: flex; + align-items: center; + gap: 20px; + font-size: 17px; +} + + .value-item .label { + color: #555; + min-width: 280px; + text-align: right; + } + +.value-badge { + padding: 12px 24px; + border-radius: 30px; + font-weight: 700; + font-size: 18px; + min-width: 140px; + text-align: center; +} + + .value-badge.orange { + background: linear-gradient(135deg, #f39c12, #e67e22); + color: white; + } + + .value-badge.gray { + background: #bdc3c7; + color: #2c3e50; + } + +.factors-title { + text-align: center; + font-size: 21px; + font-weight: 700; + color: #2c3e50; + margin-bottom: 32px; +} + +.factors-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 32px; + justify-items: center; +} + +.factor { + text-align: center; + max-width: 200px; +} + +.circle { + width: 140px; + height: 140px; + border-radius: 50%; + margin: 0 auto 16px; + position: relative; + background: conic-gradient(from 0deg, #27ae60 0%, #f1c40f 50%, #e74c3c 100%); + box-shadow: 0 8px 25px rgba(0,0,0,0.15); + display: flex; + align-items: center; + justify-content: center; +} + + .circle.green { + background: conic-gradient(#27ae60 0% 30%, #f1c40f 30% 100%); + } + + .circle.yellow { + background: conic-gradient(#f1c40f 0% 70%, #e67e22 70% 100%); + } + + .circle.red { + background: conic-gradient(#e74c3c 0% 80%, #f1c40f 80% 100%); + } + +.percent { + font-size: 28px; + font-weight: 800; + color: white; + text-shadow: 0 2px 8px rgba(0,0,0,0.4); +} + +.factor-label { + font-size: 15px; + color: #444; + line-height: 1.4; +} + +@media (max-width: 1100px) { + .container { + grid-template-columns: 1fr; + } + + .value-item { + flex-direction: column; + text-align: center; + } + + .value-item .label { + text-align: center; + min-width: auto; + } +} diff --git a/static/css/history.css b/static/css/history.css new file mode 100644 index 0000000..167ba97 --- /dev/null +++ b/static/css/history.css @@ -0,0 +1,152 @@ +.styled { + background: #c8d6e0; + padding: 16px; + border-radius: 12px; + font-weight: 600; + color: #2c3e50; + position: relative; +} + + .styled .arrow { + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + font-size: 18px; + } + +.checkbox-row { + margin: 20px 0; +} + + .checkbox-row label { + display: flex; + align-items: center; + gap: 10px; + font-size: 16px; + cursor: pointer; + } + + .checkbox-row input[type="checkbox"] { + width: 20px; + height: 20px; + accent-color: #f07a2a; + } + +.period-tabs { + display: flex; + gap: 8px; + margin: 16px 0; + flex-wrap: wrap; +} + +.tab { + padding: 10px 16px; + background: #c8d6e0; + border: none; + border-radius: 12px; + font-weight: 600; + color: #2c3e50; + cursor: pointer; + transition: all 0.25s ease; +} + +.tab.active { + background: #c8d9e5 !important; + box-shadow: none !important; + color: #2c3e50 !important; +} + +.tab:hover { + background: #d9e3eb; + transform: translateY(-2px); + box-shadow: 0 6px 12px rgba(0,0,0,0.1); +} + +.export-btn { + background: linear-gradient(180deg, #95a5a6, #7f8c8d) !important; + margin-top: 12px; +} + + .export-btn:hover { + background: linear-gradient(180deg, #a0b0b1, #95a5a6) !important; + } + +.chart-container { + background: white; + border-radius: 18px; + padding: 24px; + margin-top: 20px; + box-shadow: 0 6px 20px rgba(0,0,0,0.08); + position: relative; + height: 620px; +} + +.chart-title { + position: absolute; + top: 16px; + left: 50%; + transform: translateX(-50%); + font-size: 20px; + font-weight: 700; + color: #2c3e50; + z-index: 10; +} + +#concentrationChart { + width: 100% !important; + height: 100% !important; +} + +.clickable { + cursor: pointer; + user-select: none; +} + +.dropdown-list { + display: none; + position: absolute; + background: white; + border-radius: 12px; + box-shadow: 0 10px 25px rgba(0,0,0,0.15); + margin-top: 6px; + width: 100%; + max-width: 302.5px; + max-height: 180px; + overflow-y: auto; + z-index: 1000; + border: 1px solid #ddd; +} + +.dropdown-item { + padding: 12px 16px; + cursor: pointer; + transition: background 0.2s; +} + +.dropdown-item:hover { + background: #e3f2fd; + color: #1976d2; +} + +.address-input { + width: 100%; + padding: 14px 16px; + border: 2px solid #c8d6e0; + border-radius: 12px; + font-size: 16px; + background: #f8fbff; +} + +.address-input:focus { + outline: none; + border-color: #f07a2a; + border-color: #f07a2a; + box-shadow: 0 0 0 3px rgba(240,122,42,0.2); +} + +#substanceDropdown { + width: 100%; + max-width: 302.5px !important; + max-height: 200px; +} diff --git a/static/css/index.css b/static/css/index.css new file mode 100644 index 0000000..d3af1d8 --- /dev/null +++ b/static/css/index.css @@ -0,0 +1,83 @@ +html, body { + height: 100%; + margin: 0; + padding: 0; + overflow: hidden; + width: 100%; +} + +body { + display: flex; + flex-direction: column; + width: 100%; +} + +.header { + flex-shrink: 0; + width: 100%; +} + +.container { + display: flex; + flex: 1; + overflow: hidden; + min-height: 0; + width: 100%; + max-width: none; +} + +.panel { + flex-shrink: 0; + overflow-y: auto; + height: 100%; + min-width: fit-content; +} + +.card { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + width: 100%; +} + +.map-header { + flex-shrink: 0; + width: 100%; +} + +#map { + flex: 1; + min-height: 0; + width: 100%; +} + +.timeline-wrap { + flex-shrink: 0; + width: 100%; +} + +.panel .btn { + background: linear-gradient(180deg, #ffa64d, #f07a2a) !important; +} + + .panel .btn:hover { + background: linear-gradient(180deg, #ffb766, #f58c3b) !important; + } + +.container-fluid { + max-width: none !important; + padding-left: 0 !important; + padding-right: 0 !important; +} + +.row { + margin-left: 0 !important; + margin-right: 0 !important; +} + +.col, [class*="col-"] { + padding-left: 0 !important; + padding-right: 0 !important; +} diff --git a/static/css/login.css b/static/css/login.css new file mode 100644 index 0000000..65bfd44 --- /dev/null +++ b/static/css/login.css @@ -0,0 +1,87 @@ +.login-container { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 40px; + padding: 40px; + max-width: 1400px; + margin: 0 auto; +} + +.login-card, .register-card { + background: white; + padding: 40px; + border-radius: 20px; + box-shadow: 0 10px 30px rgba(0,0,0,0.12); +} + + .login-card h2, .register-card h2 { + text-align: center; + font-size: 22px; + margin-bottom: 30px; + color: #2c3e50; + } + +.form-group { + display: flex; + flex-direction: column; + gap: 16px; +} + +.grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px 30px; +} + +.input-field { + padding: 16px 20px; + background: #dce4ec; + border-radius: 14px; + border: none; + font-size: 16px; + color: #2c3e50; +} + +.wide { + grid-column: 1 / -1; +} + +.wide-label { + margin-top: 20px; + display: block; +} + +.captcha { + display: flex; + align-items: center; + gap: 12px; + margin: 20px 0; + font-size: 15px; +} + +.consent { + display: flex; + align-items: center; + gap: 10px; + margin: 25px 0; + font-size: 15px; +} + +.login-btn { + background: linear-gradient(180deg, #ffa64d, #f07a2a); + margin-top: 20px; +} + +.register-btn { + background: linear-gradient(180deg, #ffa64d, #f07a2a); + width: 100%; + padding: 18px; + font-size: 18px; + margin-top: 20px; +} + +@media (max-width: 1000px) { + .login-container { + grid-template-columns: 1fr; + } +} diff --git a/static/css/recommendations.css b/static/css/recommendations.css new file mode 100644 index 0000000..825e07e --- /dev/null +++ b/static/css/recommendations.css @@ -0,0 +1,105 @@ +.alerts-panel { + display: flex; + flex-direction: column; + gap: 28px; + padding: 28px 24px; +} + +.section-title { + font-size: 19px; + font-weight: 700; + color: #222; + margin-bottom: 8px; +} + +.recommendations-title { + margin-top: 12px; +} + +.alert-item { + background: white; + padding: 18px; + border-radius: 16px; + box-shadow: 0 4px 15px rgba(0,0,0,0.08); +} + +.alert-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 10px; + font-size: 17px; + font-weight: 600; +} + +.dot { + width: 14px; + height: 14px; + border-radius: 50%; + background: #f1c40f; + box-shadow: 0 2px 6px rgba(241,196,15,0.4); +} + +.substance { + flex-grow: 1; + color: #2c3e50; +} + +.percent-badge { + background: #2c3e50; + color: white; + padding: 6px 14px; + border-radius: 20px; + font-weight: 700; + font-size: 15px; +} + + .percent-badge.dark { + background: #34495e; + } + +.alert-time { + font-size: 14px; + color: #777; + margin-bottom: 4px; +} + +.alert-time-value { + font-size: 16px; + font-weight: 700; + color: #2c3e50; + text-decoration: underline; + text-decoration-color: #f07a2a; +} + +.recommendations-list { + list-style: none; + padding-left: 4px; +} + + .recommendations-list li { + position: relative; + padding-left: 28px; + margin: 18px 0; + font-size: 16px; + line-height: 1.5; + color: #333; + } + + .recommendations-list li::before { + content: "•"; + position: absolute; + left: 0; + color: #2c3e50; + font-size: 24px; + font-weight: bold; + } + +#map { + height: 720px !important; + margin-top: 16px; + border-radius: 16px; + overflow: hidden; + border: 5px solid rgba(0,0,0,0.05); + box-shadow: inset 0 4px 10px rgba(0,0,0,0.08); +} diff --git a/static/img/logo_white.png b/static/img/logo_white.png new file mode 100644 index 0000000000000000000000000000000000000000..7cf08a648a47fe9dbf81d0b2fd6172a8d99e0774 GIT binary patch literal 86103 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 z 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%0A$ZR9lJc;7^UtnK~zd?GONrdmTUU7;>y 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 literal 0 HcmV?d00001 diff --git a/static/js/enterprise.js b/static/js/enterprise.js new file mode 100644 index 0000000..dc768bd --- /dev/null +++ b/static/js/enterprise.js @@ -0,0 +1,1041 @@ +window.appConfig = { + YMAPS_API_KEY: '{{ config.YMAPS_API_KEY }}', + YMAPS_LANG: '{{ config.YMAPS_LANG }}', + YWEATHER_API_KEY: '{{ config.YWEATHER_API_KEY }}', +}; + +const CONFIG = { + MAX_DISTANCE: 10000, // Максимальное расстояние для расчета (м) + MIN_DISTANCE: 1, // Минимальное расстояние для расчета (м) + PDK_H2S: 0.008, // ПДК сероводорода (мг/м3) + MAX_ZOOM: 18 +}; + +// Коэффициенты Пасквилла-Гиффорда для разных классов устойчивости атмосферы +const STABILITY_PARAMS = { + 'A': { a: 0.22, b: 0.0001, c: -0.5, d: 0.2 }, // Очень неустойчивая + 'B': { a: 0.16, b: 0.0001, c: -0.5, d: 0.12 }, // Неустойчивая + 'C': { a: 0.11, b: 0.0001, c: -0.5, d: 0.08 }, // Слабо неустойчивая + 'D': { a: 0.08, b: 0.0001, c: -0.5, d: 0.06 }, // Нейтральная + 'E': { a: 0.06, b: 0.0001, c: -0.5, d: 0.03 }, // Слабо устойчивая + 'F': { a: 0.04, b: 0.0001, c: -0.5, d: 0.016 } // Устойчивая +}; + +// Зависимость радиуса влияния точек тепловой карты от уровня зума +const ZOOM_RADIUS_MAP = { + 0: 3, + 1: 2, + 2: 2, + 3: 2, + 4: 3, + 5: 4, + 6: 8, + 7: 12, + 8: 18, + 9: 24, + 10: 32, + 11: 50, + 12: 80, + 13: 100, + 14: 130, + 15: 160, + 16: 210, + 17: 260, + 18: 320, + 19: 400, + 20: 490, + 21: 600 +}; + +// Глобальные переменные +let map = null; +let heatmapInstance = null; +let sourcePlacemarks = []; +let windVectorPlacemarks = []; +let heatmapModuleLoaded = false; + +const state = { + sources: [], + isPlacingSource: false, + sourceToDelete: null, + isCalculating: false, + heatmapRadius: 100, // Так как значение зума по умолчанию - 13 + heatmapOpacity: 0.8, + heatmapDissipating: true, + heatmapVisible: true, + heatmapData: [], + gridStep: 70, // Постоянный шаг сетки 70 пикселей + visualizationThreshold: 0.0001, // 0.01% от ПДК + maxTotalPoints: 50000, + windSpeed: 2.4, + windDirection: 180, + windEffectStrength: 0.8, + dispersionMode: 'anisotropic', + stabilityClass: 'D', + showWindVectors: true +}; + +// Глобальные функции +window.requestDeleteSource = function(sourceId) { + state.sourceToDelete = state.sources.find(s => s.id === sourceId); + if (state.sourceToDelete) { + document.getElementById('confirm-modal').style.display = 'block'; + } +}; + +window.updateHeatmapData = async function() { + if (!map || state.isCalculating) { + updateDebugStatus("Карта не готова или идет расчет"); + return; + } + + // Проверка инициализации тепловой карты + if (!heatmapInstance) { + updateDebugStatus("Тепловая карта не инициализирована, идёт создание..."); + await initHeatmap(); + if (!heatmapInstance) { + updateDebugStatus("Не удалось создать тепловую карту"); + return; + } + } + + state.isCalculating = true; + updateDebugStatus("Расчет рассеивания загрязнений..."); + console.log("Расчет рассеивания загрязнений..."); + + document.getElementById('loading-indicator').style.display = 'block'; + document.getElementById('progress-text').textContent = '0%'; + document.getElementById('progress-fill').style.width = '0%'; + + if (state.sources.length === 0) { + if (heatmapInstance && heatmapInstance.setData) { + heatmapInstance.setData([]); + } + + document.getElementById('loading-indicator').style.display = 'none'; + state.isCalculating = false; + updateCalculationInfo(); + updateDebugStatus("Нет источников - тепловая карта очищена"); + console.log("Нет источников - тепловая карта очищена"); + return; + } + + updateWindVectors(); + + const allPoints = []; + let totalGeneratedPoints = 0; + let maxConcentration = 0; + + for (let i = 0; i < state.sources.length; i++) { + const source = state.sources[i]; + updateDebugStatus(`Расчет для источника ${i+1}/${state.sources.length}...`); + + const sourcePoints = generateDispersionPointsForSource(source); + sourcePoints.forEach(point => { + const existingPoint = allPoints.find(p => + calculateDistance(p.lat, p.lng, point.lat, point.lng) < state.gridStep / 2 + ); + + if (existingPoint) { + existingPoint.concentration += point.concentration; + existingPoint.pdkPercent = (existingPoint.concentration / CONFIG.PDK_H2S) * 100; + existingPoint.weight = Math.min(existingPoint.pdkPercent / 100, 1.0); + } else { + allPoints.push(point); + } + + if (point.concentration > maxConcentration) { + maxConcentration = point.concentration; + } + }); + + totalGeneratedPoints += sourcePoints.length; + + const progress = Math.floor((i + 1) / state.sources.length * 100); + document.getElementById('progress-text').textContent = progress + '%'; + document.getElementById('progress-fill').style.width = progress + '%'; + + if (allPoints.length > state.maxTotalPoints) { + updateDebugStatus("Достигнут лимит точек, расчёт остановлен"); + break; + } + } + + console.log(`Сгенерировано ${totalGeneratedPoints} исходных точек, объединено в ${allPoints.length} точек`); + updateDebugStatus(`Точек: ${allPoints.length}, Макс. конц.: ${maxConcentration.toExponential(3)} мг/м³`); + + const heatmapPoints = []; + let pointsAboveThreshold = 0; + + allPoints.forEach(point => { + if (point.pdkPercent >= state.visualizationThreshold * 100) { + pointsAboveThreshold++; + // Формат: [lat, lng, weight] + heatmapPoints.push([point.lat, point.lng, point.weight]); + } + }); + + console.log(`Точек выше порога: ${pointsAboveThreshold}/${allPoints.length}`); + updateDebugStatus(`Визуализируется: ${pointsAboveThreshold} точек`); + + try { + heatmapInstance.setData(heatmapPoints); + updateDebugStatus("Тепловая карта обновлена"); + console.log("Данные тепловой карты успешно обновлены"); + updateCalculationInfo(maxConcentration); + } catch (error) { + console.error("Ошибка при обновлении данных тепловой карты:", error); + updateDebugStatus("Ошибка обновления: " + error.message); + showErrorMessage("Ошибка при обновлении тепловой карты: " + error.message); + } + + setTimeout(() => { + document.getElementById('loading-indicator').style.display = 'none'; + state.isCalculating = false; + updateDebugStatus("Расчет завершен"); + console.log("Расчет рассеивания завершен"); + }, 500); +}; + +window.toggleDebugInfo = function() { + const debugInfo = document.getElementById('debug-info'); + debugInfo.style.display = debugInfo.style.display === 'block' ? 'none' : 'block'; +}; + +// Вспомогательные функции +function updateDebugStatus(status) { + const debugStatus = document.getElementById('debug-status'); + if (debugStatus) { + debugStatus.textContent = status; + } + console.log("Debug: " + status); +} + +function updateZoomDisplay() { + if (!map) return; + + const zoom = map.getZoom(); + const radiusValue = document.getElementById('radius-value'); + + // Обновление радиуса тепловой карты в зависимости от зума + const newRadius = ZOOM_RADIUS_MAP[zoom] || 100; + if (state.heatmapRadius !== newRadius) { + state.heatmapRadius = newRadius; + if (heatmapInstance && heatmapInstance.options && heatmapInstance.options.set) { + heatmapInstance.options.set('radius', state.heatmapRadius); + console.log(`Радиус тепловой карты обновлен: ${state.heatmapRadius}px (зум: ${zoom})`); + } + } + + if (radiusValue) { + radiusValue.textContent = `Радиус: ${state.heatmapRadius} пикселей`; + } +} + +function updateWindVectors() { + if (!map || !state.showWindVectors) { + if (windVectorPlacemarks.length > 0) { + windVectorPlacemarks.forEach(placemark => { + map.geoObjects.remove(placemark); + }); + windVectorPlacemarks = []; + } + return; + } + + if (windVectorPlacemarks.length > 0) { + windVectorPlacemarks.forEach(placemark => { + map.geoObjects.remove(placemark); + }); + windVectorPlacemarks = []; + } + + // Добавление векторов ветра для каждого источника + state.sources.forEach(source => { + const windDirectionRad = (state.windDirection * Math.PI) / 180; + const vectorLength = 1000; // метров + const dx = vectorLength * Math.sin(windDirectionRad); + const dy = vectorLength * Math.cos(windDirectionRad); + + const dLat = dy / 111000; // 1 градус широты ~ 111 км + const dLng = dx / (111000 * Math.cos(source.lat * Math.PI / 180)); + const endLat = source.lat + dLat; + const endLng = source.lng + dLng; + + const windVector = new ymaps.Polyline([ + [source.lat, source.lng], + [endLat, endLng] + ], { + balloonContent: `Ветер: ${state.windSpeed} м/с, направление: ${state.windDirection}°` + }, { + strokeColor: '#3498db', + strokeWidth: 2, + strokeOpacity: 0.7, + zIndex: 400 + }); + + const arrowSize = 0.0005; + const arrowAngle = 30 * Math.PI / 180; + const arrowPoint1Lat = endLat - arrowSize * Math.cos(windDirectionRad - arrowAngle); + const arrowPoint1Lng = endLng - arrowSize * Math.sin(windDirectionRad - arrowAngle); + const arrowPoint2Lat = endLat - arrowSize * Math.cos(windDirectionRad + arrowAngle); + const arrowPoint2Lng = endLng - arrowSize * Math.sin(windDirectionRad + arrowAngle); + + const windArrow = new ymaps.Polygon([ + [ + [endLat, endLng], + [arrowPoint1Lat, arrowPoint1Lng], + [arrowPoint2Lat, arrowPoint2Lng], + [endLat, endLng] + ] + ], { + balloonContent: `Направление ветра: ${state.windDirection}°` + }, { + fillColor: '#3498db', + strokeColor: '#3498db', + strokeWidth: 1, + strokeOpacity: 0.7, + fillOpacity: 0.7, + zIndex: 401 + }); + + map.geoObjects.add(windVector); + map.geoObjects.add(windArrow); + windVectorPlacemarks.push(windVector, windArrow); + }); +} + +function updateWindDisplay() { + const windDirectionArrow = document.getElementById('wind-direction-arrow'); + const windDirectionLabel = document.getElementById('wind-direction-label'); + + if (windDirectionArrow) { + windDirectionArrow.style.transform = `translateX(-50%) rotate(${state.windDirection}deg)`; + } + + if (windDirectionLabel) { + windDirectionLabel.textContent = `${state.windDirection}°`; + } + + const windSpeedValue = document.getElementById('wind-speed-value'); + if (windSpeedValue) { + windSpeedValue.textContent = `${state.windSpeed.toFixed(1)} м/с`; + } + + const windDirectionValue = document.getElementById('wind-direction-value'); + if (windDirectionValue) { + const directionName = getWindDirectionName(state.windDirection); + windDirectionValue.textContent = `${state.windDirection}° (${directionName})`; + } + + const windSpeedDisplay = document.getElementById('wind-speed-display'); + const windDirectionDisplay = document.getElementById('wind-direction-display'); + const stabilityClassDisplay = document.getElementById('stability-class-display'); + + if (windSpeedDisplay) windSpeedDisplay.textContent = state.windSpeed.toFixed(1); + if (windDirectionDisplay) windDirectionDisplay.textContent = getWindDirectionName(state.windDirection); + if (stabilityClassDisplay) stabilityClassDisplay.textContent = state.stabilityClass; +} + +function updateWindVisualization() { + updateWindDisplay(); + + if (state.sources.length > 0) { + updateHeatmapData(); + } + + const windInfo = `Ветер: ${state.windSpeed.toFixed(1)} м/с, направление: ${state.windDirection}°\n` + + `Влияние ветра: ${Math.round(state.windEffectStrength * 100)}%\n` + + `Вытягивание шлейфа: ${(1.0 + (state.windSpeed * 0.3) * state.windEffectStrength).toFixed(2)}x`; + + updateDebugStatus(windInfo); +} + +function getWindDirectionName(degrees) { + const directions = [ + { name: 'Север', min: 337.5, max: 22.5 }, + { name: 'Северо-восток', min: 22.5, max: 67.5 }, + { name: 'Восток', min: 67.5, max: 112.5 }, + { name: 'Юго-восток', min: 112.5, max: 157.5 }, + { name: 'Юг', min: 157.5, max: 202.5 }, + { name: 'Юго-запад', min: 202.5, max: 247.5 }, + { name: 'Запад', min: 247.5, max: 292.5 }, + { name: 'Северо-запад', min: 292.5, max: 337.5 } + ]; + + const normalizedDegrees = degrees % 360; + for (const direction of directions) { + if (direction.min > direction.max) { + // Для севера (охватывает 337.5-360 и 0-22.5) + if (normalizedDegrees >= direction.min || normalizedDegrees <= direction.max) { + return direction.name; + } + } else if (normalizedDegrees >= direction.min && normalizedDegrees <= direction.max) { + return direction.name; + } + } + return 'Не определено'; +} + +window.resetWindSettings = function() { + state.windSpeed = 2.4; + state.windDirection = 180; + state.windEffectStrength = 0.8; + state.dispersionMode = 'anisotropic'; + + const windSpeed = document.getElementById('wind-speed'); + const windDirection = document.getElementById('wind-direction'); + + if (windSpeed) windSpeed.value = state.windSpeed; + if (windDirection) windDirection.value = state.windDirection; + + updateWindDisplay(); + updateDebugStatus("Настройки ветра сброшены"); + + if (state.sources.length > 0) { + updateHeatmapData(); + } +}; + +function calculateGaussianConcentration(source, distance, angle) { + if (distance < CONFIG.MIN_DISTANCE || distance > CONFIG.MAX_DISTANCE) { + return 0; + } + + // Параметры для текущего класса устойчивости + const params = STABILITY_PARAMS[state.stabilityClass] || STABILITY_PARAMS['D']; + + // Базовые дисперсии (sigma_y и sigma_z) по модели Пасквилла-Гиффорда + const baseSigmaY = params.a * distance * Math.pow(1 + params.b * distance, params.c); + const baseSigmaZ = params.d * distance; + + if (baseSigmaY <= 0 || baseSigmaZ <= 0) return 0; + + let effectiveSigmaY = baseSigmaY; + let effectiveSigmaZ = baseSigmaZ; + const windDirectionRad = (state.windDirection * Math.PI) / 180; + let angleDiff = angle - windDirectionRad; + + // Нормализация разницы углов в диапазон [-pi, pi] + angleDiff = ((angleDiff + Math.PI) % (2 * Math.PI)) - Math.PI; + + // Коэффициент вытягивания по ветру (линейно зависит от скорости ветра) + const windStretchFactor = 1.0 + (state.windSpeed * 0.3) * state.windEffectStrength; + + // Коэффициент сжатия поперек ветра + const crossWindCompressionFactor = 1.0 / (1.0 + (state.windSpeed * 0.2) * state.windEffectStrength); + + if (state.dispersionMode === 'anisotropic' || state.dispersionMode === 'debug') { + // Влияние угла относительно направления ветра + const cosAngleDiff = Math.cos(angleDiff); + const absAngleDiff = Math.abs(angleDiff); + + if (state.dispersionMode === 'anisotropic') { + // Для точек по направлению ветра - вытягивание + // Для точек поперек ветра - сжатие + const alongWindFactor = windStretchFactor; + + // Вращение поля рассеивания + // Чем больше скорость ветра, тем сильнее поворот "шлейфа" + const rotationStrength = Math.min(state.windSpeed * 0.05 * state.windEffectStrength, 0.8); + const rotatedAngle = angle - rotationStrength * Math.sin(angleDiff); + + // Пересчет sigma_y с учетом вращения и вытягивания по ветру + // Сильное вытягивание вдоль направления ветра, сильное сжатие поперек + effectiveSigmaY = baseSigmaY * Math.sqrt( + Math.pow(alongWindFactor * Math.cos(rotatedAngle - windDirectionRad), 2) + + Math.pow(crossWindCompressionFactor * 0.3 * Math.sin(rotatedAngle - windDirectionRad), 2) + ); + + // sigma_z также может зависеть от ветра + effectiveSigmaZ = baseSigmaZ * (1.0 + state.windSpeed * 0.02 * state.windEffectStrength); + } + } + + // Гауссова модель рассеивания + const Q = source.emissionRate * 1000; // Перевод г/с в мг/с + const u = Math.max(state.windSpeed, 0.1); // Скорость ветра (м/с), минимум 0.1 + + // Расстояние вдоль направления ветра (x) и поперек (y) + const x = distance * Math.cos(angleDiff); // По ветру (+), против ветра (-) + const y = distance * Math.sin(angleDiff); // Поперек ветра + + // Расчет концентрации с учетом ветра + const concentration = (Q / (2 * Math.PI * u * effectiveSigmaY * effectiveSigmaZ)) * + Math.exp(-0.5 * Math.pow(y / effectiveSigmaY, 2)) * + Math.exp(-0.5 * Math.pow(source.height / effectiveSigmaZ, 2)) * + Math.exp(-0.1 * Math.abs(x) / (effectiveSigmaY * u)); // Дополнительное затухание против ветра + + // Дополнительный множитель для направления по ветру + const windDirectionFactor = x > 0 ? + 1.0 + (windStretchFactor - 1.0) * (1.0 - Math.exp(-Math.abs(x) / 500)) : // По ветру - усиление + 0.1 + 0.2 * Math.exp(-Math.abs(x) / 200); // Против ветра - ослабление + + return Math.max(concentration * windDirectionFactor, 0); +} + +function updateSourceMarkers() { + if (!map) return; + + if (sourcePlacemarks.length > 0) { + sourcePlacemarks.forEach(placemark => { + map.geoObjects.remove(placemark); + }); + sourcePlacemarks = []; + } + + state.sources.forEach(source => { + const placemark = new ymaps.Placemark( + [source.lat, source.lng], + { + balloonContent: `${source.name}
Высота: ${source.height} м
Выброс: ${source.emissionRate} г/с` + }, + { + preset: 'islands#redIcon', + iconColor: '#e74c3c', + draggable: false, + zIndex: 300 + } + ); + + map.geoObjects.add(placemark); + sourcePlacemarks.push(placemark); + }); + + console.log(`Добавлено ${sourcePlacemarks.length} меток на карту`); +} + +function calculateDistance(lat1, lon1, lat2, lon2) { + const R = 6371000; // Радиус Земли в метрах + const dLat = (lat2 - lat1) * Math.PI / 180; + const dLon = (lon2 - lon1) * Math.PI / 180; + const a = Math.sin(dLat/2) * Math.sin(dLat/2) + + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * + Math.sin(dLon/2) * Math.sin(dLon/2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); + return R * c; +} + +function generateDispersionPointsForSource(source) { + const points = []; + const centerLat = source.lat; + const centerLng = source.lng; + const maxTestRadius = 5000; + let maxRadius = 0; + + const directions = state.dispersionMode === 'isotropic' ? 16 : 8; + for (let dir = 0; dir < directions; dir++) { + const angle = (dir * 2 * Math.PI) / directions; + let testDistance = state.gridStep; + + while (testDistance <= maxTestRadius) { + const concentration = calculateGaussianConcentration(source, testDistance, angle); + const pdkPercent = (concentration / CONFIG.PDK_H2S) * 100; + + if (pdkPercent >= state.visualizationThreshold * 100) { + if (testDistance > maxRadius) { + maxRadius = testDistance; + } + testDistance += state.gridStep; + } else { + break; + } + } + } + + if (maxRadius === 0) { + maxRadius = state.gridStep * 2; + } + + // Сетка точек с шагом 70 метров + const gridSize = Math.ceil(maxRadius / state.gridStep); + + for (let i = -gridSize; i <= gridSize; i++) { + for (let j = -gridSize; j <= gridSize; j++) { + const dx = i * state.gridStep; + const dy = j * state.gridStep; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance > maxRadius || distance === 0) continue; + + const dLat = dy / 111000; + const dLng = dx / (111000 * Math.cos(centerLat * Math.PI / 180)); + const pointLat = centerLat + dLat; + const pointLng = centerLng + dLng; + const angle = Math.atan2(dy, dx); + const concentration = calculateGaussianConcentration(source, distance, angle); + const pdkPercent = (concentration / CONFIG.PDK_H2S) * 100; + + if (pdkPercent >= state.visualizationThreshold * 100) { + const normalizedValue = Math.min(pdkPercent / 100, 1.0); + const weight = Math.max(normalizedValue, 0.01); + + points.push({ + lat: pointLat, + lng: pointLng, + weight: weight, + concentration: concentration, + pdkPercent: pdkPercent, + angle: angle, + distance: distance + }); + } + } + } + + const centerConcentration = calculateGaussianConcentration(source, 1, 0); + const centerPdkPercent = (centerConcentration / CONFIG.PDK_H2S) * 100; + if (centerPdkPercent >= state.visualizationThreshold * 100) { + points.push({ + lat: centerLat, + lng: centerLng, + weight: Math.min(centerPdkPercent / 100, 1.0), + concentration: centerConcentration, + pdkPercent: centerPdkPercent, + angle: 0, + distance: 0 + }); + } + + return points; +} + +function loadHeatmapModule() { + return new Promise((resolve, reject) => { + if (typeof ymaps.modules === 'undefined') { + reject(new Error("ymaps.modules не определен")); + return; + } + + updateDebugStatus("Загрузка модуля тепловых карт..."); + + ymaps.modules.require(['Heatmap'], function (Heatmap) { + updateDebugStatus("Модуль тепловых карт загружен"); + heatmapModuleLoaded = true; + resolve(Heatmap); + }, function (err) { + updateDebugStatus("Ошибка загрузки тепловых карт"); + reject(new Error("Не удалось загрузить модуль тепловых карт: " + err)); + }); + }); +} + +async function initHeatmap() { + if (!map) { + console.error("Карта не инициализирована для создания тепловой карты"); + updateDebugStatus("Карта не инициализирована"); + return; + } + + try { + updateDebugStatus("Инициализация тепловой карты..."); + + const Heatmap = await loadHeatmapModule(); + const currentZoom = map.getZoom(); + const initialRadius = ZOOM_RADIUS_MAP[currentZoom] || 100; + state.heatmapRadius = initialRadius; + + heatmapInstance = new Heatmap([], { + radius: state.heatmapRadius, + dissipating: true, + opacity: state.heatmapOpacity, + intensityOfMidpoint: 0.2, + gradient: { + 0.1: 'rgba(0, 255, 0, 0.3)', // < 20% ПДК + 0.2: 'rgba(127, 255, 0, 0.5)', // 20-40% ПДК + 0.4: 'rgba(255, 255, 0, 0.7)', // 40-60% ПДК + 0.6: 'rgba(255, 165, 0, 0.8)', // 60-80% ПДК + 0.8: 'rgba(255, 0, 0, 0.9)', // 80-100% ПДК + 1.0: 'rgba(255, 0, 0, 1.0)' // > 100% ПДК + } + }); + + heatmapInstance.setMap(map); + updateDebugStatus("Тепловая карта создана"); + console.log("Тепловая Яндекс.Карта инициализирована"); + updateHeatmapData(); + + } catch (error) { + console.error("Ошибка при инициализации тепловой карты:", error); + updateDebugStatus("Ошибка: " + error.message); + showErrorMessage("Ошибка при создании тепловой карты: " + error.message); + } +} + +function initMap() { + try { + document.getElementById('loading-indicator').style.display = 'block'; + document.getElementById('progress-text').textContent = 'Загрузка карты...'; + document.getElementById('progress-fill').style.width = '30%'; + updateDebugStatus("Инициализация карты..."); + + map = new ymaps.Map('map', { + center: [55.7558, 37.6173], + zoom: 13, + controls: ['zoomControl', 'typeSelector', 'fullscreenControl'] + }); + + map.events.add('boundschange', function(e) { + updateZoomDisplay(); + }); + + document.getElementById('progress-fill').style.width = '100%'; + document.getElementById('progress-text').textContent = 'Карта загружена'; + updateDebugStatus("Карта загружена"); + + setTimeout(() => { + document.getElementById('loading-indicator').style.display = 'none'; + }, 500); + + console.log("Яндекс.Карта инициализирована"); + setTimeout(updateZoomDisplay, 100); + + init(); + setTimeout(() => { + initHeatmap(); + }, 1000); + + } catch (error) { + console.error("Ошибка при инициализации карты:", error); + document.getElementById('loading-indicator').style.display = 'none'; + document.getElementById('error-message').style.display = 'block'; + updateDebugStatus("Ошибка: " + error.message); + showErrorMessage("Ошибка загрузки карты: " + error.message); + } +} + +function updateCalculationInfo(maxConcentration = 0) { + const calculationResults = document.getElementById('calculation-results'); + if (!calculationResults) return; + + if (state.sources.length === 0) { + calculationResults.innerHTML = '

Добавьте источники для расчета

'; + return; + } + + let totalEmission = 0; + let maxSourceConcentration = 0; + + state.sources.forEach(source => { + totalEmission += source.emissionRate; + const concentration = calculateGaussianConcentration(source, 1, 0); + if (concentration > maxSourceConcentration) { + maxSourceConcentration = concentration; + } + }); + + const avgEmission = totalEmission / state.sources.length; + const maxPdkPercent = ((maxConcentration || maxSourceConcentration) / CONFIG.PDK_H2S) * 100; + + let dispersionModeName = ''; + switch(state.dispersionMode) { + case 'anisotropic': dispersionModeName = 'Анизотропный (ветер)'; break; + default: dispersionModeName = state.dispersionMode; + } + + let html = ` +
+ Гауссова модель рассеивания активна +
+
+

Параметры расчета:

+

Класс устойчивости: ${state.stabilityClass}

+

Режим рассеивания: ${dispersionModeName}

+

Ветер: ${state.windSpeed.toFixed(1)} м/с, направление: ${state.windDirection}° (${getWindDirectionName(state.windDirection)})

+

Влияние ветра: ${Math.round(state.windEffectStrength * 100)}%

+

Шаг сетки: ${state.gridStep} м (постоянный)

+

Порог визуализации: ${(state.visualizationThreshold * 100).toFixed(4)}% ПДК (постоянный)

+

Радиус влияния: ${state.heatmapRadius}px (зависит от зума)

+

Текущий масштаб: ${map ? map.getZoom() : 'N/A'}

+
+

Результаты:

+

Количество источников: ${state.sources.length}

+

Суммарный выброс: ${totalEmission.toFixed(2)} г/с

+

Средний выброс: ${avgEmission.toFixed(2)} г/с

+

Макс. концентрация: ${(maxConcentration || maxSourceConcentration).toExponential(3)} мг/м³

+

Макс. % ПДК: ${maxPdkPercent.toFixed(4)}%

+ `; + + if (maxPdkPercent > 100) { + html += `

Превышение ПДК в ${(maxPdkPercent/100).toFixed(2)} раз!

`; + } else if (maxPdkPercent > 80) { + html += `

Концентрация близка к ПДК

`; + } else if (maxPdkPercent > 0) { + html += `

Концентрация в допустимых пределах

`; + } + + html += `

Модель: гауссово рассеивание с коэффициентами Пасквилла-Гиффорда и учетом ветра

`; + html += `
`; + calculationResults.innerHTML = html; +} + +function showErrorMessage(message) { + console.error("Сообщение об ошибке:", message); + const errorMessage = document.getElementById('error-message'); + if (errorMessage) { + errorMessage.innerHTML = ` +

Ошибка

+

${message}

+ + `; + errorMessage.style.display = 'block'; + } + updateDebugStatus("Ошибка: " + message); +} + +async function apiCall(url, options = {}) { + try { + const response = await fetch(url, { + headers: { 'Content-Type': 'application/json', ...options.headers }, + ...options + }); + return await response.json(); + } catch (error) { + console.error('API Error:', error); + return null; + } +} + +async function loadSourcesFromDB() { + const sources = await apiCall('/api/sources'); + if (sources) { + state.sources = sources.map(source => ({ + ...source, + id: source.id, + lat: source.latitude, + lng: source.longitude, + emissionRate: source.emission_rate, + height: source.height + })); + updateSourcesList(); + updateSourceMarkers(); + + if (state.sources.length > 0) { + setTimeout(() => { + updateHeatmapData(); + }, 1500); + } + } +} + +async function addSourceToDB(sourceData) { + return await apiCall('/api/sources', { + method: 'POST', + body: JSON.stringify(sourceData) + }); +} + +async function deleteSourceFromDB(sourceId) { + return await apiCall(`/api/sources/${sourceId}`, { method: 'DELETE' }); +} + +function updateSourcesList() { + const sourcesList = document.getElementById('sources-list'); + if (!sourcesList) return; + + sourcesList.innerHTML = ''; + state.sources.forEach(source => { + const sourceEl = document.createElement('div'); + sourceEl.className = 'enterprise-source-item'; + sourceEl.innerHTML = ` +
${source.name}
${source.emissionRate} г/с
+ `; + sourcesList.appendChild(sourceEl); + }); +} + +function init() { + setupEventListeners(); + loadSourcesFromDB().then(() => { + console.log("Источники выбросов загружены"); + updateDebugStatus("Источники выбросов загружены"); + }); +} + +function setupEventListeners() { + const heatmapToggle = document.getElementById('heatmap-toggle'); + if (heatmapToggle) { + heatmapToggle.addEventListener('change', function() { + state.heatmapVisible = this.checked; + if (heatmapInstance && heatmapInstance.options && heatmapInstance.options.set) { + heatmapInstance.options.set('visible', state.heatmapVisible); + updateDebugStatus(`Видимость: ${state.heatmapVisible}`); + } + if (state.heatmapVisible && state.sources.length > 0) { + updateHeatmapData(); + } + }); + } + + const windSpeed = document.getElementById('wind-speed'); + if (windSpeed) { + windSpeed.addEventListener('input', function() { + state.windSpeed = parseFloat(this.value); + updateWindVisualization(); + }); + } + + const windDirection = document.getElementById('wind-direction'); + if (windDirection) { + windDirection.addEventListener('input', function() { + state.windDirection = parseInt(this.value); + updateWindVisualization(); + }); + } + + const showWindVectors = document.getElementById('show-wind-vectors'); + if (showWindVectors) { + showWindVectors.addEventListener('change', function() { + state.showWindVectors = this.checked; + updateDebugStatus(`Векторы ветра: ${state.showWindVectors ? 'вкл' : 'выкл'}`); + if (state.sources.length > 0) { + updateHeatmapData(); + } + }); + } + + const resetWindBtn = document.getElementById('reset-wind-btn'); + if (resetWindBtn) { + resetWindBtn.addEventListener('click', resetWindSettings); + } + + const addSourceBtn = document.getElementById('add-source-btn'); + if (addSourceBtn) { + addSourceBtn.addEventListener('click', enableSourcePlacement); + } + + const updateHeatmapBtn = document.getElementById('update-heatmap-btn'); + if (updateHeatmapBtn) { + updateHeatmapBtn.addEventListener('click', updateHeatmapData); + } + + const toggleDebugBtn = document.getElementById('toggle-debug-btn'); + if (toggleDebugBtn) { + toggleDebugBtn.addEventListener('click', toggleDebugInfo); + } + + const testWindEffectBtn = document.getElementById('test-wind-effect'); + if (testWindEffectBtn) { + testWindEffectBtn.addEventListener('click', function() { + const originalWindSpeed = state.windSpeed; + const originalWindDirection = state.windDirection; + + let angle = 0; + const interval = setInterval(() => { + state.windDirection = angle % 360; + updateWindVisualization(); + angle += 10; + + if (angle >= 360) { + clearInterval(interval); + state.windSpeed = originalWindSpeed; + state.windDirection = originalWindDirection; + setTimeout(() => updateWindVisualization(), 1000); + } + }, 100); + }); + } + + document.querySelectorAll('.close').forEach(el => el.addEventListener('click', + () => { document.getElementById('confirm-modal').style.display = 'none'; })); + + const cancelDelete = document.getElementById('cancel-delete'); + if (cancelDelete) { + cancelDelete.addEventListener('click', () => { + document.getElementById('confirm-modal').style.display = 'none'; + }); + } + + const confirmDelete = document.getElementById('confirm-delete'); + if (confirmDelete) { + confirmDelete.addEventListener('click', confirmDeleteSource); + } + + updateWindDisplay(); +} + +async function addSource(lat, lng, height, emissionRate) { + const emission = parseFloat(emissionRate.toString().replace(',', '.')); + + const result = await addSourceToDB({ + name: `Источник ${Date.now() % 10000}`, + type: 'point', + latitude: lat, + longitude: lng, + height: height, + emission_rate: emission + }); + if (result && result.success) { + await loadSourcesFromDB(); + updateHeatmapData(); + } +} + +function enableSourcePlacement() { + if (state.isPlacingSource) { + disableSourcePlacement(); + return; + } + state.isPlacingSource = true; + + const addSourceBtn = document.getElementById('add-source-btn'); + if (addSourceBtn) { + addSourceBtn.textContent = 'Отменить (клик на карте)'; + addSourceBtn.style.background = '#e74c3c'; + } + map.events.add('click', handleMapClickForPlacement); + + const mapContainer = document.getElementById('map'); + if (mapContainer) { + mapContainer.style.cursor = 'crosshair'; + } +} + +function disableSourcePlacement() { + state.isPlacingSource = false; + const addSourceBtn = document.getElementById('add-source-btn'); + if (addSourceBtn) { + addSourceBtn.textContent = 'Добавить источник'; + addSourceBtn.style.background = ''; + } + map.events.remove('click', handleMapClickForPlacement); + + const mapContainer = document.getElementById('map'); + if (mapContainer) { + mapContainer.style.cursor = ''; + } +} + +async function handleMapClickForPlacement(e) { + const coords = e.get('coords'); + const sourceHeight = document.getElementById('source-height'); + const emissionRate = document.getElementById('emission-rate'); + + if (sourceHeight && emissionRate) { + await addSource( + coords[0], + coords[1], + parseInt(sourceHeight.value), + emissionRate.value + ); + } + disableSourcePlacement(); +} + +async function confirmDeleteSource() { + if (!state.sourceToDelete) return; + + const result = await deleteSourceFromDB(state.sourceToDelete.id); + if (result && result.success) { + await loadSourcesFromDB(); + updateHeatmapData(); + } + + const confirmModal = document.getElementById('confirm-modal'); + if (confirmModal) { + confirmModal.style.display = 'none'; + } + state.sourceToDelete = null; +} + +ymaps.ready(initMap); diff --git a/static/js/history.js b/static/js/history.js new file mode 100644 index 0000000..ae456d7 --- /dev/null +++ b/static/js/history.js @@ -0,0 +1,110 @@ +document.addEventListener('DOMContentLoaded', () => { + // Выбор города из выпадающего списка для дальнейшего составления прогноза + const citySelect = document.getElementById('citySelect'); + const cityDropdown = document.getElementById('cityDropdown'); + + if (citySelect && cityDropdown) { + citySelect.addEventListener('click', () => { + const isOpen = cityDropdown.style.display === 'block'; + cityDropdown.style.display = isOpen ? 'none' : 'block'; + }); + + cityDropdown.querySelectorAll('.dropdown-item').forEach(item => { + item.addEventListener('click', () => { + const text = item.textContent.trim(); + citySelect.innerHTML = text + ' '; + cityDropdown.style.display = 'none'; + console.log('Выбран город:', text); + }); + }); + + // Закрытие выпадающего списка при клике в другом месте + document.addEventListener('click', (e) => { + if (!citySelect.contains(e.target) && !cityDropdown.contains(e.target)) { + cityDropdown.style.display = 'none'; + } + }); + } + + const checkbox = document.getElementById('manualAddressCheckbox'); + const inputBlock = document.getElementById('manualAddressInput'); + const addressField = document.getElementById('addressField'); + + if (checkbox && inputBlock) { + checkbox.addEventListener('change', () => { + if (checkbox.checked) { + inputBlock.style.display = 'block'; + addressField.focus(); + } else { + inputBlock.style.display = 'none'; + addressField.value = ''; + } + }); + } + + if (addressField) { + addressField.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + console.log('Введён адрес:', addressField.value); + // Здесь будет запрос к геокодеру + } + }); + } + + // Выбор вещества из выпадающего списка для дальнейшего составления прогноза + const substanceSelect = document.getElementById('substanceSelect'); + const substanceDropdown = document.getElementById('substanceDropdown'); + + if (substanceSelect && substanceDropdown) { + substanceSelect.addEventListener('click', () => { + const isOpen = substanceDropdown.style.display === 'block'; + substanceDropdown.style.display = isOpen ? 'none' : 'block'; + }); + + substanceDropdown.querySelectorAll('.dropdown-item').forEach(item => { + item.addEventListener('click', () => { + const text = item.textContent.trim(); + substanceSelect.innerHTML = text + ' '; + substanceDropdown.style.display = 'none'; + console.log('Выбрано вещество:', text); + }); + }); + + // Закрытие выпадающего списка при клике в другом месте + document.addEventListener('click', (e) => { + if (!substanceSelect.contains(e.target) && !substanceDropdown.contains(e.target)) { + substanceDropdown.style.display = 'none'; + } + }); + } + + const ctx = document.getElementById('concentrationChart').getContext('2d'); + + new Chart(ctx, { + type: 'line', + data: { + labels: ['Дек', 'Янв', 'Фев', 'Мар', 'Апр', 'Май', 'Июн', 'Июл', 'Авг'], + datasets: [{ + data: [0.0033, 0.0028, 0.0025, 0.0024, 0.0026, 0.0027, 0.0029, 0.0031, 0.0030], + borderColor: '#3498db', + backgroundColor: 'rgba(52, 152, 219, 0.1)', + fill: true, + tension: 0.4, + pointRadius: 5, + pointBackgroundColor: '#3498db' + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { + y: { + beginAtZero: true, + max: 0.01, + ticks: { stepSize: 0.001, callback: value => value.toFixed(6) } + } + } + } + }); +}); diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..c311daa --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,1060 @@ +window.appConfig = { + YMAPS_API_KEY: '{{ config.YMAPS_API_KEY }}', + YMAPS_LANG: '{{ config.YMAPS_LANG }}', + YWEATHER_API_KEY: '{{ config.YWEATHER_API_KEY }}', +}; + +function qs(sel, root = document) { return root.querySelector(sel) } +function qsa(sel, root = document) { return Array.from(root.querySelectorAll(sel)) } + +let map, gridCollection = null; +const CENTER = [55.75, 37.62]; +const GRID_STEP = 0.015; + +// Объектная модель документа +const slider = document.createElement('input'); +slider.type = 'range'; +slider.min = 0; slider.max = 100; slider.value = 50; slider.className = 'slider'; + +document.addEventListener('DOMContentLoaded', () => { + // Инициализация слайдеров проходит, только если они есть на странице + const timelineWraps = qsa('.timeline-wrap'); + if (timelineWraps.length > 0) { + timelineWraps.forEach(wrap => { + const tl = wrap.querySelector('.timeline'); + if (tl) { + tl.appendChild(slider.cloneNode(true)); + } + }); + attachControls(); + } + + // Инициализация карты проходит, только если её контейнер существует + const mapContainer = document.getElementById('map'); + if (mapContainer) { + const path = window.location.pathname; + if (path === '/' || path === '/enterprise') { + initMonitoringMap(); + } else { + initYandexMap(); + } + } +}); + +function attachControls() { + const timeLabels = qsa('.time-label'); + if (timeLabels.length > 0) { + timeLabels.forEach(el => { + if (el) el.textContent = 'Сейчас'; + }); + } + + qsa('.timeline-wrap').forEach((wrap, i) => { + const sl = wrap.querySelector('.slider'); + const handle = wrap.querySelector('.handle'); + const timeLabel = wrap.querySelector('.time-label'); + + // Проверка существования всех необходимых элементов + if (!sl || !handle || !timeLabel) return; + + function updateHandle() { + const pct = sl.value; + handle.style.left = pct + '%'; + const base = new Date(); + base.setMinutes(Math.round((parseInt(pct) / 100) * 180) - 90 + base.getMinutes()); + const hh = base.getHours().toString().padStart(2, '0'); + const mm = base.getMinutes().toString().padStart(2, '0'); + timeLabel.textContent = `${hh}:${mm}`; + if (typeof updateGridForValue === 'function') updateGridForValue(parseInt(sl.value)); + } + sl.addEventListener('input', updateHandle); + updateHandle(); + }); + + // Обработчики элементов подключаются, только если они существуют + const addSourceBtns = qsa('.add-source-btn'); + if (addSourceBtns.length > 0) { + addSourceBtns.forEach(btn => { + btn.addEventListener('click', () => { + const heightInput = document.querySelector('.input-height'); + const qtyInput = document.querySelector('.input-qty'); + const h = heightInput ? parseFloat(heightInput.value) || 40 : 40; + const q = qtyInput ? parseFloat(qtyInput.value) || 3.7 : 3.7; + if (typeof addSourceMarker === 'function') { + addSourceMarker(CENTER, { height: h, qty: q }); + } + }); + }); + } + + const removeSourceBtns = qsa('.remove-source-btn'); + if (removeSourceBtns.length > 0) { + removeSourceBtns.forEach(btn => { + btn.addEventListener('click', () => { + if (typeof removeAllSources === 'function') { + removeAllSources(); + } + }); + }); + } +} + +function initYandexMap() { + if (typeof ymaps === 'undefined') return; + + ymaps.ready(() => { + const mapContainer = document.getElementById('map'); + if (!mapContainer) return; + + map = new ymaps.Map('map', { + center: [55.7558, 37.6176], + zoom: 10, + controls: ['zoomControl', 'fullscreenControl'] + }); + + if (window.location.pathname === '/enterprise') { + let clickHandler = (e) => { + const coords = e.get('coords'); + const heightInput = document.querySelector('.input-height'); + const qtyInput = document.querySelector('.input-qty'); + const height = heightInput ? parseFloat(heightInput.value) || 40 : 40; + const emission = qtyInput ? parseFloat(qtyInput.value) || 3.7 : 3.7; + + addEnterpriseSource(coords, height, emission); + }; + map.events.add('click', clickHandler); + } + + if (typeof loadSourcesForEnterprise === 'function') { + loadSourcesForEnterprise(); + } + }); +} + +function onYMapsReady() { + const mapContainer = document.getElementById('map'); + if (!mapContainer || typeof ymaps === 'undefined') return; + + map = new ymaps.Map('map', { + center: CENTER, + zoom: 10, + controls: ['zoomControl', 'typeSelector', 'fullscreenControl', 'geolocationControl'] + }, { suppressMapOpenBlock: true }); + + const placemark = new ymaps.Placemark(CENTER, { balloonContent: 'Пример источника' }, { preset: 'islands#redDotIcon' }); + map.geoObjects.add(placemark); +} + +function drawGridAroundCenter(center, step = 0.02, radiusCount = 10) { + if (!ymaps || !map || !gridCollection) return; + gridCollection.removeAll(); + const [latC, lonC] = center; + const half = step * radiusCount; + const latStart = latC - half; + const lonStart = lonC - half; + const rows = Math.round((half * 2) / step); + const cols = rows; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const lat1 = latStart + r * step; + const lon1 = lonStart + c * step; + const lat2 = lat1 + step; + const lon2 = lon1 + step; + const dist = Math.hypot((lat1 - latC), (lon1 - lonC)); + const intensity = Math.max(0, 1 - dist / (half * 1.2)); + const color = colorForIntensity(intensity, 50); + const poly = new ymaps.Polygon([[ + [lat1, lon1], + [lat1, lon2], + [lat2, lon2], + [lat2, lon1] + ]], {}, { + fillColor: color, + strokeColor: 'rgba(0,0,0,0.02)', + strokeWidth: 1, + interactivityModel: 'default#opaque' + }); + gridCollection.add(poly); + } + } +} + +function updateGridForValue(value) { + if (!gridCollection) return; + const total = gridCollection.getLength(); + for (let i = 0; i < total; i++) { + const g = gridCollection.get(i); + const baseIntensity = 1 - (i / total); + const newColor = colorForIntensity(baseIntensity, value); + g.options.set('fillColor', newColor); + } +} + +function colorForIntensity(intensity, sliderVal) { + const s = sliderVal / 100; + const hue = Math.round(120 * (1 - (intensity * s))); + const saturation = 80; + const light = 50 - Math.round(20 * (1 - intensity)); + const alpha = 0.45 * (0.6 + 0.4 * intensity); + const rgb = hslToRgb(hue / 360, saturation / 100, light / 100); + return `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${alpha})`; +} + +function hslToRgb(h, s, l) { + let r, g, b; + if (s == 0) r = g = b = l; + else { + const hue2rgb = (p, q, t) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + r = hue2rgb(p, q, h + 1 / 3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1 / 3); + } + return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; +} + +// Специфичные для вкладки "Режим предприятия" функции +let enterpriseSources = []; + +async function addEnterpriseSource(coords = null, height = 40, emission = 3.7) { + if (!map || typeof ymaps === 'undefined') return; + + if (!coords) coords = map.getCenter(); // Если координаты не переданы, источник ставится по центру карты + + const placemark = new ymaps.Placemark(coords, { + hintContent: 'Точечный источник выбросов', + balloonContent: ` +
+ Источник выбросов
+ Высота трубы: ${height} м
+ Выброс H₂S: ${emission.toFixed(2)} г/с

+ +
+ ` + }, { + preset: 'islands#orangeFactoryIcon', + draggable: true + }); + + let sourceId = null; + + // Сохранение источника в базе данных + try { + const resp = await fetch('/api/sources', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: `Источник ${enterpriseSources.length + 1}`, + latitude: coords[0], + longitude: coords[1], + height: height, + emission_rate: emission, + source_type: 'point' + }) + }); + const data = await resp.json(); + if (data.success && data.source_id) { + sourceId = data.source_id; + placemark.properties.set('sourceId', sourceId); + } + } catch (err) { + console.error('Ошибка сохранения:', err); + } + + map.geoObjects.add(placemark); + enterpriseSources.push(placemark); + + appendSourceToList(placemark, height, emission, sourceId); + + // Обновление координат источника при перетаскивании метки + placemark.events.add('dragend', async () => { + const newCoords = placemark.geometry.getCoordinates(); + if (sourceId) { + await fetch(`/api/sources/${sourceId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + latitude: newCoords[0], + longitude: newCoords[1] + }) + }); + } + }); +} + +window.deleteEnterpriseSource = async function (button) { + const balloonContent = button.closest('.ymaps-balloon__content'); + if (!balloonContent) return; + + // Обнаружение метки источника на карте + let placemarkToRemove = null; + if (map) { + map.geoObjects.each(obj => { + if (obj.balloon && obj.balloon.isOpen() && obj.balloon.getContent().includes(button.textContent)) { + placemarkToRemove = obj; + } + }); + } + + if (!placemarkToRemove && balloonContent.__parent) { + placemarkToRemove = balloonContent.__parent; + } + + if (placemarkToRemove) { + const sourceId = placemarkToRemove.properties.get('sourceId'); + if (sourceId) { + await fetch(`/api/sources/${sourceId}`, { method: 'DELETE' }); + } + if (map) map.geoObjects.remove(placemarkToRemove); + enterpriseSources = enterpriseSources.filter(p => p !== placemarkToRemove); + + const listItem = document.querySelector(`[data-source-id="${sourceId}"]`); + if (listItem) listItem.remove(); + } +}; + +function appendSourceToList(placemark, height, emission, sourceId) { + const legend = qs('.legend-items') || qs('.panel'); + if (!legend) return; + + const div = document.createElement('div'); + div.className = 'source-item'; + div.dataset.sourceId = sourceId || ''; + div.innerHTML = ` +
Источник ${enterpriseSources.length}, точечный
+ Высота: ${height} м, выброс: ${emission} г/с + + `; + legend.appendChild(div); +} + +// Специфичные для вкладки "Мониторинг" функции +const CONFIG = { + MAX_DISTANCE: 10000, + MIN_DISTANCE: 10, + PDK_H2S: 0.008, + POLYGON_DETAIL: 4000, + METERS_IN_DEGREE_LAT: 111132.954 +}; + +const PASQUILL_COEFFS = { + 'A': { Iy: -1.104, Jy: 0.9878, Ky: -0.0076, Iz: 4.679, Jz: -1.7172, Kz: 0.2770 }, + 'B': { Iy: -1.634, Jy: 1.0350, Ky: -0.0096, Iz: -1.999, Jz: 0.8752, Kz: 0.0136 }, + 'C': { Iy: -2.054, Jy: 1.0231, Ky: -0.0076, Iz: -2.341, Jz: 0.9477, Kz: -0.0020 }, + 'D': { Iy: -2.555, Jy: 1.0423, Ky: -0.0087, Iz: -3.186, Jz: 1.1737, Kz: -0.0316 }, + 'E': { Iy: -2.754, Jy: 1.0106, Ky: -0.0064, Iz: -3.783, Jz: 1.3010, Kz: -0.0450 }, + 'F': { Iy: -3.143, Jy: 1.0148, Ky: -0.0070, Iz: -4.490, Jz: 1.4024, Kz: -0.0540 } +}; + +// Легенда цветов по уровням ПДК +const COLOR_SCHEME = [ + { threshold: 0.2, color: '#00FF00' }, + { threshold: 0.4, color: '#7FFF00' }, + { threshold: 0.6, color: '#FFFF00' }, + { threshold: 0.8, color: '#FFA500' }, + { threshold: 1.0, color: '#FF0000' } +]; + +let monitoringMap; +const monitoringState = { + sources: [], + pollutionLayers: [], + isPlacingSource: false, + sourceToDelete: null, + isHeatmapVisible: true, + isCalculating: false +}; + +function initMonitoringMap() { + if (typeof ymaps === 'undefined') return; + + ymaps.ready(() => { + const mapContainer = document.getElementById('map'); + if (!mapContainer) return; + + monitoringMap = new ymaps.Map('map', { + center: [55.7558, 37.6173], + zoom: 13, + controls: ['zoomControl', 'typeSelector', 'fullscreenControl'] + }); + + // Проверяем, что мы на странице мониторинга + if (window.location.pathname === '/') { + initMonitoring(); + } + }); +} + +async function apiCall(url, options = {}) { + try { + const response = await fetch(url, { + headers: { 'Content-Type': 'application/json', ...options.headers }, + ...options + }); + return await response.json(); + } catch (error) { + console.error('API Error:', error); + return null; + } +} + +async function loadSourcesFromDB() { + const sources = await apiCall('/api/sources'); + if (sources) { + clearAllMapObjects(); + const windSpeedInput = document.getElementById('wind-speed'); + const windDirectionInput = document.getElementById('wind-direction'); + const stabilityClassInput = document.getElementById('stability-class'); + + monitoringState.sources = sources.map(source => ({ + ...source, id: source.id, lat: source.latitude, lng: source.longitude, + emissionRate: source.emission_rate, + windSpeed: windSpeedInput ? parseFloat(windSpeedInput.value) : 3, + windDirection: windDirectionInput ? windDirectionInput.value : '270', + stabilityClass: stabilityClassInput ? stabilityClassInput.value : 'D', + placemark: null + })); + updateSourcesOnMap(); + updateSourcesList(); + } +} + +async function addSourceToDB(sourceData) { + return await apiCall('/api/sources', { + method: 'POST', + body: JSON.stringify(sourceData) + }); +} + +async function deleteSourceFromDB(sourceId) { + return await apiCall(`/api/sources/${sourceId}`, { + method: 'DELETE' + }); +} + +async function updateParamsInDB(params) { + return await apiCall('/api/params', { + method: 'POST', + body: JSON.stringify(params) + }); +} + +function clearAllMapObjects() { + if (!monitoringMap) return; + + monitoringState.sources.forEach(source => { + if (source.placemark) monitoringMap.geoObjects.remove(source.placemark); + }); + monitoringState.pollutionLayers.forEach(layer => monitoringMap.geoObjects.remove(layer)); + monitoringState.pollutionLayers = []; +} + +function calculateSigma(x, coeffs) { + if (x <= CONFIG.MIN_DISTANCE) return { sigmaY: 0, sigmaZ: 0 }; + const logX = Math.log(x); + const sigmaY = Math.exp(coeffs.Iy + coeffs.Jy * logX + coeffs.Ky * Math.pow(logX, 2)); + const sigmaZ = Math.exp(coeffs.Iz + coeffs.Jz * logX + coeffs.Kz * Math.pow(logX, 2)); + return { sigmaY, sigmaZ }; +} + +function calculateMaxConcentration(source) { + const coeffs = PASQUILL_COEFFS[source.stabilityClass] || PASQUILL_COEFFS['D']; + let xMax = 0, cMax = 0; + for (let x = CONFIG.MIN_DISTANCE; x <= CONFIG.MAX_DISTANCE; x += 100) { + const { sigmaY, sigmaZ } = calculateSigma(x, coeffs); + if (sigmaY <= 0 || sigmaZ <= 0) continue; + const c = (source.emissionRate * 1000) / (Math.PI * source.windSpeed * sigmaY * sigmaZ) * Math.exp(-0.5 * Math.pow(source.height / sigmaZ, 2)); + if (c > cMax) { cMax = c; xMax = x; } + } + return { distance: xMax, concentration: cMax }; +} + +function calculatePollutionPolygon(source, concentrationThreshold, color) { + const coeffs = PASQUILL_COEFFS[source.stabilityClass] || PASQUILL_COEFFS['D']; + const plumeDirectionRad = (parseFloat(source.windDirection) + 180) * Math.PI / 180; + + const pointsPositiveY = []; + const pointsNegativeY = []; + let x_tip = 0; + + const step = CONFIG.MAX_DISTANCE / CONFIG.POLYGON_DETAIL; + + for (let x = CONFIG.MIN_DISTANCE; x <= CONFIG.MAX_DISTANCE; x += step) { + const { sigmaY, sigmaZ } = calculateSigma(x, coeffs); + if (sigmaY <= 0 || sigmaZ <= 0) continue; + + const verticalTerm = Math.exp(-0.5 * Math.pow(source.height / sigmaZ, 2)); + const A = (source.emissionRate * 1000) / (2 * Math.PI * source.windSpeed * sigmaY * sigmaZ) * verticalTerm; + + const logTerm = Math.log(concentrationThreshold / A); + const y = (logTerm < 0) ? sigmaY * Math.sqrt(-2 * logTerm) : 0; + + if (y > 0) { + x_tip = x; + + const dE = x * Math.sin(plumeDirectionRad) + y * Math.cos(plumeDirectionRad); + const dN = x * Math.cos(plumeDirectionRad) - y * Math.sin(plumeDirectionRad); + const dE_neg = x * Math.sin(plumeDirectionRad) - y * Math.cos(plumeDirectionRad); + const dN_neg = x * Math.cos(plumeDirectionRad) + y * Math.sin(plumeDirectionRad); + + const metersInDegreeLon = CONFIG.METERS_IN_DEGREE_LAT * Math.cos(source.lat * Math.PI / 180); + + const lat = source.lat + dN / CONFIG.METERS_IN_DEGREE_LAT; + const lon = source.lng + dE / metersInDegreeLon; + pointsPositiveY.push([lat, lon]); + + const lat_neg = source.lat + dN_neg / CONFIG.METERS_IN_DEGREE_LAT; + const lon_neg = source.lng + dE_neg / metersInDegreeLon; + pointsNegativeY.push([lat_neg, lon_neg]); + } + } + + if (pointsPositiveY.length < 2) return null; + + const dE_tip = x_tip * Math.sin(plumeDirectionRad); + const dN_tip = x_tip * Math.cos(plumeDirectionRad); + const metersInDegreeLon_tip = CONFIG.METERS_IN_DEGREE_LAT * Math.cos(source.lat * Math.PI / 180); + const tipLat = source.lat + dN_tip / CONFIG.METERS_IN_DEGREE_LAT; + const tipLon = source.lng + dE_tip / metersInDegreeLon_tip; + const tipPointGeo = [tipLat, tipLon]; + + const polygonContour = [ + [source.lat, source.lng], + ...pointsPositiveY, + tipPointGeo, + ...pointsNegativeY.reverse(), + [source.lat, source.lng] + ]; + + return new ymaps.Polygon([polygonContour], {}, { + fillColor: color, + strokeWidth: 0, + fillOpacity: 0.45, + zIndex: Math.round(concentrationThreshold / CONFIG.PDK_H2S * 100) + }); +} + +function updatePollutionZones() { + if (monitoringState.isCalculating || !monitoringMap) return; + monitoringState.isCalculating = true; + + const calculationResults = document.getElementById('calculation-results'); + if (calculationResults) { + calculationResults.innerHTML = '
⏳ Выполняется расчет...
'; + } + + monitoringState.pollutionLayers.forEach(layer => monitoringMap.geoObjects.remove(layer)); + monitoringState.pollutionLayers = []; + + if (monitoringState.sources.length === 0) { + updateCalculationResults([]); + monitoringState.isCalculating = false; + return; + } + + const maxResults = []; + let totalPolygons = 0; + + setTimeout(() => { + monitoringState.sources.forEach(source => { + maxResults.push({ + ...calculateMaxConcentration(source), + height: source.height, + emission: source.emissionRate + }); + + COLOR_SCHEME.forEach(level => { + const concentrationThreshold = level.threshold * CONFIG.PDK_H2S; + const polygon = calculatePollutionPolygon(source, concentrationThreshold, level.color); + if (polygon) { + monitoringState.pollutionLayers.push(polygon); + monitoringMap.geoObjects.add(polygon); + totalPolygons++; + } + }); + }); + + updateCalculationResults(maxResults); + monitoringState.isCalculating = false; + + if (calculationResults) { + const statusMsg = totalPolygons > 0 + ? `Расчет завершен. Построено полигонов: ${totalPolygons}.` + : `Концентрации ниже порогов отображения.`; + const statusStyle = totalPolygons > 0 + ? 'background: #d4edda; border-color: #c3e6cb;' + : 'background: #f8d7da; border-color: #f5c6cb;'; + + calculationResults.innerHTML = `
${statusMsg}
` + calculationResults.innerHTML; + } + }, 50); +} + +function updateCalculationResults(results) { + const calculationResults = document.getElementById('calculation-results'); + if (!calculationResults) return; + + if (results.length === 0) { + calculationResults.innerHTML = '

Нет данных для отображения.

'; + return; + } + + let html = ''; + results.forEach((result, index) => { + const pdkPercent = (result.concentration / CONFIG.PDK_H2S) * 100; + const pdkClass = pdkPercent > 100 ? 'style="color: red; font-weight: bold;"' : pdkPercent > 80 ? 'style="color: orange;"' : ''; + html += ` +
+

Источник #${index + 1}

+

Выброс: ${result.emission.toFixed(3)} г/с, Высота: ${result.height} м

+

Макс. концентрация: ${result.concentration.toFixed(6)} мг/м³ (${pdkPercent.toFixed(1)}% ПДК)

+

На расстоянии: ${(result.distance / 1000).toFixed(1)} км

+
`; + }); + calculationResults.innerHTML = html; +} + +function initMonitoring() { + // Инициализация проходит, если мы на странице мониторинга + if (window.location.pathname !== '/') return; + + setupMonitoringEventListeners(); + loadSourcesFromDB(); +} + +function setupMonitoringEventListeners() { + // Обработчики элементов добавляются, только если они существуют + const addSourceBtn = document.getElementById('add-source-btn'); + const updateBtn = document.getElementById('update-btn'); + const toggleHeatmapBtn = document.getElementById('toggle-heatmap-btn'); + const cancelDeleteBtn = document.getElementById('cancel-delete'); + const confirmDeleteBtn = document.getElementById('confirm-delete'); + + if (addSourceBtn) { + addSourceBtn.addEventListener('click', enableSourcePlacement); + } + + if (updateBtn) { + updateBtn.addEventListener('click', updateAllSources); + } + + if (toggleHeatmapBtn) { + toggleHeatmapBtn.addEventListener('click', toggleHeatmap); + } + + // Обработчики модального окна подключаются, если оно существует + const closeButtons = document.querySelectorAll('.close'); + if (closeButtons.length > 0) { + closeButtons.forEach(el => { + el.addEventListener('click', () => { + const modal = document.getElementById('confirm-modal'); + if (modal) modal.style.display = 'none'; + }); + }); + } + + if (cancelDeleteBtn) { + cancelDeleteBtn.addEventListener('click', () => { + const modal = document.getElementById('confirm-modal'); + if (modal) modal.style.display = 'none'; + }); + } + + if (confirmDeleteBtn) { + confirmDeleteBtn.addEventListener('click', confirmDeleteSource); + } +} + +function updateSourcesOnMap() { + if (!monitoringMap) return; + + monitoringState.sources.forEach(source => { + if (source.placemark) monitoringMap.geoObjects.remove(source.placemark); + }); + + monitoringState.sources.forEach(source => { + source.placemark = new ymaps.Placemark([source.lat, source.lng], { + balloonContent: `${source.name}
Высота: ${source.height} м
Выброс: ${source.emissionRate.toFixed(3)} г/с H2S` + }, { preset: 'islands#redIcon' }); + monitoringMap.geoObjects.add(source.placemark); + }); +} + +async function addSource(lat, lng, height, emissionRate) { + const result = await addSourceToDB({ + name: `Источник ${Date.now() % 10000}`, + type: 'point', + latitude: lat, + longitude: lng, + height: height, + emission_rate: emissionRate + }); + + if (result && result.success) { + await loadSourcesFromDB(); + if (monitoringState.isHeatmapVisible) updatePollutionZones(); + } +} + +function updateSourcesList() { + const sourcesList = document.getElementById('sources-list'); + if (!sourcesList) return; + + sourcesList.innerHTML = monitoringState.sources.length === 0 + ? '

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

' + : ''; + + monitoringState.sources.forEach(source => { + const sourceEl = document.createElement('div'); + sourceEl.className = 'source-item'; + sourceEl.innerHTML = ` + ${source.name}
+ Высота: ${source.height} м, выброс: ${source.emissionRate.toFixed(3)} г/с +
+ + +
`; + sourcesList.appendChild(sourceEl); + }); +} + +function enableSourcePlacement() { + if (!monitoringMap || monitoringState.isPlacingSource) { + disableSourcePlacement(); + return; + } + + monitoringState.isPlacingSource = true; + const addSourceBtn = document.getElementById('add-source-btn'); + if (addSourceBtn) { + addSourceBtn.textContent = 'Отменить (клик на карте)'; + addSourceBtn.style.background = '#e74c3c'; + } + + monitoringMap.events.add('click', handleMapClickForPlacement); + monitoringMap.setOptions('cursor', 'crosshair'); +} + +function disableSourcePlacement() { + monitoringState.isPlacingSource = false; + const addSourceBtn = document.getElementById('add-source-btn'); + if (addSourceBtn) { + addSourceBtn.textContent = 'Добавить источник на карту'; + addSourceBtn.style.background = ''; + } + + if (monitoringMap) { + monitoringMap.events.remove('click', handleMapClickForPlacement); + monitoringMap.setOptions('cursor', 'grab'); + } +} + +async function handleMapClickForPlacement(e) { + const coords = e.get('coords'); + const sourceHeightInput = document.getElementById('source-height'); + const emissionRateInput = document.getElementById('emission-rate'); + + await addSource( + coords[0], + coords[1], + sourceHeightInput ? parseFloat(sourceHeightInput.value) : 40, + emissionRateInput ? parseFloat(emissionRateInput.value) : 3.7 + ); + disableSourcePlacement(); +} + +async function updateAllSources() { + if (monitoringState.isCalculating) { + alert('Дождитесь завершения текущего расчета.'); + return; + } + + const windSpeedInput = document.getElementById('wind-speed'); + const windDirectionInput = document.getElementById('wind-direction'); + const stabilityClassInput = document.getElementById('stability-class'); + + const params = { + wind_speed: windSpeedInput ? parseFloat(windSpeedInput.value) : 3, + wind_direction: windDirectionInput ? windDirectionInput.value : '270', + stability_class: stabilityClassInput ? stabilityClassInput.value : 'D' + }; + + await updateParamsInDB(params); + monitoringState.sources.forEach(source => { + source.windSpeed = params.wind_speed; + source.windDirection = params.wind_direction; + source.stabilityClass = params.stability_class; + }); + + if (monitoringState.isHeatmapVisible) updatePollutionZones(); +} + +function requestDeleteSource(sourceId) { + monitoringState.sourceToDelete = monitoringState.sources.find(s => s.id === sourceId); + if (monitoringState.sourceToDelete) { + const modal = document.getElementById('confirm-modal'); + if (modal) modal.style.display = 'block'; + } +} + +async function confirmDeleteSource() { + if (!monitoringState.sourceToDelete) return; + + const result = await deleteSourceFromDB(monitoringState.sourceToDelete.id); + if (result && result.success) { + await loadSourcesFromDB(); + if (monitoringState.isHeatmapVisible) updatePollutionZones(); + } + + const modal = document.getElementById('confirm-modal'); + if (modal) modal.style.display = 'none'; + monitoringState.sourceToDelete = null; +} + +function flyToSource(sourceId) { + if (!monitoringMap) return; + + const source = monitoringState.sources.find(s => s.id === sourceId); + if (source) { + monitoringMap.panTo([source.lat, source.lng], { duration: 1000, flying: true }).then(() => { + monitoringMap.setZoom(15, { duration: 500 }); + if (source.placemark) source.placemark.balloon.open(); + }); + } +} + +function toggleHeatmap() { + if (monitoringState.isCalculating) { + alert('Дождитесь завершения расчета.'); + return; + } + + monitoringState.isHeatmapVisible = !monitoringState.isHeatmapVisible; + const toggleHeatmapBtn = document.getElementById('toggle-heatmap-btn'); + + if (toggleHeatmapBtn) { + toggleHeatmapBtn.textContent = monitoringState.isHeatmapVisible + ? 'Скрыть зоны загрязнения' + : 'Показать зоны загрязнения'; + toggleHeatmapBtn.style.background = monitoringState.isHeatmapVisible + ? 'linear-gradient(180deg, #27ae60, #219653)' + : ''; + } + + if (monitoringState.isHeatmapVisible) { + updatePollutionZones(); + } else { + monitoringState.pollutionLayers.forEach(layer => { + if (monitoringMap) monitoringMap.geoObjects.remove(layer); + }); + monitoringState.pollutionLayers = []; + } +} + +// Глобальные функции для использования в HTML +window.requestDeleteSource = requestDeleteSource; +window.flyToSource = flyToSource; + +// Анимация ветра для вкладки "Мониторинг" +let weatherData = { wind_speed: 3, wind_dir: 'n', prec_type: 0, prec_strength: 0 }; +let isWindOn = true; +let isRainOn = true; + +// Перевод направления ветра из API в текст +function dirToText(dir) { + const map = { n:'С', ne:'СВ', e:'В', se:'ЮВ', s:'Ю', sw:'ЮЗ', w:'З', nw:'СЗ' }; + return map[dir] || 'С'; +} + +// Обновление текстовой панели с данными о погоде +async function refreshWeather() { + try { + const res = await fetch('https://api.weather.yandex.ru/v2/forecast?lat=55.7558&lon=37.6173&limit=1', { + headers: { 'X-Yandex-API-Key': windows.appConfig.YWEATHER_API_KEY } + }); + + if (!res.ok) { + throw new Error(`HTTP error! status: ${res.status}`); + } + + const data = await res.json(); + const f = data.fact; + + weatherData = { + wind_speed: f.wind_speed, + wind_dir: f.wind_dir, + pressure_mm: f.pressure_mm, + humidity: f.humidity, + visibility: f.visibility ? (f.visibility/1000).toFixed(1) : 10, + prec_type: f.prec_type || 0, + prec_strength: f.prec_strength || 0 + }; + + const panel = document.querySelector('.map-meta'); + if (panel) { + panel.textContent = `Ветер: ${f.wind_speed} м/с, ${dirToText(f.wind_dir)} Давление: ${f.pressure_mm} мм рт. ст. Влажность: ${f.humidity}% Дальность видимости: ${weatherData.visibility} км Изотермия: не наблюдается`; + } + } catch (e) { + console.warn('Погода не загрузилась, используем дефолт'); + // Устанавливаем значения по умолчанию + weatherData = { + wind_speed: 3, + wind_dir: 'n', + pressure_mm: 764, + humidity: 61, + visibility: '61', + prec_type: 0, + prec_strength: 0 + }; + } +} + +// Анимация +let canvas, ctx, particles = [], animationId; + +function startWeatherAnimation() { + if (!map?.container) return; + + canvas = document.createElement('canvas'); + canvas.style.cssText = 'position:absolute; top:0; left:0; pointer-events:none; z-index:1000;'; + const container = map.container.getElement(); + container.style.position = 'relative'; + container.appendChild(canvas); + + function resize() { + canvas.width = container.clientWidth; + canvas.height = container.clientHeight; + } + resize(); + window.addEventListener('resize', resize); + if (map.events) { + map.events.add('boundschange', resize); + } + + function animate() { + ctx.clearRect(0, 0, canvas.width, canvas.height); + + const ws = weatherData.wind_speed * 1.2; + const angle = { n:0, ne:45, e:90, se:135, s:180, sw:225, w:270, nw:315 }[weatherData.wind_dir] || 0; + const rad = angle * Math.PI / 180; + + // Ветер + if (isWindOn) { + if (particles.length < 70) { + particles.push({ + x: Math.random() * canvas.width, + y: Math.random() * canvas.height, + life: 1 + }); + } + particles.forEach((p, i) => { + p.x += Math.cos(rad) * ws; + p.y += Math.sin(rad) * ws; + p.life -= 0.01; + + if (p.life > 0) { + ctx.strokeStyle = `rgba(52, 152, 219, ${p.life})`; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.moveTo(p.x, p.y); + ctx.lineTo(p.x - Math.cos(rad)*30, p.y - Math.sin(rad)*30); + ctx.stroke(); + } else { + particles.splice(i, 1); + } + }); + } + + // Дождь и снег + if (isRainOn && weatherData.prec_strength > 0) { + for (let i = 0; i < 5 * weatherData.prec_strength; i++) { + particles.push({ + x: Math.random() * canvas.width, + y: -10, + life: 1, + isRain: true + }); + } + particles.forEach((p, i) => { + if (!p.isRain) return; + p.y += 10; + p.life -= 0.02; + if (p.life > 0 && p.y < canvas.height) { + ctx.strokeStyle = weatherData.prec_type === 2 ? 'rgba(255,255,255,0.8)' : 'rgba(100,180,255,0.7)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(p.x, p.y); + ctx.lineTo(p.x, p.y + 12); + ctx.stroke(); + } else if (p.y >= canvas.height) { + particles.splice(i, 1); + } + }); + } + + animationId = requestAnimationFrame(animate); + } + ctx = canvas.getContext('2d'); + animate(); +} + +if (typeof ymaps !== 'undefined') { + ymaps.ready(() => { + // Анимация погоды инициализируется, если есть карта + const mapContainer = document.getElementById('map'); + if (!mapContainer) return; + + if (typeof map === 'undefined' || !map) { + map = new ymaps.Map('map', { + center: [55.7558, 37.6173], + zoom: 10, + controls: ['zoomControl', 'fullscreenControl'] + }); + } + + // Каждые 10 минут данные о погоде обновляются + setTimeout(() => { + refreshWeather(); + setInterval(refreshWeather, 600000); + + // Анимация запускается, если есть карта + if (map) { + startWeatherAnimation(); + } + + // Обработчики элементов добавляются, если они существуют + const toggleWindBtn = document.getElementById('toggleWind'); + const togglePrecipBtn = document.getElementById('togglePrecip'); + const updateWeatherBtn = document.getElementById('updateWeather'); + + if (toggleWindBtn) { + toggleWindBtn.addEventListener('click', () => { + isWindOn = !isWindOn; + toggleWindBtn.textContent = `Ветер: ${isWindOn ? 'Вкл' : 'Выкл'}`; + }); + } + + if (togglePrecipBtn) { + togglePrecipBtn.addEventListener('click', () => { + isRainOn = !isRainOn; + togglePrecipBtn.textContent = `Осадки: ${isRainOn ? 'Вкл' : 'Выкл'}`; + }); + } + + if (updateWeatherBtn) { + updateWeatherBtn.addEventListener('click', refreshWeather); + } + }, 1500); + }); +} diff --git a/templates/enterprise.html b/templates/enterprise.html new file mode 100644 index 0000000..6df3b1c --- /dev/null +++ b/templates/enterprise.html @@ -0,0 +1,203 @@ + + + + + Система моделирования выбросов вредных веществ + + + + + + + + + + +
+
+ Логотип +

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

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

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

+

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

+ +
+ + +
+
Расчет рассеивания загрязнений...
+
+
+
+
0%
+
+ + +
+
Радиус: 100 пикселей
+ + + + +
+ + +
+
Настройки ветра
+ + +
2.4 м/с
+ + +
180° (Юг)
+ +
+
+
С
+
В
+
Ю
+
З
+
+
+
180°
+
+ + + + +
+ + +
+
+
+ < 20% ПДК +
+
+
+ 20-60% ПДК +
+
+
+ 60-80% ПДК +
+
+
+ > 80% ПДК +
+
+ +
+
+ 09:3009:4009:50 + 10:0010:1010:20 + Сейчас10:4010:50 + 11:0011:1011:2011:30 +
+
+
+
+
+
+
+
+
+
+ + + + + diff --git a/templates/forecast.html b/templates/forecast.html deleted file mode 100644 index 566549b..0000000 --- a/templates/forecast.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - - Title - - - - - \ No newline at end of file diff --git a/templates/forecasting.html b/templates/forecasting.html new file mode 100644 index 0000000..54897eb --- /dev/null +++ b/templates/forecasting.html @@ -0,0 +1,125 @@ + + + + + Система моделирования выбросов вредных веществ + + + + + +
+
+ Логотип +

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

+
+ + +
+ +
+ + + + +
+
+

Результаты предсказания с помощью модели

+ +
+
+
Прогнозируемый уровень концентрации (средний за год):
+
0.03 ПДК
+
+
+
Возможная ошибка прогнозирования:
+
1.05 %
+
+
+
+ +

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

+ +
+
+
+
0.5%
+
+
Ветер (восточный, 19 м/с)
+
+ +
+
+
7.5%
+
+
Класс устойчивости по Пасквиллу
+
+ +
+
+
10.7%
+
+
Погодные условия
+
+ +
+
+
5.5%
+
+
Особенности выбранной местности
+
+ +
+
+
22.25%
+
+
Предприятия, находящиеся рядом
+
+
+
+
+ + diff --git a/templates/history.html b/templates/history.html index 566549b..6f5d767 100644 --- a/templates/history.html +++ b/templates/history.html @@ -1,10 +1,124 @@ - - + + - - Title + + Система моделирования выбросов вредных веществ + + + + + +
+
+ Логотип +

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

+
+ +
+ +
+ + + +
+
+
+ Ветер: 1,9 м/с, В Давление: 764 мм рт. ст. Влажность: 61% Дальность видимости: 61 км Изотермия: не наблюдается +
+
+ +
+
СОДЕРЖАНИЕ В ВОЗДУХЕ, мг/м³
+ +
+ +
+
+ Декабрь 2024 + Январь 2025 + Февраль 2025 + Март 2025 + Апрель 2025 + Май 2025 + Июнь 2025 + Июль 2025 + Август 2025 + Сейчас +
+
+
+
+
+
+
+
+
+
- \ No newline at end of file + diff --git a/templates/index.html b/templates/index.html index 566549b..a4128c9 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,10 +1,93 @@ - - + + - - Title + + Система моделирования выбросов вредных веществ +
+ + + + + +
+
+ Логотип +

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

+
+ +
+
+ + +
+
+
+ Ветер: 1,9 м/с, В     Давление: 764 мм рт. ст.     Влажность: 61%     Дальность видимости: 61 км     Изотермия: не наблюдается +
+
+ +
+ + + +
+
+ +
+
+ 09:3009:4009:50 + 10:0010:1010:20 + Сейчас10:4010:50 + 11:0011:1011:2011:30 +
+
+
+
+
+
+
+
+
+
- \ No newline at end of file + diff --git a/templates/layout.html b/templates/layout.html new file mode 100644 index 0000000..e516fe2 --- /dev/null +++ b/templates/layout.html @@ -0,0 +1,31 @@ + + + + + + {% block title %}������� ������������� ��������{% endblock %} + + {% block extra_css %}{% endblock %} + + +
+
+

������� ������������� �������� ������� �������

+
+ +
+ +
+ {% block content %}{% endblock %} +
+ + {% block scripts %}{% endblock %} + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..ecffe6f --- /dev/null +++ b/templates/login.html @@ -0,0 +1,86 @@ + + + + + Система моделирования выбросов вредных веществ + + + + + +
+
+ Логотип +

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

+
+ +
+ +
+ + + + +
+

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

+
+
+ + + + + + + + +
+
+ + + + + + + + +
+
+ + + + + + + + + + +
+
+ + diff --git a/templates/monitoring.html b/templates/monitoring.html deleted file mode 100644 index 566549b..0000000 --- a/templates/monitoring.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - - Title - - - - - \ No newline at end of file diff --git a/templates/recomendations.html b/templates/recomendations.html deleted file mode 100644 index 566549b..0000000 --- a/templates/recomendations.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - - Title - - - - - \ No newline at end of file diff --git a/templates/recommendations.html b/templates/recommendations.html new file mode 100644 index 0000000..560aea7 --- /dev/null +++ b/templates/recommendations.html @@ -0,0 +1,101 @@ + + + + + Система моделирования выбросов вредных веществ + + + + + + + +
+
+ Логотип +

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

+ +
+ + +
+ +
+ + + + +
+
+
+ Ветер: 1,9 м/с, В Давление: 764 мм рт. ст. Влажность: 61% Дальность видимости: 61 км Изотермия: не наблюдается +
+
+ +
+ +
+
+ 09:3009:4009:50 + 10:0010:1010:20 + Сейчас10:4010:50 + 11:0011:1011:2011:30 +
+
+
+
+
+
+
+
+
+
+ + \ No newline at end of file From a8e60044375417b4099d1ea0f82a951c50254e91 Mon Sep 17 00:00:00 2001 From: axxxiomatic Date: Tue, 23 Dec 2025 13:19:25 +0300 Subject: [PATCH 02/13] Delete LICENSE --- LICENSE | 30 ------------------------------ 1 file changed, 30 deletions(-) delete mode 100644 LICENSE diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 01f875e..0000000 --- a/LICENSE +++ /dev/null @@ -1,30 +0,0 @@ -Copyright (c) 2025 National Institute of Electronic Technology (MIET), Sub-Institute SPINTECH - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -1. The above copyright notice, this list of conditions, and the following - disclaimer must be included in all copies or substantial portions of the - Software. - -2. All modifications made to the original source code must be clearly - documented and attributed to the modifying author or organization. However, - the underlying intellectual property and copyright of the original Software - remains with MIET, Sub-Institute SPINTECH. - -3. The names "MIET", "National Institute of Electronic Technology", - "SPINTECH", and any related logos shall not be used to endorse or promote - products derived from this Software without specific prior written - permission from MIET, Sub-Institute SPINTECH. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. From 0c64e7f8a61f7ba208235029e176816747b1a140 Mon Sep 17 00:00:00 2001 From: axxxiomatic Date: Wed, 18 Mar 2026 21:49:37 +0300 Subject: [PATCH 03/13] Delete __pycache__ directory --- __pycache__/database.cpython-312.pyc | Bin 8022 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 __pycache__/database.cpython-312.pyc diff --git a/__pycache__/database.cpython-312.pyc b/__pycache__/database.cpython-312.pyc deleted file mode 100644 index 3154897c301a07c6ad9b7158645d88616ddd217e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8022 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 From 9b8c980101362d970bd73daacb05dc46ac8f3817 Mon Sep 17 00:00:00 2001 From: axxxiomatic Date: Wed, 18 Mar 2026 21:50:01 +0300 Subject: [PATCH 04/13] Update .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2eea525..d50a09f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -.env \ No newline at end of file +.env +__pycache__/ From bf74dc6e52b1d3947a564c3d343d83f94d6ba642 Mon Sep 17 00:00:00 2001 From: Khasnash Andrei Date: Wed, 18 Mar 2026 23:49:09 +0300 Subject: [PATCH 05/13] added venv in gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d50a09f..25de67b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .env __pycache__/ +./venv From 8332fca7cc711195093dd8d9185b7abf10f84dd5 Mon Sep 17 00:00:00 2001 From: Khasnash Andrei Date: Fri, 20 Mar 2026 16:47:55 +0300 Subject: [PATCH 06/13] rewrite part of legacy code on fastapi --- .env.example | 3 -- .gitignore | 1 + .idea/.gitignore | 3 -- .idea/EcologicalPredictor.iml | 10 ----- .idea/misc.xml | 6 +-- .idea/modules.xml | 2 +- app.py | 1 - backend/__init__.py | 0 backend/app/__init__.py | 0 backend/app/app.py | 4 ++ backend/app/database.py | 29 +++++++++++++ backend/app/models/__init__.py | 0 backend/app/models/simulation_params.py | 10 +++++ backend/app/models/sources.py | 16 ++++++++ backend/app/repositories/__init__.py | 0 backend/app/repositories/simulation_params.py | 38 ++++++++++++++++++ backend/app/repositories/sources.py | 30 ++++++++++++++ backend/app/schemas/__init__.py | 0 backend/app/schemas/simulation_params.py | 11 +++++ database.py | 24 ----------- requirements.txt | Bin 49 -> 1870 bytes 21 files changed, 143 insertions(+), 45 deletions(-) delete mode 100644 .env.example delete mode 100644 .idea/.gitignore delete mode 100644 .idea/EcologicalPredictor.iml create mode 100644 backend/__init__.py create mode 100644 backend/app/__init__.py create mode 100644 backend/app/app.py create mode 100644 backend/app/database.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/simulation_params.py create mode 100644 backend/app/models/sources.py create mode 100644 backend/app/repositories/__init__.py create mode 100644 backend/app/repositories/simulation_params.py create mode 100644 backend/app/repositories/sources.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/simulation_params.py diff --git a/.env.example b/.env.example deleted file mode 100644 index f2b0760..0000000 --- a/.env.example +++ /dev/null @@ -1,3 +0,0 @@ -YMAPS_API_KEY= -YMAPS_LANG= -YWEATHER_API_KEY= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 25de67b..db0364d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +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/EcologicalPredictor.iml b/.idea/EcologicalPredictor.iml deleted file mode 100644 index 6cb8b9a..0000000 --- a/.idea/EcologicalPredictor.iml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 4675d00..f67aa98 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,7 +1,7 @@ - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml index 365e9b8..de28711 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -2,7 +2,7 @@ - + \ No newline at end of file diff --git a/app.py b/app.py index ac2d08b..494844d 100644 --- a/app.py +++ b/app.py @@ -2,7 +2,6 @@ import os import webbrowser from threading import Timer -from flask import Flask, render_template, request, jsonify from dotenv import load_dotenv load_dotenv() 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..b21386e --- /dev/null +++ b/backend/app/app.py @@ -0,0 +1,4 @@ +from fastapi import FastAPI + +app = FastAPI() + diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..b26bf99 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,29 @@ +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.orm import sessionmaker, DeclarativeBase +from dotenv import load_dotenv +import os + +load_dotenv() + +''' + Константу заменим на postgres потом + + BASE_DIR И DB_PATH нужны только пока есть sqlite +''' + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DB_PATH = os.path.join(os.path.dirname(BASE_DIR), 'instance', 'h2s_simulation.db') +SQLALCHEMY_DATABASE_URL = f'sqlite+aiosqlite:///{DB_PATH}' + +engine = create_async_engine(SQLALCHEMY_DATABASE_URL) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +class Base(DeclarativeBase): + pass + + +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..e69de29 diff --git a/backend/app/models/simulation_params.py b/backend/app/models/simulation_params.py new file mode 100644 index 0000000..292113f --- /dev/null +++ b/backend/app/models/simulation_params.py @@ -0,0 +1,10 @@ +from backend.app.database import Base +from sqlalchemy import Column, String, Integer, func, TIMESTAMP, Float + + +class SimulationParams(Base): + id = Column(Integer, primary_key=True, nullable=False) + wind_speed = Column(Float, nullable=False) + wind_direction = Column(String, nullable=False) + stability_class = Column(String, nullable=False) + updated_at = Column(TIMESTAMP, nullable=False, default=func.now()) diff --git a/backend/app/models/sources.py b/backend/app/models/sources.py new file mode 100644 index 0000000..fbc34a9 --- /dev/null +++ b/backend/app/models/sources.py @@ -0,0 +1,16 @@ +from backend.app.database import Base +from sqlalchemy import Column, String, Integer, func, TIMESTAMP, Float + + +class Sources(Base): + __tablename__ = "sources" + + id = Column(Integer, primary_key=True, nullable=False) + name = Column(String, nullable=False) + type = Column(String, nullable=False) + latitude = Column(Float, nullable=False) + longitude = Column(Float, nullable=False) + height = Column(Float, nullable=False) + emission_rate = Column(Float, nullable=False) + created_at = Column(TIMESTAMP, server_default=func.now()) + diff --git a/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/repositories/simulation_params.py b/backend/app/repositories/simulation_params.py new file mode 100644 index 0000000..93587b2 --- /dev/null +++ b/backend/app/repositories/simulation_params.py @@ -0,0 +1,38 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from ..models.simulation_params import SimulationParams +from typing import List +from ..schemas.simulation_params import SimulationParamsCreate + + +class SimulationParamsRepository: + def __init__(self, db: AsyncSession): + self.db = db + + async def get_simulation_params(self) -> List[SimulationParams]: + stmt = select(SimulationParams) + result = await self.db.execute(stmt) + return result.scalars().all() + + async def update_simulation_params( + self, + id: int, + new_simulation_params: SimulationParamsCreate + ) -> SimulationParams | None: + + stmt = select(SimulationParams).where(SimulationParams.id == id) + result = await self.db.execute(stmt) + simulation_params = result.scalars().first() + + if not simulation_params: + return None + + update_data = new_simulation_params.model_dump() + + for key, value in update_data.items(): + setattr(simulation_params, key, value) + + 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..81e559e --- /dev/null +++ b/backend/app/repositories/sources.py @@ -0,0 +1,30 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from ..models.sources import Sources +from typing import List + + +class SourcesRepository: + def __init__(self, db:AsyncSession): + self.db = db + + async def get_all_sources(self) -> List[Sources]: + stmt = select(Sources) + result = await self.db.execute(stmt) + return result.scalars().all() + + async def add_source(self, source_data: dict) -> Sources: + 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) -> None: + stmt = select(Sources).where(source_id==Sources.id) + + result = await self.db.execute(stmt) + source = result.scalars().first() + + await self.db.delete(source) + await self.db.commit() diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/simulation_params.py b/backend/app/schemas/simulation_params.py new file mode 100644 index 0000000..3449c38 --- /dev/null +++ b/backend/app/schemas/simulation_params.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + + +class SimulationParamsBase(BaseModel): + wind_speed: int + wind_direction: str + stability_class: str + + +class SimulationParamsCreate(SimulationParamsBase): + pass diff --git a/database.py b/database.py index cc890ac..99b5deb 100644 --- a/database.py +++ b/database.py @@ -16,30 +16,6 @@ def init_db(): conn = sqlite3.connect(db_path) cursor = conn.cursor() - # Создание таблицы источников - cursor.execute(''' - CREATE TABLE IF NOT EXISTS sources ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - type TEXT NOT NULL, - latitude REAL NOT NULL, - longitude REAL NOT NULL, - height REAL NOT NULL, - emission_rate REAL NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - ''') - - # Создание таблицы параметров моделирования - cursor.execute(''' - CREATE TABLE IF NOT EXISTS simulation_params ( - id INTEGER PRIMARY KEY, - wind_speed REAL NOT NULL, - wind_direction TEXT NOT NULL, - stability_class TEXT NOT NULL, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - ''') # Проверка наличия данных в таблице с помощью курсора cursor.execute("SELECT COUNT(*) FROM sources") diff --git a/requirements.txt b/requirements.txt index 2b8928e4405a0ffc0df7c7903fff057da90516bf..69736f9f736b5198b009475968cb60b60cc317c1 100644 GIT binary patch literal 1870 zcmZ`)O>f#z5ZrU6{uHCyK++s~$hB&vRyp+)@nIkZKd5a=^5@&m?0AhKsn7$9w;!{! zv+KWqX7*%zYwVZp@YGsqpYZu$BP;M3S#Iy`oy~AtT4i(CEZidZyN~#ubQZ}HBDJt( zl%-`j`_7v2c>ZtO+KV0F*Yx>s-ACCIZi$oFCip+Uz}Db4A=AOuz4sV4%t`+R;?_X* zWGfuTC~#BQ4J=q=Vbjiz94u_>Hmq%oBL)`8zQd_eUxgDa5zUQzFM*)}h8JYoz%q52 zM#e}&LJrCJ4($BK6Z0+c+j?XnQ&!opaM$2=hPXmb7IWa5_Q)=6GT>*_z^6`%_qQzf zcJy9ddgUJf8bjSjJy)~_BPBiwSxX>gUb4nhI4>LE&3n(>Sy$u@>|0buZW)EX>e|Te zI$v3kAM$^x4s|A?Joh;YXI!`BH-W#vMJ&_=wca@Q396w!$b{lc6VbEJuub+8F^WNy z==xXuzlCpLmts^OdtKv}b8te8%(G^$5!zEbnhYXhBw8PF|A6OJ#|R_RYV5cxL_5N6 z=NYAjV4B*ani6V@In@mFTN=N%$&lq4_SAKT-{BO>SCdYJuYDVMvqNNPJz$Rtjmf$8 zdc5d2b4TX)SFGaJJ4xF$P@lC=3vNB_ z6LL!qYM{wntJ+JfZ916G2HU1YRh)WuVS~!5o(b*=P5*;^M?LqjTewGsxWO(nPcSv? zvi1!Xrrw_42vhJAT@9t2cS7NM)^zDsc&K`9#uAySmDr8S8l4*YJR&z$Nd}tELt4=G zXH}Xp%>rwmqX$tBJEo2tp$g6dS=L5YR#u`2C-BueCwx1wYGsn{quvw#pW}vav@M=P Io5Owf582)j5C8xG literal 49 zcmZ?ENi5E`vo+E)(lh1?Pc6!>N-a&d1@n!#3Mxx7^7C|4@=H?l%Ia(lfr< Date: Sat, 21 Mar 2026 18:41:57 +0300 Subject: [PATCH 07/13] added services/added schemas --- backend/app/repositories/simulation_params.py | 7 +++---- backend/app/repositories/sources.py | 20 +++++++++++++------ backend/app/routers/__init__.py | 0 backend/app/routers/pages.py | 14 +++++++++++++ backend/app/schemas/simulation_params.py | 6 ++++++ backend/app/schemas/sources.py | 20 +++++++++++++++++++ backend/app/services/__init__.py | 0 .../app/services/simulation_params_service.py | 16 +++++++++++++++ backend/app/services/source_service.py | 20 +++++++++++++++++++ 9 files changed, 93 insertions(+), 10 deletions(-) create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/pages.py create mode 100644 backend/app/schemas/sources.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/simulation_params_service.py create mode 100644 backend/app/services/source_service.py diff --git a/backend/app/repositories/simulation_params.py b/backend/app/repositories/simulation_params.py index 93587b2..8f1b06c 100644 --- a/backend/app/repositories/simulation_params.py +++ b/backend/app/repositories/simulation_params.py @@ -1,7 +1,6 @@ from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select +from sqlalchemy import select, Sequence from ..models.simulation_params import SimulationParams -from typing import List from ..schemas.simulation_params import SimulationParamsCreate @@ -9,7 +8,7 @@ class SimulationParamsRepository: def __init__(self, db: AsyncSession): self.db = db - async def get_simulation_params(self) -> List[SimulationParams]: + async def get_simulation_params(self) -> Sequence[SimulationParams]: stmt = select(SimulationParams) result = await self.db.execute(stmt) return result.scalars().all() @@ -27,7 +26,7 @@ async def update_simulation_params( if not simulation_params: return None - update_data = new_simulation_params.model_dump() + update_data = new_simulation_params.model_dump(exclude_unset=True) for key, value in update_data.items(): setattr(simulation_params, key, value) diff --git a/backend/app/repositories/sources.py b/backend/app/repositories/sources.py index 81e559e..9bbdc2f 100644 --- a/backend/app/repositories/sources.py +++ b/backend/app/repositories/sources.py @@ -1,30 +1,38 @@ from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select +from sqlalchemy import select, Sequence from ..models.sources import Sources -from typing import List +from ..schemas.sources import SourcesCreate class SourcesRepository: def __init__(self, db:AsyncSession): self.db = db - async def get_all_sources(self) -> List[Sources]: + async def get_all_sources(self) -> Sequence[Sources]: stmt = select(Sources) result = await self.db.execute(stmt) return result.scalars().all() - async def add_source(self, source_data: dict) -> Sources: + 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) -> None: - stmt = select(Sources).where(source_id==Sources.id) + async def del_source(self, source_id: int) -> bool: + stmt = select(Sources).where(Sources.id==source_id) result = await self.db.execute(stmt) source = result.scalars().first() + if not source: + return False + await self.db.delete(source) await self.db.commit() + + return True diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/pages.py b/backend/app/routers/pages.py new file mode 100644 index 0000000..a2e778d --- /dev/null +++ b/backend/app/routers/pages.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter +from fastapi.responses import FileResponse +from ..services.source_service import SourceService +from ..services.simulation_params_service import SimulationParamsService + + +router = APIRouter(tags=['pages']) + + +@router.get('/') +async def index(): + sources = SourceService.get_all_sources() + simulation_params = SimulationParamsService.get_simulation_params() + response = FileResponse("index.html") \ No newline at end of file diff --git a/backend/app/schemas/simulation_params.py b/backend/app/schemas/simulation_params.py index 3449c38..65b9421 100644 --- a/backend/app/schemas/simulation_params.py +++ b/backend/app/schemas/simulation_params.py @@ -9,3 +9,9 @@ class SimulationParamsBase(BaseModel): 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..53da572 --- /dev/null +++ b/backend/app/schemas/sources.py @@ -0,0 +1,20 @@ +from pydantic import BaseModel + + +class SourcesBase: + name: str + type: str + latitude: float + longitude: float + height: float + emission_rate: float + + +class SourcesResponse(SourcesBase): + + class Config: + from_attributes: True + + +class SourcesCreate(SourcesBase): + pass 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/simulation_params_service.py b/backend/app/services/simulation_params_service.py new file mode 100644 index 0000000..5cf9810 --- /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 update_simulation_params(self, new_simulation_params:SimulationParamsCreate): + result = await self.repository.update_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..1b2776c --- /dev/null +++ b/backend/app/services/source_service.py @@ -0,0 +1,20 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from ..repositories.sources import SourcesRepository +from ..schemas.sources import SourcesCreate + + +class SourceService: + def __init__(self, db: AsyncSession): + self.repository = SourcesRepository(db) + + async def add_source(self, source_schema: SourcesCreate): + result = await self.repository.add_source(source_schema) + return result + + async def delete_source(self, source_id: int): + result = await self.repository.del_source(source_id) + return result + + async def get_all_sources(self): + result = await self.repository.get_all_sources() + return result From d9b879596103b702bba1826449bf796e398ceb84 Mon Sep 17 00:00:00 2001 From: Khasnash Andrei Date: Sat, 21 Mar 2026 21:42:58 +0300 Subject: [PATCH 08/13] updated *.html files to match fastAPI requirements (changed filename to path) --- backend/app/core/__init__.py | 0 backend/app/core/config.py | 0 backend/app/core/templates.py | 0 instance/h2s_simulation.db | Bin 16384 -> 0 bytes templates/enterprise.html | 8 ++++---- templates/forecasting.html | 6 +++--- templates/history.html | 8 ++++---- templates/index.html | 8 ++++---- templates/layout.html | 2 +- templates/login.html | 6 +++--- templates/recommendations.html | 8 ++++---- 11 files changed, 23 insertions(+), 23 deletions(-) create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/templates.py delete mode 100644 instance/h2s_simulation.db diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/templates.py b/backend/app/core/templates.py new file mode 100644 index 0000000..e69de29 diff --git a/instance/h2s_simulation.db b/instance/h2s_simulation.db deleted file mode 100644 index e2b24630a6da753a8b132bd17c6eaa01f97d8a4d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI2eM}Q)7{Kp9DW9z}6(>$_f(}2J_uckfyyLJaYvB-DqklM7X_XqjY>UD+mnfnb zb$&B-Zn`Zd&Y0Zc({q=c& z&+|UVg=S4PIox(^cA+!h>c%LfAA+KYfME!NSiz$Yc*r9N{PO&BR6fze(Tcn|e?=6S z5FB|JQO{IIfCC)R05kv%Km*VKGyn}i1JD3801fW6`BCUS?sj6QEq) zLy$ki`{D~{086r%rPOr5NCQjiYS|M*Z?fE_MZM1P)RVi|X74Sld4&aYUT9*j-7zQE z-5buH?*P>c3fT$L+1unCr~O|i-E(jLdf)YkW}n8U8Kgd;cB_X+)kGCX#YbLNm8ud#ehyh0!t`wc2soetXaE|32L77? zX;K)XjZvvk6;;2fVNt{4`Xdd?>JQf+tgprxnqrFz9R+T|*q)-Uf7>jGpDf5}Z~i=v z|7KKM$%XcNBu+Ak1eu7l7(sC~%i)Y9Dg#M|q`g2B>Uel+eUTupql2<6b%Xh>yF-al zZKeJ|0_R9lqLhIIt?vZx4sR(MB8VGmZv$XFe|BxIb1!znABZy?Nl2tJkfvF!FT$XK zwbI5yLEQ8pGRsmE&sT3dGXL~`eE^W=NLnJ4fh5IrIy}n8O_?T$Tc#}pz-T^tYjsuX zPs;;A4_GLR;pMR?5C zsJ{-fT*w08Fn;^2v>)$$_1E)2l4GhIsy$q~%)U z$({Z{T+flL6sHWNS-B34PgRxcHna)i`m4V;R?dv)cdmCIn)~6S0HBVeXlXWO=wUP|H38Ce6Lom?4PU_HO~e62AH!hX3H^ z+5n)IqqWj7WgyLvlyB=HyUtJfs7w$y^U{H~uoC_UZ;uNAvYsWsrVM1Xw7lde-^Fh* z>=ML{hVcj5!b|vL56}HNXxZ2RAOm(v#!xRXsGELBGjg{vKCatW$mfCxD+YoHkxKql z-iqXni$Vg3G)L1?ta6BymSlWcxpH>&kTWfUSogSS$Ag5L zLwrEAYs=Qtj10V8wRGYap@LX*XGWCNOol&C0zHA^2)*=b525MgUTth_{o}!O&|1%a z-#l;N>25>AIZ)CRB@I>v>Or&it@T=TN?GevK|I$KSyjEiE0DlQlB0BhDaG^v)OPln zs{_v68EFG+wXdwI{#FSH8kb%gqzq(OQZAU5a&(pYQvhTHjgwFSf<8kt=>x+MM&Sag zB}h5DpIx2sTZAB z5MCibP*M!g?7M~>d#~F_y-+f% zf6q{mKe@{`<9n7&xG0FVIjaw}MJPeR8+1}XWuH2NmM_lDxrx`V9|uu7G+lv2B86iF zC~)xih)&Wd14+GJ2DZN2emUZbARb3Sgn`SJfdmP*v`HO?5DE$Mbwp!hM+XEuX25$* zlfOHS?``E5=p%$JP%E0ic+S@DFFz;5ybiqYMkKvM|*d)NM)M5R~nB*-Aq^o zN-=oPfwq1Dx$uk4bN`QO?jqm^4rl-xfCiueXaE|32A~0G02+V>paEzA8hCjOM4-X3 zsP9V{lf3u;7ZJ_HmuG=+CC~sg01ZF`&;T?54L}3X05kv%Km*Xg|Hwcb8iJ@a8cGPJ MItHHH&9@2u1|U`bi2wiq diff --git a/templates/enterprise.html b/templates/enterprise.html index 6df3b1c..e844d47 100644 --- a/templates/enterprise.html +++ b/templates/enterprise.html @@ -4,18 +4,18 @@ Система моделирования выбросов вредных веществ - - + + - +
- Логотип + Логотип

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