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/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 0000000..3154897 Binary files /dev/null and b/__pycache__/database.cpython-312.pyc differ 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 0000000..e2b2463 Binary files /dev/null and b/instance/h2s_simulation.db differ 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 0000000..7cf08a6 Binary files /dev/null and b/static/img/logo_white.png differ diff --git a/static/js/enterprise.js b/static/js/enterprise.js new file mode 100644 index 0000000..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