From 4f21c9d7c81c4bb0cf0c30861529dc371852428f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D1=88=D0=BA=D0=B8=D0=BD=20=D0=A1=D0=B5=D1=80?= =?UTF-8?q?=D0=B3=D0=B5=D0=B9?= Date: Tue, 11 Nov 2025 17:23:36 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9D=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B8?= =?UTF-8?q?=D0=BB=20=D1=81=D0=B5=D0=B5=D0=BB=D0=B5=D1=80=D0=B8,=20=D0=BD?= =?UTF-8?q?=D0=B0=D1=87=D0=B0=D0=BB=20=D0=BF=D1=80=D0=B8=D0=B2=D1=8F=D0=B7?= =?UTF-8?q?=D0=BA=D1=83=20lyngsat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.dev | 46 +- .env.prod | 56 +- .gitignore | 68 +- ASYNC_CHANGES_SUMMARY.md | 792 +- ASYNC_LYNGSAT_GUIDE.md | 840 +- CHANGES_SUMMARY.md | 266 +- DEPLOYMENT_CHECKLIST.md | 498 +- DEPLOYMENT_INSTRUCTIONS.md | 204 +- DOCKER_README.md | 524 +- DOCKER_SETUP.md | 614 +- FILES_OVERVIEW.md | 480 +- INSTALLATION_GUIDE.md | 694 +- LYNGSAT_FILL_GUIDE.md | 156 +- Makefile | 198 +- QUICKSTART.md | 212 +- QUICKSTART_ASYNC.md | 234 +- dbapp/.dockerignore | 120 +- dbapp/.env.example | 18 +- dbapp/.python-version | 1 + dbapp/CELERY_SETUP.md | 217 + dbapp/Dockerfile | 114 +- dbapp/dbapp/__init__.py | 15 +- dbapp/dbapp/asgi.py | 32 +- dbapp/dbapp/celery.py | 4 +- dbapp/dbapp/settings/__init__.py | 48 +- dbapp/dbapp/settings/base.py | 13 +- dbapp/dbapp/settings/development.py | 94 +- dbapp/dbapp/settings/production.py | 270 +- dbapp/dbapp/urls.py | 60 +- dbapp/dbapp/wsgi.py | 32 +- dbapp/entrypoint.sh | 74 +- dbapp/lyngsatapp/admin.py | 18 +- dbapp/lyngsatapp/apps.py | 12 +- dbapp/lyngsatapp/migrations/0001_initial.py | 74 +- .../0002_alter_lyngsat_last_update.py | 18 + dbapp/lyngsatapp/models.py | 74 +- dbapp/lyngsatapp/parser.py | 842 +- dbapp/lyngsatapp/tasks.py | 146 +- dbapp/lyngsatapp/tests.py | 6 +- dbapp/lyngsatapp/utils.py | 345 +- dbapp/lyngsatapp/views.py | 6 +- dbapp/mainapp/admin.py | 1684 +- dbapp/mainapp/clusters.py | 68 +- dbapp/mainapp/filters.py | 150 +- dbapp/mainapp/forms.py | 600 +- .../management/commands/test_celery.py | 24 + dbapp/mainapp/migrations/0001_initial.py | 408 +- ..._created_at_objitem_created_by_and_more.py | 70 +- ...tem_created_at_alter_objitem_updated_at.py | 46 +- ...add_remove_objitem_id_user_add_and_more.py | 50 +- .../migrations/0005_alter_geo_objitem.py | 38 +- ...user_options_alter_geo_options_and_more.py | 580 +- ...ve_parameter_objitems_parameter_objitem.py | 46 +- ...objitem_objitem_source_type_id_and_more.py | 63 + dbapp/mainapp/mixins.py | 458 +- dbapp/mainapp/models.py | 1568 +- dbapp/mainapp/popup_filters.py | 150 +- dbapp/mainapp/signals.py | 26 +- dbapp/mainapp/tasks.py | 65 + dbapp/mainapp/templates/admin/map_custom.html | 118 +- dbapp/mainapp/templates/mainapp/actions.html | 376 +- .../templates/mainapp/add_data_from_csv.html | 66 +- .../mainapp/add_data_from_excel.html | 70 +- dbapp/mainapp/templates/mainapp/base.html | 82 +- .../mainapp/components/_form_field.html | 66 +- .../mainapp/components/_messages.html | 50 +- .../templates/mainapp/components/_navbar.html | 114 +- .../mainapp/components/_table_header.html | 64 +- .../templates/mainapp/fill_lyngsat_data.html | 236 +- dbapp/mainapp/templates/mainapp/link_vch.html | 134 +- .../mainapp/lyngsat_task_status.html | 482 +- .../mainapp/objitem_confirm_delete.html | 50 +- .../templates/mainapp/objitem_detail.html | 944 +- .../templates/mainapp/objitem_form.html | 1204 +- .../templates/mainapp/process_kubsat.html | 102 +- .../mainapp/transponders_upload.html | 104 +- .../templates/mainapp/upload_html.html | 110 +- dbapp/mainapp/templatetags/__init__.py | 6 +- .../templatetags/coordinate_filters.py | 266 +- dbapp/mainapp/tests.py | 358 +- dbapp/mainapp/urls.py | 62 +- dbapp/mainapp/utils.py | 1294 +- dbapp/mainapp/views.py | 2252 +-- dbapp/manage.py | 44 +- dbapp/mapsapp/admin.py | 134 +- dbapp/mapsapp/apps.py | 12 +- dbapp/mapsapp/migrations/0001_initial.py | 74 +- ...002_alter_transponders_options_and_more.py | 128 +- dbapp/mapsapp/models.py | 234 +- dbapp/mapsapp/templates/mapsapp/map2d.html | 1120 +- .../mapsapp/templates/mapsapp/map2d_base.html | 164 +- dbapp/mapsapp/templates/mapsapp/map3d.html | 234 +- dbapp/mapsapp/tests.py | 6 +- dbapp/mapsapp/urls.py | 26 +- dbapp/mapsapp/utils.py | 330 +- dbapp/mapsapp/views.py | 294 +- dbapp/pyproject.toml | 4 + dbapp/requirements.txt | 33 - dbapp/start_celery_worker.sh | 10 +- .../static/leaflet-measure/languages/de.json | 60 +- dbapp/static/leaflet/leaflet-src.esm.js | 16806 ++++++++-------- dbapp/static/leaflet/leaflet-src.js | 16806 ++++++++-------- dbapp/static/leaflet/leaflet.css | 1280 +- dbapp/static/mapsapp/main.js | 2224 +- dbapp/static/mapsapp/style.css | 622 +- dbapp/transponders.json | 3314 +-- dbapp/uv.lock | 267 + docker-compose.prod.yaml | 190 +- docker-compose.yaml | 212 +- generate_secret_key.py | 34 +- 110 files changed, 34270 insertions(+), 33631 deletions(-) create mode 100644 dbapp/.python-version create mode 100644 dbapp/CELERY_SETUP.md create mode 100644 dbapp/lyngsatapp/migrations/0002_alter_lyngsat_last_update.py create mode 100644 dbapp/mainapp/management/commands/test_celery.py create mode 100644 dbapp/mainapp/migrations/0008_remove_sourcetype_objitem_objitem_source_type_id_and_more.py create mode 100644 dbapp/mainapp/tasks.py delete mode 100644 dbapp/requirements.txt diff --git a/.env.dev b/.env.dev index b5e0384..463871d 100644 --- a/.env.dev +++ b/.env.dev @@ -1,23 +1,23 @@ -# Development Environment Variables - -# Django Settings -DEBUG=True -ENVIRONMENT=development -DJANGO_SETTINGS_MODULE=dbapp.settings.development -SECRET_KEY=django-insecure-dev-key-only-for-development - -# Database Configuration -DB_ENGINE=django.contrib.gis.db.backends.postgis -DB_NAME=geodb -DB_USER=geralt -DB_PASSWORD=123456 -DB_HOST=db -DB_PORT=5432 - -# Allowed Hosts -ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0 - -# PostgreSQL Configuration -POSTGRES_DB=geodb -POSTGRES_USER=geralt -POSTGRES_PASSWORD=123456 +# Development Environment Variables + +# Django Settings +DEBUG=True +ENVIRONMENT=development +DJANGO_SETTINGS_MODULE=dbapp.settings.development +SECRET_KEY=django-insecure-dev-key-only-for-development + +# Database Configuration +DB_ENGINE=django.contrib.gis.db.backends.postgis +DB_NAME=geodb +DB_USER=geralt +DB_PASSWORD=123456 +DB_HOST=db +DB_PORT=5432 + +# Allowed Hosts +ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0 + +# PostgreSQL Configuration +POSTGRES_DB=geodb +POSTGRES_USER=geralt +POSTGRES_PASSWORD=123456 diff --git a/.env.prod b/.env.prod index c2619ec..f79bf3c 100644 --- a/.env.prod +++ b/.env.prod @@ -1,28 +1,28 @@ -# Production Environment Variables -# ВАЖНО: Измените все значения перед деплоем! - -# Django Settings -DEBUG=False -ENVIRONMENT=production -DJANGO_SETTINGS_MODULE=dbapp.settings.production -SECRET_KEY=change-this-to-a-very-long-random-secret-key-in-production - -# Database Configuration -DB_ENGINE=django.contrib.gis.db.backends.postgis -DB_NAME=geodb -DB_USER=geralt -DB_PASSWORD=CHANGE_THIS_STRONG_PASSWORD -DB_HOST=db -DB_PORT=5432 - -# Allowed Hosts (comma-separated) -ALLOWED_HOSTS=localhost,127.0.0.1,yourdomain.com - -# PostgreSQL Configuration -POSTGRES_DB=geodb -POSTGRES_USER=geralt -POSTGRES_PASSWORD=CHANGE_THIS_STRONG_PASSWORD - -# Gunicorn Configuration -GUNICORN_WORKERS=3 -GUNICORN_TIMEOUT=120 +# Production Environment Variables +# ВАЖНО: Измените все значения перед деплоем! + +# Django Settings +DEBUG=False +ENVIRONMENT=production +DJANGO_SETTINGS_MODULE=dbapp.settings.production +SECRET_KEY=change-this-to-a-very-long-random-secret-key-in-production + +# Database Configuration +DB_ENGINE=django.contrib.gis.db.backends.postgis +DB_NAME=geodb +DB_USER=geralt +DB_PASSWORD=CHANGE_THIS_STRONG_PASSWORD +DB_HOST=db +DB_PORT=5432 + +# Allowed Hosts (comma-separated) +ALLOWED_HOSTS=localhost,127.0.0.1,yourdomain.com + +# PostgreSQL Configuration +POSTGRES_DB=geodb +POSTGRES_USER=geralt +POSTGRES_PASSWORD=CHANGE_THIS_STRONG_PASSWORD + +# Gunicorn Configuration +GUNICORN_WORKERS=3 +GUNICORN_TIMEOUT=120 diff --git a/.gitignore b/.gitignore index 5bbf4fe..42658b3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,35 +1,35 @@ -# Python-generated files -__pycache__/ -*.py[oc] -build/ -dist/ -wheels/ -*.egg-info - -# Virtual environments -.venv -.hintrc -.vscode -data.json - -# Environment files -.env -.env.local -.env.*.local - -# Django -*.log -db.sqlite3 -db.sqlite3-journal -staticfiles/ -media/ - -django-leaflet -admin-interface -Тестовые -tiles -.kiro - -# Docker -# docker-* +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv +.hintrc +.vscode +data.json + +# Environment files +.env +.env.local +.env.*.local + +# Django +*.log +db.sqlite3 +db.sqlite3-journal +staticfiles/ +media/ + +django-leaflet +admin-interface +Тестовые +tiles +.kiro + +# Docker +# docker-* maplibre-gl-js-5.10.0.zip \ No newline at end of file diff --git a/ASYNC_CHANGES_SUMMARY.md b/ASYNC_CHANGES_SUMMARY.md index 740b4f1..5c2ee37 100644 --- a/ASYNC_CHANGES_SUMMARY.md +++ b/ASYNC_CHANGES_SUMMARY.md @@ -1,396 +1,396 @@ -# Сводка изменений: Асинхронная обработка данных Lyngsat - -## Обзор - -Реализована полная асинхронная обработка данных Lyngsat с использованием Celery, Redis и детальным логированием. - -## Ключевые улучшения - -### 1. ✅ Асинхронная обработка -- Задачи выполняются в фоновом режиме -- Веб-интерфейс не блокируется -- Можно обрабатывать несколько задач одновременно - -### 2. ✅ Отслеживание прогресса -- Прогресс-бар в реальном времени -- Текущий статус обработки -- Процент выполнения - -### 3. ✅ Детальное логирование -- Логи на уровне задачи -- Логи на уровне спутника -- Логи на уровне источника -- Все ошибки записываются в лог - -### 4. ✅ Результаты и статистика -- Количество обработанных спутников -- Количество обработанных источников -- Количество созданных/обновленных записей -- Список всех ошибок - -## Новые файлы - -### Backend -1. **dbapp/dbapp/celery.py** - конфигурация Celery -2. **dbapp/dbapp/__init__.py** - инициализация Celery app -3. **dbapp/lyngsatapp/tasks.py** - асинхронная задача заполнения данных -4. **dbapp/start_celery_worker.sh** - скрипт запуска worker - -### Frontend -5. **dbapp/mainapp/templates/mainapp/lyngsat_task_status.html** - страница отслеживания прогресса - -### Документация -6. **ASYNC_LYNGSAT_GUIDE.md** - полное руководство -7. **QUICKSTART_ASYNC.md** - быстрый старт -8. **ASYNC_CHANGES_SUMMARY.md** - этот файл - -## Измененные файлы - -### Конфигурация -1. **dbapp/requirements.txt** - - Добавлено: `celery>=5.4.0` - - Добавлено: `django-celery-results>=2.5.1` - -2. **dbapp/dbapp/settings/base.py** - - Добавлено: `django_celery_results` в INSTALLED_APPS - - Добавлено: полная конфигурация Celery (брокер, результаты, таймауты, логирование) - -3. **docker-compose.yaml** - - Добавлено: сервис Redis - - Добавлено: сервис FlareSolver - - Добавлено: volume для Redis - -### Backend логика -4. **dbapp/lyngsatapp/utils.py** - - Добавлено: параметр `task_id` для логирования - - Добавлено: параметр `update_progress` для обновления прогресса - - Добавлено: детальное логирование на всех уровнях - - Добавлено: логирование каждые 10 источников - - Улучшено: обработка ошибок с логированием - -5. **dbapp/mainapp/views.py** - - Изменено: `FillLyngsatDataView` теперь запускает асинхронную задачу - - Добавлено: `LyngsatTaskStatusView` - страница отслеживания - - Добавлено: `LyngsatTaskStatusAPIView` - API для проверки статуса - -6. **dbapp/mainapp/urls.py** - - Добавлено: `/lyngsat-task-status/` - страница статуса - - Добавлено: `/lyngsat-task-status//` - статус конкретной задачи - - Добавлено: `/api/lyngsat-task-status//` - API endpoint - -## Технические детали - -### Архитектура - -``` -User Request → Django View → Celery Task → Redis Broker - ↓ - Celery Worker - ↓ - ┌───────────┴───────────┐ - ↓ ↓ - LyngSat Parser PostgreSQL - ↓ ↓ - FlareSolver Save Results -``` - -### Поток данных - -1. **Пользователь отправляет форму** - - Django view получает данные - - Создается асинхронная задача Celery - - Возвращается task_id - - Перенаправление на страницу статуса - -2. **Celery Worker обрабатывает задачу** - - Логирует начало обработки - - Вызывает `fill_lyngsat_data` с callback - - Обновляет прогресс через `update_state` - - Логирует каждый шаг - - Сохраняет результат в кеш - -3. **Страница статуса отслеживает прогресс** - - JavaScript опрашивает API каждые 2 секунды - - Обновляет прогресс-бар - - Показывает текущий статус - - Отображает результаты при завершении - -### Логирование - -#### Уровни логирования -- **INFO**: Основные события (начало, завершение, прогресс) -- **DEBUG**: Детальная информация (каждая запись) -- **WARNING**: Некритичные ошибки (спутник не найден) -- **ERROR**: Критичные ошибки (с traceback) - -#### Формат логов -``` -[Timestamp: Level/Process][Task Name(Task ID)] [Task ID] Message -``` - -Пример: -``` -[2024-01-15 10:30:45: INFO/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Начало обработки данных Lyngsat -[2024-01-15 10:30:45: INFO/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Спутники: Astra 4A, Hotbird 13G -[2024-01-15 10:30:46: INFO/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Получено данных по 2 спутникам -[2024-01-15 10:31:00: INFO/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Обработка спутника 1/2: Astra 4A -[2024-01-15 10:31:00: INFO/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Найдено 150 источников для Astra 4A -[2024-01-15 10:31:05: DEBUG/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Создана запись для Astra 4A 11766.0 МГц -[2024-01-15 10:31:10: INFO/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Обработано 10/150 источников для Astra 4A -``` - -### API Endpoints - -#### GET /api/lyngsat-task-status// - -**Ответ при выполнении:** -```json -{ - "task_id": "abc123", - "state": "PROGRESS", - "status": "Обработка Astra 4A...", - "current": 1, - "total": 2, - "percent": 50 -} -``` - -**Ответ при успехе:** -```json -{ - "task_id": "abc123", - "state": "SUCCESS", - "status": "Задача завершена успешно", - "result": { - "total_satellites": 2, - "total_sources": 300, - "created": 250, - "updated": 50, - "errors": [] - } -} -``` - -**Ответ при ошибке:** -```json -{ - "task_id": "abc123", - "state": "FAILURE", - "status": "Ошибка при выполнении задачи", - "error": "Connection timeout" -} -``` - -## Настройки Celery - -### Основные параметры -```python -CELERY_BROKER_URL = 'redis://localhost:6379/0' -CELERY_RESULT_BACKEND = 'django-db' -CELERY_TASK_TRACK_STARTED = True -CELERY_TASK_TIME_LIMIT = 30 * 60 # 30 минут -``` - -### Переменные окружения -Можно переопределить через `.env`: -```bash -CELERY_BROKER_URL=redis://redis:6379/0 -``` - -## Зависимости - -### Обязательные сервисы -1. **Redis** - брокер сообщений Celery -2. **FlareSolver** - обход Cloudflare -3. **PostgreSQL** - хранение данных и результатов - -### Python пакеты -- `celery>=5.4.0` - асинхронная обработка -- `django-celery-results>=2.5.1` - хранение результатов -- `redis>=6.4.0` - клиент Redis - -## Команды для работы - -### Запуск сервисов -```bash -# Redis и FlareSolver -docker-compose up -d redis flaresolverr - -# Celery Worker -celery -A dbapp worker --loglevel=info - -# Celery Worker в фоне -celery -A dbapp worker --loglevel=info --logfile=logs/celery_worker.log --detach -``` - -### Мониторинг -```bash -# Просмотр логов -tail -f dbapp/logs/celery_worker.log - -# Flower (веб-интерфейс) -pip install flower -celery -A dbapp flower -# Откройте http://localhost:5555 -``` - -### Отладка -```bash -# Проверка Redis -redis-cli ping - -# Проверка FlareSolver -curl http://localhost:8191/v1 - -# Django shell -python manage.py shell ->>> from celery.result import AsyncResult ->>> task = AsyncResult('task_id') ->>> print(task.state, task.info) -``` - -## Производственное развертывание - -### Systemd сервис -```bash -sudo systemctl enable celery-worker -sudo systemctl start celery-worker -sudo systemctl status celery-worker -``` - -### Supervisor -```bash -sudo supervisorctl start celery-worker -sudo supervisorctl status celery-worker -``` - -### Docker -Можно добавить Celery worker в docker-compose.yaml: -```yaml -celery-worker: - build: ./dbapp - command: celery -A dbapp worker --loglevel=info - depends_on: - - redis - - db - environment: - - CELERY_BROKER_URL=redis://redis:6379/0 -``` - -## Тестирование - -### Проверка системы -```bash -# 1. Проверка Django -python manage.py check - -# 2. Проверка миграций -python manage.py migrate --check - -# 3. Проверка Celery -celery -A dbapp inspect ping - -# 4. Проверка Redis -redis-cli ping - -# 5. Проверка FlareSolver -curl http://localhost:8191/v1 -``` - -### Тестовый запуск -```python -# Django shell -python manage.py shell - -from lyngsatapp.tasks import fill_lyngsat_data_task - -# Запуск задачи -task = fill_lyngsat_data_task.delay(['Astra 4A'], ['europe']) -print(f"Task ID: {task.id}") - -# Проверка статуса -print(task.state) -print(task.info) -``` - -## Метрики и мониторинг - -### Что отслеживать -- Количество активных workers -- Количество задач в очереди -- Среднее время выполнения задачи -- Количество ошибок -- Использование памяти Redis - -### Инструменты -- **Flower** - веб-интерфейс для Celery -- **Redis Commander** - GUI для Redis -- **Prometheus + Grafana** - метрики и дашборды - -## Безопасность - -### Рекомендации -1. Используйте пароль для Redis в production -2. Ограничьте доступ к Redis только для localhost -3. Используйте SSL для Redis в production -4. Ограничьте время выполнения задач -5. Логируйте все действия - -### Пример конфигурации Redis с паролем -```python -CELERY_BROKER_URL = 'redis://:password@localhost:6379/0' -``` - -## Масштабирование - -### Горизонтальное масштабирование -Запустите несколько workers: -```bash -# Worker 1 -celery -A dbapp worker --loglevel=info -n worker1@%h - -# Worker 2 -celery -A dbapp worker --loglevel=info -n worker2@%h - -# Worker 3 -celery -A dbapp worker --loglevel=info -n worker3@%h -``` - -### Приоритеты задач -Можно настроить разные очереди для разных типов задач: -```python -@shared_task(queue='high_priority') -def urgent_task(): - pass - -@shared_task(queue='low_priority') -def background_task(): - pass -``` - -## Следующие шаги - -1. ✅ Применить миграции -2. ✅ Запустить Redis и FlareSolver -3. ✅ Запустить Celery Worker -4. ✅ Протестировать через веб-интерфейс -5. ⏳ Настроить production окружение -6. ⏳ Добавить периодические задачи (Celery Beat) -7. ⏳ Настроить email уведомления -8. ⏳ Настроить мониторинг (Flower) - -## Заключение - -Система асинхронной обработки данных Lyngsat обеспечивает: -- ✅ Неблокирующий веб-интерфейс -- ✅ Отслеживание прогресса в реальном времени -- ✅ Детальное логирование всех операций -- ✅ Масштабируемость (несколько workers) -- ✅ Надежность (retry при ошибках) -- ✅ Мониторинг и отладка -- ✅ Production-ready решение - -Для получения дополнительной помощи: -- Полное руководство: `ASYNC_LYNGSAT_GUIDE.md` -- Быстрый старт: `QUICKSTART_ASYNC.md` -- Документация Celery: https://docs.celeryproject.org/ +# Сводка изменений: Асинхронная обработка данных Lyngsat + +## Обзор + +Реализована полная асинхронная обработка данных Lyngsat с использованием Celery, Redis и детальным логированием. + +## Ключевые улучшения + +### 1. ✅ Асинхронная обработка +- Задачи выполняются в фоновом режиме +- Веб-интерфейс не блокируется +- Можно обрабатывать несколько задач одновременно + +### 2. ✅ Отслеживание прогресса +- Прогресс-бар в реальном времени +- Текущий статус обработки +- Процент выполнения + +### 3. ✅ Детальное логирование +- Логи на уровне задачи +- Логи на уровне спутника +- Логи на уровне источника +- Все ошибки записываются в лог + +### 4. ✅ Результаты и статистика +- Количество обработанных спутников +- Количество обработанных источников +- Количество созданных/обновленных записей +- Список всех ошибок + +## Новые файлы + +### Backend +1. **dbapp/dbapp/celery.py** - конфигурация Celery +2. **dbapp/dbapp/__init__.py** - инициализация Celery app +3. **dbapp/lyngsatapp/tasks.py** - асинхронная задача заполнения данных +4. **dbapp/start_celery_worker.sh** - скрипт запуска worker + +### Frontend +5. **dbapp/mainapp/templates/mainapp/lyngsat_task_status.html** - страница отслеживания прогресса + +### Документация +6. **ASYNC_LYNGSAT_GUIDE.md** - полное руководство +7. **QUICKSTART_ASYNC.md** - быстрый старт +8. **ASYNC_CHANGES_SUMMARY.md** - этот файл + +## Измененные файлы + +### Конфигурация +1. **dbapp/requirements.txt** + - Добавлено: `celery>=5.4.0` + - Добавлено: `django-celery-results>=2.5.1` + +2. **dbapp/dbapp/settings/base.py** + - Добавлено: `django_celery_results` в INSTALLED_APPS + - Добавлено: полная конфигурация Celery (брокер, результаты, таймауты, логирование) + +3. **docker-compose.yaml** + - Добавлено: сервис Redis + - Добавлено: сервис FlareSolver + - Добавлено: volume для Redis + +### Backend логика +4. **dbapp/lyngsatapp/utils.py** + - Добавлено: параметр `task_id` для логирования + - Добавлено: параметр `update_progress` для обновления прогресса + - Добавлено: детальное логирование на всех уровнях + - Добавлено: логирование каждые 10 источников + - Улучшено: обработка ошибок с логированием + +5. **dbapp/mainapp/views.py** + - Изменено: `FillLyngsatDataView` теперь запускает асинхронную задачу + - Добавлено: `LyngsatTaskStatusView` - страница отслеживания + - Добавлено: `LyngsatTaskStatusAPIView` - API для проверки статуса + +6. **dbapp/mainapp/urls.py** + - Добавлено: `/lyngsat-task-status/` - страница статуса + - Добавлено: `/lyngsat-task-status//` - статус конкретной задачи + - Добавлено: `/api/lyngsat-task-status//` - API endpoint + +## Технические детали + +### Архитектура + +``` +User Request → Django View → Celery Task → Redis Broker + ↓ + Celery Worker + ↓ + ┌───────────┴───────────┐ + ↓ ↓ + LyngSat Parser PostgreSQL + ↓ ↓ + FlareSolver Save Results +``` + +### Поток данных + +1. **Пользователь отправляет форму** + - Django view получает данные + - Создается асинхронная задача Celery + - Возвращается task_id + - Перенаправление на страницу статуса + +2. **Celery Worker обрабатывает задачу** + - Логирует начало обработки + - Вызывает `fill_lyngsat_data` с callback + - Обновляет прогресс через `update_state` + - Логирует каждый шаг + - Сохраняет результат в кеш + +3. **Страница статуса отслеживает прогресс** + - JavaScript опрашивает API каждые 2 секунды + - Обновляет прогресс-бар + - Показывает текущий статус + - Отображает результаты при завершении + +### Логирование + +#### Уровни логирования +- **INFO**: Основные события (начало, завершение, прогресс) +- **DEBUG**: Детальная информация (каждая запись) +- **WARNING**: Некритичные ошибки (спутник не найден) +- **ERROR**: Критичные ошибки (с traceback) + +#### Формат логов +``` +[Timestamp: Level/Process][Task Name(Task ID)] [Task ID] Message +``` + +Пример: +``` +[2024-01-15 10:30:45: INFO/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Начало обработки данных Lyngsat +[2024-01-15 10:30:45: INFO/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Спутники: Astra 4A, Hotbird 13G +[2024-01-15 10:30:46: INFO/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Получено данных по 2 спутникам +[2024-01-15 10:31:00: INFO/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Обработка спутника 1/2: Astra 4A +[2024-01-15 10:31:00: INFO/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Найдено 150 источников для Astra 4A +[2024-01-15 10:31:05: DEBUG/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Создана запись для Astra 4A 11766.0 МГц +[2024-01-15 10:31:10: INFO/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Обработано 10/150 источников для Astra 4A +``` + +### API Endpoints + +#### GET /api/lyngsat-task-status// + +**Ответ при выполнении:** +```json +{ + "task_id": "abc123", + "state": "PROGRESS", + "status": "Обработка Astra 4A...", + "current": 1, + "total": 2, + "percent": 50 +} +``` + +**Ответ при успехе:** +```json +{ + "task_id": "abc123", + "state": "SUCCESS", + "status": "Задача завершена успешно", + "result": { + "total_satellites": 2, + "total_sources": 300, + "created": 250, + "updated": 50, + "errors": [] + } +} +``` + +**Ответ при ошибке:** +```json +{ + "task_id": "abc123", + "state": "FAILURE", + "status": "Ошибка при выполнении задачи", + "error": "Connection timeout" +} +``` + +## Настройки Celery + +### Основные параметры +```python +CELERY_BROKER_URL = 'redis://localhost:6379/0' +CELERY_RESULT_BACKEND = 'django-db' +CELERY_TASK_TRACK_STARTED = True +CELERY_TASK_TIME_LIMIT = 30 * 60 # 30 минут +``` + +### Переменные окружения +Можно переопределить через `.env`: +```bash +CELERY_BROKER_URL=redis://redis:6379/0 +``` + +## Зависимости + +### Обязательные сервисы +1. **Redis** - брокер сообщений Celery +2. **FlareSolver** - обход Cloudflare +3. **PostgreSQL** - хранение данных и результатов + +### Python пакеты +- `celery>=5.4.0` - асинхронная обработка +- `django-celery-results>=2.5.1` - хранение результатов +- `redis>=6.4.0` - клиент Redis + +## Команды для работы + +### Запуск сервисов +```bash +# Redis и FlareSolver +docker-compose up -d redis flaresolverr + +# Celery Worker +celery -A dbapp worker --loglevel=info + +# Celery Worker в фоне +celery -A dbapp worker --loglevel=info --logfile=logs/celery_worker.log --detach +``` + +### Мониторинг +```bash +# Просмотр логов +tail -f dbapp/logs/celery_worker.log + +# Flower (веб-интерфейс) +pip install flower +celery -A dbapp flower +# Откройте http://localhost:5555 +``` + +### Отладка +```bash +# Проверка Redis +redis-cli ping + +# Проверка FlareSolver +curl http://localhost:8191/v1 + +# Django shell +python manage.py shell +>>> from celery.result import AsyncResult +>>> task = AsyncResult('task_id') +>>> print(task.state, task.info) +``` + +## Производственное развертывание + +### Systemd сервис +```bash +sudo systemctl enable celery-worker +sudo systemctl start celery-worker +sudo systemctl status celery-worker +``` + +### Supervisor +```bash +sudo supervisorctl start celery-worker +sudo supervisorctl status celery-worker +``` + +### Docker +Можно добавить Celery worker в docker-compose.yaml: +```yaml +celery-worker: + build: ./dbapp + command: celery -A dbapp worker --loglevel=info + depends_on: + - redis + - db + environment: + - CELERY_BROKER_URL=redis://redis:6379/0 +``` + +## Тестирование + +### Проверка системы +```bash +# 1. Проверка Django +python manage.py check + +# 2. Проверка миграций +python manage.py migrate --check + +# 3. Проверка Celery +celery -A dbapp inspect ping + +# 4. Проверка Redis +redis-cli ping + +# 5. Проверка FlareSolver +curl http://localhost:8191/v1 +``` + +### Тестовый запуск +```python +# Django shell +python manage.py shell + +from lyngsatapp.tasks import fill_lyngsat_data_task + +# Запуск задачи +task = fill_lyngsat_data_task.delay(['Astra 4A'], ['europe']) +print(f"Task ID: {task.id}") + +# Проверка статуса +print(task.state) +print(task.info) +``` + +## Метрики и мониторинг + +### Что отслеживать +- Количество активных workers +- Количество задач в очереди +- Среднее время выполнения задачи +- Количество ошибок +- Использование памяти Redis + +### Инструменты +- **Flower** - веб-интерфейс для Celery +- **Redis Commander** - GUI для Redis +- **Prometheus + Grafana** - метрики и дашборды + +## Безопасность + +### Рекомендации +1. Используйте пароль для Redis в production +2. Ограничьте доступ к Redis только для localhost +3. Используйте SSL для Redis в production +4. Ограничьте время выполнения задач +5. Логируйте все действия + +### Пример конфигурации Redis с паролем +```python +CELERY_BROKER_URL = 'redis://:password@localhost:6379/0' +``` + +## Масштабирование + +### Горизонтальное масштабирование +Запустите несколько workers: +```bash +# Worker 1 +celery -A dbapp worker --loglevel=info -n worker1@%h + +# Worker 2 +celery -A dbapp worker --loglevel=info -n worker2@%h + +# Worker 3 +celery -A dbapp worker --loglevel=info -n worker3@%h +``` + +### Приоритеты задач +Можно настроить разные очереди для разных типов задач: +```python +@shared_task(queue='high_priority') +def urgent_task(): + pass + +@shared_task(queue='low_priority') +def background_task(): + pass +``` + +## Следующие шаги + +1. ✅ Применить миграции +2. ✅ Запустить Redis и FlareSolver +3. ✅ Запустить Celery Worker +4. ✅ Протестировать через веб-интерфейс +5. ⏳ Настроить production окружение +6. ⏳ Добавить периодические задачи (Celery Beat) +7. ⏳ Настроить email уведомления +8. ⏳ Настроить мониторинг (Flower) + +## Заключение + +Система асинхронной обработки данных Lyngsat обеспечивает: +- ✅ Неблокирующий веб-интерфейс +- ✅ Отслеживание прогресса в реальном времени +- ✅ Детальное логирование всех операций +- ✅ Масштабируемость (несколько workers) +- ✅ Надежность (retry при ошибках) +- ✅ Мониторинг и отладка +- ✅ Production-ready решение + +Для получения дополнительной помощи: +- Полное руководство: `ASYNC_LYNGSAT_GUIDE.md` +- Быстрый старт: `QUICKSTART_ASYNC.md` +- Документация Celery: https://docs.celeryproject.org/ diff --git a/ASYNC_LYNGSAT_GUIDE.md b/ASYNC_LYNGSAT_GUIDE.md index e7b3ad9..5a57cf0 100644 --- a/ASYNC_LYNGSAT_GUIDE.md +++ b/ASYNC_LYNGSAT_GUIDE.md @@ -1,420 +1,420 @@ -# Руководство по асинхронному заполнению данных Lyngsat - -## Обзор - -Система заполнения данных Lyngsat теперь работает асинхронно с использованием Celery. Это позволяет: -- Не блокировать веб-интерфейс во время долгих операций -- Отслеживать прогресс выполнения задачи в реальном времени -- Просматривать детальные логи обработки -- Получать уведомления о завершении задачи - -## Архитектура - -``` -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ Django │─────▶│ Celery │─────▶│ Redis │ -│ Web App │ │ Worker │ │ Broker │ -└─────────────┘ └─────────────┘ └─────────────┘ - │ │ - │ ▼ - │ ┌─────────────┐ - └─────────────▶│ PostgreSQL │ - │ Database │ - └─────────────┘ -``` - -## Установка и настройка - -### 1. Установка зависимостей - -```bash -pip install -r requirements.txt -``` - -Новые зависимости: -- `celery>=5.4.0` - асинхронная обработка задач -- `django-celery-results>=2.5.1` - хранение результатов в БД - -### 2. Применение миграций - -```bash -cd dbapp -python manage.py migrate -``` - -Это создаст таблицы для хранения результатов Celery. - -### 3. Запуск Redis - -Redis используется как брокер сообщений для Celery. - -#### Вариант 1: Docker Compose (рекомендуется) -```bash -docker-compose up -d redis -``` - -#### Вариант 2: Локальная установка -```bash -# Ubuntu/Debian -sudo apt-get install redis-server -sudo systemctl start redis - -# macOS -brew install redis -brew services start redis - -# Проверка -redis-cli ping -# Должно вернуть: PONG -``` - -### 4. Запуск FlareSolver - -FlareSolver необходим для обхода защиты Cloudflare. - -```bash -docker-compose up -d flaresolverr -``` - -Или отдельно: -```bash -docker run -d -p 8191:8191 --name flaresolverr ghcr.io/flaresolverr/flaresolverr:latest -``` - -### 5. Запуск Celery Worker - -#### Вариант 1: Используя скрипт -```bash -cd dbapp -./start_celery_worker.sh -``` - -#### Вариант 2: Напрямую -```bash -cd dbapp -celery -A dbapp worker --loglevel=info -``` - -#### Вариант 3: В фоновом режиме (Linux/macOS) -```bash -cd dbapp -celery -A dbapp worker --loglevel=info --logfile=logs/celery_worker.log --detach -``` - -## Использование - -### 1. Запуск задачи через веб-интерфейс - -1. Откройте страницу действий: `http://localhost:8000/actions/` -2. Нажмите "Заполнить данные Lyngsat" -3. Выберите спутники и регионы -4. Нажмите "Заполнить данные" -5. Вы будете перенаправлены на страницу отслеживания прогресса - -### 2. Отслеживание прогресса - -На странице статуса задачи вы увидите: -- **Прогресс-бар** с процентом выполнения -- **Текущий статус** (например, "Обработка Astra 4A...") -- **Состояние задачи** (PENDING, PROGRESS, SUCCESS, FAILURE) -- **Результаты** после завершения: - - Количество обработанных спутников - - Количество обработанных источников - - Количество созданных записей - - Количество обновленных записей - - Список ошибок (если есть) - -Страница автоматически обновляется каждые 2 секунды. - -### 3. Просмотр логов - -Логи Celery worker содержат детальную информацию о процессе: - -```bash -# Просмотр логов в реальном времени -tail -f dbapp/logs/celery_worker.log - -# Поиск по логам -grep "Task" dbapp/logs/celery_worker.log -grep "ERROR" dbapp/logs/celery_worker.log -``` - -Формат логов: -``` -[2024-01-15 10:30:45: INFO/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Начало обработки данных Lyngsat -[2024-01-15 10:30:45: INFO/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Спутники: Astra 4A, Hotbird 13G -[2024-01-15 10:30:46: INFO/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Получено данных по 2 спутникам -[2024-01-15 10:31:00: INFO/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Обработка спутника 1/2: Astra 4A -``` - -## Технические детали - -### Структура задачи - -**Файл**: `dbapp/lyngsatapp/tasks.py` - -```python -@shared_task(bind=True, name='lyngsatapp.fill_lyngsat_data_async') -def fill_lyngsat_data_task(self, target_sats, regions=None): - # Логирование начала - # Обновление прогресса - # Вызов функции заполнения - # Сохранение результата в кеш - # Обработка ошибок -``` - -### Обновление прогресса - -Функция `fill_lyngsat_data` теперь принимает callback `update_progress`: - -```python -def update_progress(current, total, status): - self.update_state( - state='PROGRESS', - meta={ - 'current': current, - 'total': total, - 'status': status - } - ) -``` - -### API для проверки статуса - -**Endpoint**: `/api/lyngsat-task-status//` - -**Ответ**: -```json -{ - "task_id": "abc123", - "state": "PROGRESS", - "status": "Обработка Astra 4A...", - "current": 1, - "total": 2, - "percent": 50 -} -``` - -### Логирование - -Используется стандартный модуль `logging` Python: - -```python -import logging -logger = logging.getLogger(__name__) - -logger.info(f"[Task {task_id}] Начало обработки") -logger.debug(f"[Task {task_id}] Детальная информация") -logger.warning(f"[Task {task_id}] Предупреждение") -logger.error(f"[Task {task_id}] Ошибка", exc_info=True) -``` - -## Настройки Celery - -**Файл**: `dbapp/dbapp/settings/base.py` - -```python -# Брокер сообщений -CELERY_BROKER_URL = 'redis://localhost:6379/0' - -# Хранение результатов -CELERY_RESULT_BACKEND = 'django-db' - -# Таймауты -CELERY_TASK_TIME_LIMIT = 30 * 60 # 30 минут -CELERY_TASK_SOFT_TIME_LIMIT = 25 * 60 # 25 минут - -# Отслеживание прогресса -CELERY_TASK_TRACK_STARTED = True -``` - -## Мониторинг и отладка - -### Flower - веб-интерфейс для мониторинга Celery - -Установка: -```bash -pip install flower -``` - -Запуск: -```bash -celery -A dbapp flower -``` - -Откройте: `http://localhost:5555` - -### Проверка статуса задачи через Django shell - -```python -python manage.py shell - -from celery.result import AsyncResult - -task_id = 'abc123' -task = AsyncResult(task_id) - -print(f"State: {task.state}") -print(f"Info: {task.info}") -print(f"Result: {task.result}") -``` - -### Очистка старых результатов - -```bash -# Удалить результаты старше 1 дня -python manage.py celery_results_cleanup --days=1 -``` - -## Решение проблем - -### Проблема: Worker не запускается - -**Решение**: -1. Проверьте, что Redis запущен: `redis-cli ping` -2. Проверьте настройки в `.env`: `CELERY_BROKER_URL` -3. Проверьте логи: `tail -f logs/celery_worker.log` - -### Проблема: Задача зависла в состоянии PENDING - -**Решение**: -1. Проверьте, что worker запущен: `ps aux | grep celery` -2. Перезапустите worker -3. Проверьте соединение с Redis - -### Проблема: Задача завершается с ошибкой - -**Решение**: -1. Проверьте логи worker -2. Проверьте, что FlareSolver запущен: `curl http://localhost:8191/v1` -3. Проверьте, что спутники существуют в базе данных - -### Проблема: Прогресс не обновляется - -**Решение**: -1. Откройте консоль браузера (F12) и проверьте ошибки -2. Проверьте, что API endpoint доступен: `/api/lyngsat-task-status//` -3. Очистите кеш браузера - -## Производственное развертывание - -### Systemd сервис для Celery Worker - -Создайте файл `/etc/systemd/system/celery-worker.service`: - -```ini -[Unit] -Description=Celery Worker for Django Lyngsat -After=network.target redis.service - -[Service] -Type=forking -User=www-data -Group=www-data -WorkingDirectory=/path/to/dbapp -Environment="PATH=/path/to/venv/bin" -ExecStart=/path/to/venv/bin/celery -A dbapp worker --loglevel=info --logfile=/var/log/celery/worker.log --detach -ExecStop=/path/to/venv/bin/celery -A dbapp control shutdown -Restart=always - -[Install] -WantedBy=multi-user.target -``` - -Запуск: -```bash -sudo systemctl daemon-reload -sudo systemctl enable celery-worker -sudo systemctl start celery-worker -sudo systemctl status celery-worker -``` - -### Supervisor (альтернатива) - -Установка: -```bash -sudo apt-get install supervisor -``` - -Конфигурация `/etc/supervisor/conf.d/celery.conf`: -```ini -[program:celery-worker] -command=/path/to/venv/bin/celery -A dbapp worker --loglevel=info -directory=/path/to/dbapp -user=www-data -autostart=true -autorestart=true -stdout_logfile=/var/log/celery/worker.log -stderr_logfile=/var/log/celery/worker_error.log -``` - -Запуск: -```bash -sudo supervisorctl reread -sudo supervisorctl update -sudo supervisorctl start celery-worker -``` - -## Дополнительные возможности - -### Периодические задачи (Celery Beat) - -Для автоматического обновления данных по расписанию: - -1. Установите `django-celery-beat`: -```bash -pip install django-celery-beat -``` - -2. Добавьте в `INSTALLED_APPS`: -```python -INSTALLED_APPS = [ - ... - 'django_celery_beat', -] -``` - -3. Примените миграции: -```bash -python manage.py migrate django_celery_beat -``` - -4. Создайте периодическую задачу через админ-панель Django - -5. Запустите beat scheduler: -```bash -celery -A dbapp beat --loglevel=info -``` - -### Уведомления по email - -Добавьте в задачу отправку email при завершении: - -```python -from django.core.mail import send_mail - -@shared_task(bind=True) -def fill_lyngsat_data_task(self, target_sats, regions=None): - # ... обработка ... - - # Отправка email - send_mail( - 'Задача Lyngsat завершена', - f'Обработано {stats["total_satellites"]} спутников', - 'noreply@example.com', - ['admin@example.com'], - ) -``` - -## Заключение - -Асинхронная обработка данных Lyngsat обеспечивает: -- ✅ Неблокирующий веб-интерфейс -- ✅ Отслеживание прогресса в реальном времени -- ✅ Детальное логирование -- ✅ Масштабируемость (можно запустить несколько workers) -- ✅ Надежность (автоматический retry при ошибках) - -Для получения дополнительной помощи обратитесь к документации: -- [Celery Documentation](https://docs.celeryproject.org/) -- [Django Celery Results](https://django-celery-results.readthedocs.io/) +# Руководство по асинхронному заполнению данных Lyngsat + +## Обзор + +Система заполнения данных Lyngsat теперь работает асинхронно с использованием Celery. Это позволяет: +- Не блокировать веб-интерфейс во время долгих операций +- Отслеживать прогресс выполнения задачи в реальном времени +- Просматривать детальные логи обработки +- Получать уведомления о завершении задачи + +## Архитектура + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ Django │─────▶│ Celery │─────▶│ Redis │ +│ Web App │ │ Worker │ │ Broker │ +└─────────────┘ └─────────────┘ └─────────────┘ + │ │ + │ ▼ + │ ┌─────────────┐ + └─────────────▶│ PostgreSQL │ + │ Database │ + └─────────────┘ +``` + +## Установка и настройка + +### 1. Установка зависимостей + +```bash +pip install -r requirements.txt +``` + +Новые зависимости: +- `celery>=5.4.0` - асинхронная обработка задач +- `django-celery-results>=2.5.1` - хранение результатов в БД + +### 2. Применение миграций + +```bash +cd dbapp +python manage.py migrate +``` + +Это создаст таблицы для хранения результатов Celery. + +### 3. Запуск Redis + +Redis используется как брокер сообщений для Celery. + +#### Вариант 1: Docker Compose (рекомендуется) +```bash +docker-compose up -d redis +``` + +#### Вариант 2: Локальная установка +```bash +# Ubuntu/Debian +sudo apt-get install redis-server +sudo systemctl start redis + +# macOS +brew install redis +brew services start redis + +# Проверка +redis-cli ping +# Должно вернуть: PONG +``` + +### 4. Запуск FlareSolver + +FlareSolver необходим для обхода защиты Cloudflare. + +```bash +docker-compose up -d flaresolverr +``` + +Или отдельно: +```bash +docker run -d -p 8191:8191 --name flaresolverr ghcr.io/flaresolverr/flaresolverr:latest +``` + +### 5. Запуск Celery Worker + +#### Вариант 1: Используя скрипт +```bash +cd dbapp +./start_celery_worker.sh +``` + +#### Вариант 2: Напрямую +```bash +cd dbapp +celery -A dbapp worker --loglevel=info +``` + +#### Вариант 3: В фоновом режиме (Linux/macOS) +```bash +cd dbapp +celery -A dbapp worker --loglevel=info --logfile=logs/celery_worker.log --detach +``` + +## Использование + +### 1. Запуск задачи через веб-интерфейс + +1. Откройте страницу действий: `http://localhost:8000/actions/` +2. Нажмите "Заполнить данные Lyngsat" +3. Выберите спутники и регионы +4. Нажмите "Заполнить данные" +5. Вы будете перенаправлены на страницу отслеживания прогресса + +### 2. Отслеживание прогресса + +На странице статуса задачи вы увидите: +- **Прогресс-бар** с процентом выполнения +- **Текущий статус** (например, "Обработка Astra 4A...") +- **Состояние задачи** (PENDING, PROGRESS, SUCCESS, FAILURE) +- **Результаты** после завершения: + - Количество обработанных спутников + - Количество обработанных источников + - Количество созданных записей + - Количество обновленных записей + - Список ошибок (если есть) + +Страница автоматически обновляется каждые 2 секунды. + +### 3. Просмотр логов + +Логи Celery worker содержат детальную информацию о процессе: + +```bash +# Просмотр логов в реальном времени +tail -f dbapp/logs/celery_worker.log + +# Поиск по логам +grep "Task" dbapp/logs/celery_worker.log +grep "ERROR" dbapp/logs/celery_worker.log +``` + +Формат логов: +``` +[2024-01-15 10:30:45: INFO/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Начало обработки данных Lyngsat +[2024-01-15 10:30:45: INFO/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Спутники: Astra 4A, Hotbird 13G +[2024-01-15 10:30:46: INFO/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Получено данных по 2 спутникам +[2024-01-15 10:31:00: INFO/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Обработка спутника 1/2: Astra 4A +``` + +## Технические детали + +### Структура задачи + +**Файл**: `dbapp/lyngsatapp/tasks.py` + +```python +@shared_task(bind=True, name='lyngsatapp.fill_lyngsat_data_async') +def fill_lyngsat_data_task(self, target_sats, regions=None): + # Логирование начала + # Обновление прогресса + # Вызов функции заполнения + # Сохранение результата в кеш + # Обработка ошибок +``` + +### Обновление прогресса + +Функция `fill_lyngsat_data` теперь принимает callback `update_progress`: + +```python +def update_progress(current, total, status): + self.update_state( + state='PROGRESS', + meta={ + 'current': current, + 'total': total, + 'status': status + } + ) +``` + +### API для проверки статуса + +**Endpoint**: `/api/lyngsat-task-status//` + +**Ответ**: +```json +{ + "task_id": "abc123", + "state": "PROGRESS", + "status": "Обработка Astra 4A...", + "current": 1, + "total": 2, + "percent": 50 +} +``` + +### Логирование + +Используется стандартный модуль `logging` Python: + +```python +import logging +logger = logging.getLogger(__name__) + +logger.info(f"[Task {task_id}] Начало обработки") +logger.debug(f"[Task {task_id}] Детальная информация") +logger.warning(f"[Task {task_id}] Предупреждение") +logger.error(f"[Task {task_id}] Ошибка", exc_info=True) +``` + +## Настройки Celery + +**Файл**: `dbapp/dbapp/settings/base.py` + +```python +# Брокер сообщений +CELERY_BROKER_URL = 'redis://localhost:6379/0' + +# Хранение результатов +CELERY_RESULT_BACKEND = 'django-db' + +# Таймауты +CELERY_TASK_TIME_LIMIT = 30 * 60 # 30 минут +CELERY_TASK_SOFT_TIME_LIMIT = 25 * 60 # 25 минут + +# Отслеживание прогресса +CELERY_TASK_TRACK_STARTED = True +``` + +## Мониторинг и отладка + +### Flower - веб-интерфейс для мониторинга Celery + +Установка: +```bash +pip install flower +``` + +Запуск: +```bash +celery -A dbapp flower +``` + +Откройте: `http://localhost:5555` + +### Проверка статуса задачи через Django shell + +```python +python manage.py shell + +from celery.result import AsyncResult + +task_id = 'abc123' +task = AsyncResult(task_id) + +print(f"State: {task.state}") +print(f"Info: {task.info}") +print(f"Result: {task.result}") +``` + +### Очистка старых результатов + +```bash +# Удалить результаты старше 1 дня +python manage.py celery_results_cleanup --days=1 +``` + +## Решение проблем + +### Проблема: Worker не запускается + +**Решение**: +1. Проверьте, что Redis запущен: `redis-cli ping` +2. Проверьте настройки в `.env`: `CELERY_BROKER_URL` +3. Проверьте логи: `tail -f logs/celery_worker.log` + +### Проблема: Задача зависла в состоянии PENDING + +**Решение**: +1. Проверьте, что worker запущен: `ps aux | grep celery` +2. Перезапустите worker +3. Проверьте соединение с Redis + +### Проблема: Задача завершается с ошибкой + +**Решение**: +1. Проверьте логи worker +2. Проверьте, что FlareSolver запущен: `curl http://localhost:8191/v1` +3. Проверьте, что спутники существуют в базе данных + +### Проблема: Прогресс не обновляется + +**Решение**: +1. Откройте консоль браузера (F12) и проверьте ошибки +2. Проверьте, что API endpoint доступен: `/api/lyngsat-task-status//` +3. Очистите кеш браузера + +## Производственное развертывание + +### Systemd сервис для Celery Worker + +Создайте файл `/etc/systemd/system/celery-worker.service`: + +```ini +[Unit] +Description=Celery Worker for Django Lyngsat +After=network.target redis.service + +[Service] +Type=forking +User=www-data +Group=www-data +WorkingDirectory=/path/to/dbapp +Environment="PATH=/path/to/venv/bin" +ExecStart=/path/to/venv/bin/celery -A dbapp worker --loglevel=info --logfile=/var/log/celery/worker.log --detach +ExecStop=/path/to/venv/bin/celery -A dbapp control shutdown +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +Запуск: +```bash +sudo systemctl daemon-reload +sudo systemctl enable celery-worker +sudo systemctl start celery-worker +sudo systemctl status celery-worker +``` + +### Supervisor (альтернатива) + +Установка: +```bash +sudo apt-get install supervisor +``` + +Конфигурация `/etc/supervisor/conf.d/celery.conf`: +```ini +[program:celery-worker] +command=/path/to/venv/bin/celery -A dbapp worker --loglevel=info +directory=/path/to/dbapp +user=www-data +autostart=true +autorestart=true +stdout_logfile=/var/log/celery/worker.log +stderr_logfile=/var/log/celery/worker_error.log +``` + +Запуск: +```bash +sudo supervisorctl reread +sudo supervisorctl update +sudo supervisorctl start celery-worker +``` + +## Дополнительные возможности + +### Периодические задачи (Celery Beat) + +Для автоматического обновления данных по расписанию: + +1. Установите `django-celery-beat`: +```bash +pip install django-celery-beat +``` + +2. Добавьте в `INSTALLED_APPS`: +```python +INSTALLED_APPS = [ + ... + 'django_celery_beat', +] +``` + +3. Примените миграции: +```bash +python manage.py migrate django_celery_beat +``` + +4. Создайте периодическую задачу через админ-панель Django + +5. Запустите beat scheduler: +```bash +celery -A dbapp beat --loglevel=info +``` + +### Уведомления по email + +Добавьте в задачу отправку email при завершении: + +```python +from django.core.mail import send_mail + +@shared_task(bind=True) +def fill_lyngsat_data_task(self, target_sats, regions=None): + # ... обработка ... + + # Отправка email + send_mail( + 'Задача Lyngsat завершена', + f'Обработано {stats["total_satellites"]} спутников', + 'noreply@example.com', + ['admin@example.com'], + ) +``` + +## Заключение + +Асинхронная обработка данных Lyngsat обеспечивает: +- ✅ Неблокирующий веб-интерфейс +- ✅ Отслеживание прогресса в реальном времени +- ✅ Детальное логирование +- ✅ Масштабируемость (можно запустить несколько workers) +- ✅ Надежность (автоматический retry при ошибках) + +Для получения дополнительной помощи обратитесь к документации: +- [Celery Documentation](https://docs.celeryproject.org/) +- [Django Celery Results](https://django-celery-results.readthedocs.io/) diff --git a/CHANGES_SUMMARY.md b/CHANGES_SUMMARY.md index ca1ce29..9df16da 100644 --- a/CHANGES_SUMMARY.md +++ b/CHANGES_SUMMARY.md @@ -1,133 +1,133 @@ -# Сводка изменений: Модернизация функциональности Lyngsat - -## Обзор - -Реализована новая функциональность для заполнения данных о транспондерах спутников с сайта Lyngsat через веб-интерфейс. - -## Основные изменения - -### 1. Удалена карточка с картами 2D/3D -- **Файл**: `dbapp/mainapp/templates/mainapp/actions.html` -- **Изменение**: Заменена карточка "Карты" на карточку "Заполнение данных Lyngsat" - -### 2. Создана новая форма для заполнения данных -- **Файл**: `dbapp/mainapp/forms.py` -- **Добавлено**: Класс `FillLyngsatDataForm` с полями: - - `satellites` - мультивыбор спутников из базы данных - - `regions` - мультивыбор регионов (Europe, Asia, America, Atlantic) - -### 3. Создан новый view для обработки формы -- **Файл**: `dbapp/mainapp/views.py` -- **Добавлено**: Класс `FillLyngsatDataView` для обработки запросов -- **Функциональность**: - - Валидация формы - - Вызов функции заполнения данных - - Отображение статистики и ошибок - -### 4. Добавлен новый URL -- **Файл**: `dbapp/mainapp/urls.py` -- **Добавлено**: `path('fill-lyngsat-data/', views.FillLyngsatDataView.as_view(), name='fill_lyngsat_data')` - -### 5. Создан новый шаблон -- **Файл**: `dbapp/mainapp/templates/mainapp/fill_lyngsat_data.html` -- **Содержимое**: - - Форма с мультивыбором спутников и регионов - - Информационные блоки - - Валидация на стороне клиента - -### 6. Доработана функция fill_lyngsat_data -- **Файл**: `dbapp/lyngsatapp/utils.py` -- **Изменения**: - - Добавлен параметр `regions` для выбора регионов - - Реализовано частичное заполнение данных - - Добавлена детальная статистика обработки: - - Количество обработанных спутников - - Количество обработанных источников - - Количество созданных записей - - Количество обновленных записей - - Список ошибок - - Улучшена обработка ошибок (процесс не прерывается при ошибке) - - Добавлена валидация данных перед сохранением - -### 7. Исправлен parser.py -- **Файл**: `dbapp/lyngsatapp/parser.py` -- **Изменение**: Удален тестовый код выполнения в конце файла - -### 8. Добавлено приложение lyngsatapp в настройки -- **Файл**: `dbapp/dbapp/settings/base.py` -- **Изменение**: Добавлено `'lyngsatapp'` в `INSTALLED_APPS` - -### 9. Исправлен admin для LyngSat -- **Файл**: `dbapp/lyngsatapp/admin.py` -- **Изменение**: Обновлены поля в `list_display`, `search_fields`, `ordering` в соответствии с моделью - -### 10. Создана миграция для LyngSat -- **Файл**: `dbapp/lyngsatapp/migrations/0001_initial.py` -- **Содержимое**: Создание модели LyngSat - -## Новые файлы - -1. `dbapp/mainapp/templates/mainapp/fill_lyngsat_data.html` - шаблон формы -2. `dbapp/lyngsatapp/migrations/0001_initial.py` - миграция базы данных -3. `LYNGSAT_FILL_GUIDE.md` - руководство пользователя -4. `CHANGES_SUMMARY.md` - этот файл - -## Измененные файлы - -1. `dbapp/mainapp/forms.py` - добавлена форма `FillLyngsatDataForm` -2. `dbapp/mainapp/views.py` - добавлен view `FillLyngsatDataView` -3. `dbapp/mainapp/urls.py` - добавлен URL для новой функциональности -4. `dbapp/mainapp/templates/mainapp/actions.html` - заменена карточка -5. `dbapp/lyngsatapp/utils.py` - доработана функция `fill_lyngsat_data` -6. `dbapp/lyngsatapp/parser.py` - удален тестовый код -7. `dbapp/lyngsatapp/admin.py` - исправлены поля админки -8. `dbapp/dbapp/settings/base.py` - добавлено приложение в INSTALLED_APPS - -## Технические детали - -### Зависимости -- FlareSolver должен быть запущен на `http://localhost:8191` -- Спутники должны быть предварительно добавлены в базу данных - -### Модель данных -Модель `LyngSat` содержит следующие поля: -- `id_satellite` - связь со спутником -- `frequency` - частота в МГц -- `polarization` - поляризация сигнала -- `modulation` - тип модуляции -- `standard` - стандарт передачи -- `sym_velocity` - символьная скорость -- `last_update` - дата последнего обновления -- `channel_info` - информация о канале -- `fec` - коэффициент коррекции ошибок -- `url` - ссылка на страницу Lyngsat - -### Процесс работы -1. Пользователь выбирает спутники и регионы -2. Система подключается к Lyngsat через FlareSolver -3. Парсит данные для каждого спутника -4. Создает или обновляет записи в базе данных -5. Возвращает статистику обработки - -## Тестирование - -Выполнены следующие проверки: -- ✅ `python manage.py check` - нет ошибок -- ✅ `python manage.py makemigrations` - миграция создана -- ✅ Проверка диагностики кода - нет критических ошибок -- ✅ Проверка импортов - все импорты корректны - -## Следующие шаги - -Для полного тестирования необходимо: -1. Применить миграции: `python manage.py migrate` -2. Запустить FlareSolver: `docker run -p 8191:8191 ghcr.io/flaresolverr/flaresolverr:latest` -3. Добавить спутники в базу данных (если еще не добавлены) -4. Протестировать форму заполнения данных через веб-интерфейс - -## Примечания - -- Процесс заполнения может занять продолжительное время (несколько минут на спутник) -- Рекомендуется начинать с небольшого количества спутников -- Все ошибки логируются и отображаются пользователю -- Существующие записи обновляются, новые создаются +# Сводка изменений: Модернизация функциональности Lyngsat + +## Обзор + +Реализована новая функциональность для заполнения данных о транспондерах спутников с сайта Lyngsat через веб-интерфейс. + +## Основные изменения + +### 1. Удалена карточка с картами 2D/3D +- **Файл**: `dbapp/mainapp/templates/mainapp/actions.html` +- **Изменение**: Заменена карточка "Карты" на карточку "Заполнение данных Lyngsat" + +### 2. Создана новая форма для заполнения данных +- **Файл**: `dbapp/mainapp/forms.py` +- **Добавлено**: Класс `FillLyngsatDataForm` с полями: + - `satellites` - мультивыбор спутников из базы данных + - `regions` - мультивыбор регионов (Europe, Asia, America, Atlantic) + +### 3. Создан новый view для обработки формы +- **Файл**: `dbapp/mainapp/views.py` +- **Добавлено**: Класс `FillLyngsatDataView` для обработки запросов +- **Функциональность**: + - Валидация формы + - Вызов функции заполнения данных + - Отображение статистики и ошибок + +### 4. Добавлен новый URL +- **Файл**: `dbapp/mainapp/urls.py` +- **Добавлено**: `path('fill-lyngsat-data/', views.FillLyngsatDataView.as_view(), name='fill_lyngsat_data')` + +### 5. Создан новый шаблон +- **Файл**: `dbapp/mainapp/templates/mainapp/fill_lyngsat_data.html` +- **Содержимое**: + - Форма с мультивыбором спутников и регионов + - Информационные блоки + - Валидация на стороне клиента + +### 6. Доработана функция fill_lyngsat_data +- **Файл**: `dbapp/lyngsatapp/utils.py` +- **Изменения**: + - Добавлен параметр `regions` для выбора регионов + - Реализовано частичное заполнение данных + - Добавлена детальная статистика обработки: + - Количество обработанных спутников + - Количество обработанных источников + - Количество созданных записей + - Количество обновленных записей + - Список ошибок + - Улучшена обработка ошибок (процесс не прерывается при ошибке) + - Добавлена валидация данных перед сохранением + +### 7. Исправлен parser.py +- **Файл**: `dbapp/lyngsatapp/parser.py` +- **Изменение**: Удален тестовый код выполнения в конце файла + +### 8. Добавлено приложение lyngsatapp в настройки +- **Файл**: `dbapp/dbapp/settings/base.py` +- **Изменение**: Добавлено `'lyngsatapp'` в `INSTALLED_APPS` + +### 9. Исправлен admin для LyngSat +- **Файл**: `dbapp/lyngsatapp/admin.py` +- **Изменение**: Обновлены поля в `list_display`, `search_fields`, `ordering` в соответствии с моделью + +### 10. Создана миграция для LyngSat +- **Файл**: `dbapp/lyngsatapp/migrations/0001_initial.py` +- **Содержимое**: Создание модели LyngSat + +## Новые файлы + +1. `dbapp/mainapp/templates/mainapp/fill_lyngsat_data.html` - шаблон формы +2. `dbapp/lyngsatapp/migrations/0001_initial.py` - миграция базы данных +3. `LYNGSAT_FILL_GUIDE.md` - руководство пользователя +4. `CHANGES_SUMMARY.md` - этот файл + +## Измененные файлы + +1. `dbapp/mainapp/forms.py` - добавлена форма `FillLyngsatDataForm` +2. `dbapp/mainapp/views.py` - добавлен view `FillLyngsatDataView` +3. `dbapp/mainapp/urls.py` - добавлен URL для новой функциональности +4. `dbapp/mainapp/templates/mainapp/actions.html` - заменена карточка +5. `dbapp/lyngsatapp/utils.py` - доработана функция `fill_lyngsat_data` +6. `dbapp/lyngsatapp/parser.py` - удален тестовый код +7. `dbapp/lyngsatapp/admin.py` - исправлены поля админки +8. `dbapp/dbapp/settings/base.py` - добавлено приложение в INSTALLED_APPS + +## Технические детали + +### Зависимости +- FlareSolver должен быть запущен на `http://localhost:8191` +- Спутники должны быть предварительно добавлены в базу данных + +### Модель данных +Модель `LyngSat` содержит следующие поля: +- `id_satellite` - связь со спутником +- `frequency` - частота в МГц +- `polarization` - поляризация сигнала +- `modulation` - тип модуляции +- `standard` - стандарт передачи +- `sym_velocity` - символьная скорость +- `last_update` - дата последнего обновления +- `channel_info` - информация о канале +- `fec` - коэффициент коррекции ошибок +- `url` - ссылка на страницу Lyngsat + +### Процесс работы +1. Пользователь выбирает спутники и регионы +2. Система подключается к Lyngsat через FlareSolver +3. Парсит данные для каждого спутника +4. Создает или обновляет записи в базе данных +5. Возвращает статистику обработки + +## Тестирование + +Выполнены следующие проверки: +- ✅ `python manage.py check` - нет ошибок +- ✅ `python manage.py makemigrations` - миграция создана +- ✅ Проверка диагностики кода - нет критических ошибок +- ✅ Проверка импортов - все импорты корректны + +## Следующие шаги + +Для полного тестирования необходимо: +1. Применить миграции: `python manage.py migrate` +2. Запустить FlareSolver: `docker run -p 8191:8191 ghcr.io/flaresolverr/flaresolverr:latest` +3. Добавить спутники в базу данных (если еще не добавлены) +4. Протестировать форму заполнения данных через веб-интерфейс + +## Примечания + +- Процесс заполнения может занять продолжительное время (несколько минут на спутник) +- Рекомендуется начинать с небольшого количества спутников +- Все ошибки логируются и отображаются пользователю +- Существующие записи обновляются, новые создаются diff --git a/DEPLOYMENT_CHECKLIST.md b/DEPLOYMENT_CHECKLIST.md index 3ae78d4..e2e8bd1 100644 --- a/DEPLOYMENT_CHECKLIST.md +++ b/DEPLOYMENT_CHECKLIST.md @@ -1,249 +1,249 @@ -# Чеклист для деплоя в Production - -## Перед деплоем - -### 1. Безопасность - -- [ ] Сгенерирован новый `SECRET_KEY` - ```bash - python generate_secret_key.py - ``` - -- [ ] Изменены все пароли в `.env`: - - [ ] `DB_PASSWORD` - сильный пароль для PostgreSQL - - [ ] `POSTGRES_PASSWORD` - должен совпадать с `DB_PASSWORD` - -- [ ] Настроен `ALLOWED_HOSTS` в `.env`: - ``` - ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com - ``` - -- [ ] `DEBUG=False` в `.env` - -### 2. База данных - -- [ ] Проверены все миграции: - ```bash - docker-compose -f docker-compose.prod.yaml exec web python manage.py showmigrations - ``` - -- [ ] Настроен backup БД (cron job): - ```bash - 0 2 * * * cd /path/to/project && make backup - ``` - -### 3. Статические файлы - -- [ ] Проверена директория для статики: - ```bash - docker-compose -f docker-compose.prod.yaml exec web python manage.py collectstatic --noinput - ``` - -### 4. SSL/HTTPS (опционально, но рекомендуется) - -- [ ] Получены SSL сертификаты (Let's Encrypt, Certbot) -- [ ] Сертификаты размещены в `nginx/ssl/` -- [ ] Переименован `nginx/conf.d/ssl.conf.example` в `ssl.conf` -- [ ] Обновлен `server_name` в `ssl.conf` - -### 5. Nginx - -- [ ] Проверена конфигурация Nginx: - ```bash - docker-compose -f docker-compose.prod.yaml exec nginx nginx -t - ``` - -- [ ] Настроены правильные домены в `nginx/conf.d/default.conf` - -### 6. Docker - -- [ ] Проверен `.dockerignore` - исключены ненужные файлы -- [ ] Проверен `.gitignore` - не коммитятся секреты - -### 7. Переменные окружения - -Проверьте `.env` файл: - -```bash -# Django -DEBUG=False -ENVIRONMENT=production -DJANGO_SETTINGS_MODULE=dbapp.settings.production -SECRET_KEY=<ваш-длинный-секретный-ключ> - -# Database -DB_NAME=geodb -DB_USER=geralt -DB_PASSWORD=<сильный-пароль> -DB_HOST=db -DB_PORT=5432 - -# Allowed Hosts -ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com - -# PostgreSQL -POSTGRES_DB=geodb -POSTGRES_USER=geralt -POSTGRES_PASSWORD=<тот-же-сильный-пароль> - -# Gunicorn -GUNICORN_WORKERS=3 -GUNICORN_TIMEOUT=120 -``` - -## Деплой - -### 1. Клонирование репозитория - -```bash -git clone -cd -``` - -### 2. Настройка окружения - -```bash -cp .env.prod .env -nano .env # Отредактируйте все необходимые переменные -``` - -### 3. Запуск контейнеров - -```bash -docker-compose -f docker-compose.prod.yaml up -d --build -``` - -### 4. Проверка статуса - -```bash -docker-compose -f docker-compose.prod.yaml ps -docker-compose -f docker-compose.prod.yaml logs -f -``` - -### 5. Создание суперпользователя - -```bash -docker-compose -f docker-compose.prod.yaml exec web python manage.py createsuperuser -``` - -### 6. Проверка работоспособности - -- [ ] Открыть http://yourdomain.com -- [ ] Открыть http://yourdomain.com/admin -- [ ] Проверить статические файлы -- [ ] Проверить медиа файлы -- [ ] Проверить TileServer GL: http://yourdomain.com:8080 - -## После деплоя - -### 1. Мониторинг - -- [ ] Настроить мониторинг логов: - ```bash - docker-compose -f docker-compose.prod.yaml logs -f web - ``` - -- [ ] Проверить использование ресурсов: - ```bash - docker stats - ``` - -### 2. Backup - -- [ ] Настроить автоматический backup БД -- [ ] Проверить восстановление из backup -- [ ] Настроить backup медиа файлов - -### 3. Обновления - -- [ ] Документировать процесс обновления -- [ ] Тестировать обновления на dev окружении - -### 4. Безопасность - -- [ ] Настроить firewall (UFW, iptables) -- [ ] Ограничить доступ к портам: - - Открыть: 80, 443 - - Закрыть: 5432, 8000 (доступ только внутри Docker сети) - -- [ ] Настроить fail2ban (опционально) - -### 5. Производительность - -- [ ] Настроить кэширование (Redis, Memcached) -- [ ] Оптимизировать количество Gunicorn workers -- [ ] Настроить CDN для статики (опционально) - -## Troubleshooting - -### Проблема: Контейнеры не запускаются - -```bash -# Проверить логи -docker-compose -f docker-compose.prod.yaml logs - -# Проверить конфигурацию -docker-compose -f docker-compose.prod.yaml config -``` - -### Проблема: База данных недоступна - -```bash -# Проверить статус БД -docker-compose -f docker-compose.prod.yaml exec db pg_isready -U geralt - -# Проверить логи БД -docker-compose -f docker-compose.prod.yaml logs db -``` - -### Проблема: Статические файлы не загружаются - -```bash -# Пересобрать статику -docker-compose -f docker-compose.prod.yaml exec web python manage.py collectstatic --noinput - -# Проверить права доступа -docker-compose -f docker-compose.prod.yaml exec web ls -la /app/staticfiles -``` - -### Проблема: 502 Bad Gateway - -```bash -# Проверить, что Django запущен -docker-compose -f docker-compose.prod.yaml ps web - -# Проверить логи Gunicorn -docker-compose -f docker-compose.prod.yaml logs web - -# Проверить конфигурацию Nginx -docker-compose -f docker-compose.prod.yaml exec nginx nginx -t -``` - -## Полезные команды - -```bash -# Перезапуск сервисов -docker-compose -f docker-compose.prod.yaml restart web -docker-compose -f docker-compose.prod.yaml restart nginx - -# Обновление кода -git pull -docker-compose -f docker-compose.prod.yaml up -d --build - -# Backup БД -docker-compose -f docker-compose.prod.yaml exec db pg_dump -U geralt geodb > backup.sql - -# Восстановление БД -docker-compose -f docker-compose.prod.yaml exec -T db psql -U geralt geodb < backup.sql - -# Просмотр логов -docker-compose -f docker-compose.prod.yaml logs -f --tail=100 web - -# Очистка старых образов -docker system prune -a -``` - -## Контакты для поддержки - -- Документация: [DOCKER_README.md](DOCKER_README.md) -- Быстрый старт: [QUICKSTART.md](QUICKSTART.md) +# Чеклист для деплоя в Production + +## Перед деплоем + +### 1. Безопасность + +- [ ] Сгенерирован новый `SECRET_KEY` + ```bash + python generate_secret_key.py + ``` + +- [ ] Изменены все пароли в `.env`: + - [ ] `DB_PASSWORD` - сильный пароль для PostgreSQL + - [ ] `POSTGRES_PASSWORD` - должен совпадать с `DB_PASSWORD` + +- [ ] Настроен `ALLOWED_HOSTS` в `.env`: + ``` + ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com + ``` + +- [ ] `DEBUG=False` в `.env` + +### 2. База данных + +- [ ] Проверены все миграции: + ```bash + docker-compose -f docker-compose.prod.yaml exec web python manage.py showmigrations + ``` + +- [ ] Настроен backup БД (cron job): + ```bash + 0 2 * * * cd /path/to/project && make backup + ``` + +### 3. Статические файлы + +- [ ] Проверена директория для статики: + ```bash + docker-compose -f docker-compose.prod.yaml exec web python manage.py collectstatic --noinput + ``` + +### 4. SSL/HTTPS (опционально, но рекомендуется) + +- [ ] Получены SSL сертификаты (Let's Encrypt, Certbot) +- [ ] Сертификаты размещены в `nginx/ssl/` +- [ ] Переименован `nginx/conf.d/ssl.conf.example` в `ssl.conf` +- [ ] Обновлен `server_name` в `ssl.conf` + +### 5. Nginx + +- [ ] Проверена конфигурация Nginx: + ```bash + docker-compose -f docker-compose.prod.yaml exec nginx nginx -t + ``` + +- [ ] Настроены правильные домены в `nginx/conf.d/default.conf` + +### 6. Docker + +- [ ] Проверен `.dockerignore` - исключены ненужные файлы +- [ ] Проверен `.gitignore` - не коммитятся секреты + +### 7. Переменные окружения + +Проверьте `.env` файл: + +```bash +# Django +DEBUG=False +ENVIRONMENT=production +DJANGO_SETTINGS_MODULE=dbapp.settings.production +SECRET_KEY=<ваш-длинный-секретный-ключ> + +# Database +DB_NAME=geodb +DB_USER=geralt +DB_PASSWORD=<сильный-пароль> +DB_HOST=db +DB_PORT=5432 + +# Allowed Hosts +ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com + +# PostgreSQL +POSTGRES_DB=geodb +POSTGRES_USER=geralt +POSTGRES_PASSWORD=<тот-же-сильный-пароль> + +# Gunicorn +GUNICORN_WORKERS=3 +GUNICORN_TIMEOUT=120 +``` + +## Деплой + +### 1. Клонирование репозитория + +```bash +git clone +cd +``` + +### 2. Настройка окружения + +```bash +cp .env.prod .env +nano .env # Отредактируйте все необходимые переменные +``` + +### 3. Запуск контейнеров + +```bash +docker-compose -f docker-compose.prod.yaml up -d --build +``` + +### 4. Проверка статуса + +```bash +docker-compose -f docker-compose.prod.yaml ps +docker-compose -f docker-compose.prod.yaml logs -f +``` + +### 5. Создание суперпользователя + +```bash +docker-compose -f docker-compose.prod.yaml exec web python manage.py createsuperuser +``` + +### 6. Проверка работоспособности + +- [ ] Открыть http://yourdomain.com +- [ ] Открыть http://yourdomain.com/admin +- [ ] Проверить статические файлы +- [ ] Проверить медиа файлы +- [ ] Проверить TileServer GL: http://yourdomain.com:8080 + +## После деплоя + +### 1. Мониторинг + +- [ ] Настроить мониторинг логов: + ```bash + docker-compose -f docker-compose.prod.yaml logs -f web + ``` + +- [ ] Проверить использование ресурсов: + ```bash + docker stats + ``` + +### 2. Backup + +- [ ] Настроить автоматический backup БД +- [ ] Проверить восстановление из backup +- [ ] Настроить backup медиа файлов + +### 3. Обновления + +- [ ] Документировать процесс обновления +- [ ] Тестировать обновления на dev окружении + +### 4. Безопасность + +- [ ] Настроить firewall (UFW, iptables) +- [ ] Ограничить доступ к портам: + - Открыть: 80, 443 + - Закрыть: 5432, 8000 (доступ только внутри Docker сети) + +- [ ] Настроить fail2ban (опционально) + +### 5. Производительность + +- [ ] Настроить кэширование (Redis, Memcached) +- [ ] Оптимизировать количество Gunicorn workers +- [ ] Настроить CDN для статики (опционально) + +## Troubleshooting + +### Проблема: Контейнеры не запускаются + +```bash +# Проверить логи +docker-compose -f docker-compose.prod.yaml logs + +# Проверить конфигурацию +docker-compose -f docker-compose.prod.yaml config +``` + +### Проблема: База данных недоступна + +```bash +# Проверить статус БД +docker-compose -f docker-compose.prod.yaml exec db pg_isready -U geralt + +# Проверить логи БД +docker-compose -f docker-compose.prod.yaml logs db +``` + +### Проблема: Статические файлы не загружаются + +```bash +# Пересобрать статику +docker-compose -f docker-compose.prod.yaml exec web python manage.py collectstatic --noinput + +# Проверить права доступа +docker-compose -f docker-compose.prod.yaml exec web ls -la /app/staticfiles +``` + +### Проблема: 502 Bad Gateway + +```bash +# Проверить, что Django запущен +docker-compose -f docker-compose.prod.yaml ps web + +# Проверить логи Gunicorn +docker-compose -f docker-compose.prod.yaml logs web + +# Проверить конфигурацию Nginx +docker-compose -f docker-compose.prod.yaml exec nginx nginx -t +``` + +## Полезные команды + +```bash +# Перезапуск сервисов +docker-compose -f docker-compose.prod.yaml restart web +docker-compose -f docker-compose.prod.yaml restart nginx + +# Обновление кода +git pull +docker-compose -f docker-compose.prod.yaml up -d --build + +# Backup БД +docker-compose -f docker-compose.prod.yaml exec db pg_dump -U geralt geodb > backup.sql + +# Восстановление БД +docker-compose -f docker-compose.prod.yaml exec -T db psql -U geralt geodb < backup.sql + +# Просмотр логов +docker-compose -f docker-compose.prod.yaml logs -f --tail=100 web + +# Очистка старых образов +docker system prune -a +``` + +## Контакты для поддержки + +- Документация: [DOCKER_README.md](DOCKER_README.md) +- Быстрый старт: [QUICKSTART.md](QUICKSTART.md) diff --git a/DEPLOYMENT_INSTRUCTIONS.md b/DEPLOYMENT_INSTRUCTIONS.md index 9ba33b9..9202982 100644 --- a/DEPLOYMENT_INSTRUCTIONS.md +++ b/DEPLOYMENT_INSTRUCTIONS.md @@ -1,102 +1,102 @@ -# Инструкция по развертыванию изменений - -## Шаг 1: Применение миграций - -```bash -cd dbapp -python manage.py migrate -``` - -Это создаст таблицу `lyngsatapp_lyngsat` в базе данных. - -## Шаг 2: Запуск FlareSolver (если еще не запущен) - -FlareSolver необходим для обхода защиты Cloudflare на сайте Lyngsat. - -### Вариант 1: Docker -```bash -docker run -d -p 8191:8191 --name flaresolverr ghcr.io/flaresolverr/flaresolverr:latest -``` - -### Вариант 2: Docker Compose -Добавьте в `docker-compose.yaml`: -```yaml -services: - flaresolverr: - image: ghcr.io/flaresolverr/flaresolverr:latest - container_name: flaresolverr - ports: - - "8191:8191" - restart: unless-stopped -``` - -Затем запустите: -```bash -docker-compose up -d flaresolverr -``` - -## Шаг 3: Проверка работоспособности - -1. Запустите сервер разработки: -```bash -python manage.py runserver -``` - -2. Откройте браузер и перейдите на: -``` -http://localhost:8000/actions/ -``` - -3. Найдите карточку "Заполнение данных Lyngsat" и нажмите на кнопку - -4. Выберите один-два спутника для тестирования - -5. Выберите регионы (например, только Europe) - -6. Нажмите "Заполнить данные" и дождитесь завершения - -## Шаг 4: Проверка результатов - -1. Перейдите в админ-панель Django: -``` -http://localhost:8000/admin/ -``` - -2. Откройте раздел "Lyngsatapp" → "Источники LyngSat" - -3. Проверьте, что данные загружены корректно - -## Возможные проблемы и решения - -### Проблема: FlareSolver не отвечает -**Решение**: Проверьте, что FlareSolver запущен: -```bash -curl http://localhost:8191/v1 -``` - -### Проблема: Спутники не найдены в базе -**Решение**: Убедитесь, что спутники добавлены в базу данных. Используйте функцию "Добавление списка спутников" на странице действий. - -### Проблема: Долгое выполнение -**Решение**: Это нормально. Процесс может занять несколько минут на спутник. Начните с 1-2 спутников для тестирования. - -### Проблема: Ошибки при парсинге -**Решение**: Проверьте логи. Некоторые ошибки (например, некорректные частоты) не критичны и не прерывают процесс. - -## Откат изменений (если необходимо) - -Если нужно откатить изменения: - -```bash -# Откатить миграцию -python manage.py migrate lyngsatapp zero - -# Откатить изменения в коде -git checkout HEAD -- dbapp/ -``` - -## Дополнительная информация - -- Подробное руководство пользователя: `LYNGSAT_FILL_GUIDE.md` -- Сводка изменений: `CHANGES_SUMMARY.md` -- Документация по проекту: `README.md` +# Инструкция по развертыванию изменений + +## Шаг 1: Применение миграций + +```bash +cd dbapp +python manage.py migrate +``` + +Это создаст таблицу `lyngsatapp_lyngsat` в базе данных. + +## Шаг 2: Запуск FlareSolver (если еще не запущен) + +FlareSolver необходим для обхода защиты Cloudflare на сайте Lyngsat. + +### Вариант 1: Docker +```bash +docker run -d -p 8191:8191 --name flaresolverr ghcr.io/flaresolverr/flaresolverr:latest +``` + +### Вариант 2: Docker Compose +Добавьте в `docker-compose.yaml`: +```yaml +services: + flaresolverr: + image: ghcr.io/flaresolverr/flaresolverr:latest + container_name: flaresolverr + ports: + - "8191:8191" + restart: unless-stopped +``` + +Затем запустите: +```bash +docker-compose up -d flaresolverr +``` + +## Шаг 3: Проверка работоспособности + +1. Запустите сервер разработки: +```bash +python manage.py runserver +``` + +2. Откройте браузер и перейдите на: +``` +http://localhost:8000/actions/ +``` + +3. Найдите карточку "Заполнение данных Lyngsat" и нажмите на кнопку + +4. Выберите один-два спутника для тестирования + +5. Выберите регионы (например, только Europe) + +6. Нажмите "Заполнить данные" и дождитесь завершения + +## Шаг 4: Проверка результатов + +1. Перейдите в админ-панель Django: +``` +http://localhost:8000/admin/ +``` + +2. Откройте раздел "Lyngsatapp" → "Источники LyngSat" + +3. Проверьте, что данные загружены корректно + +## Возможные проблемы и решения + +### Проблема: FlareSolver не отвечает +**Решение**: Проверьте, что FlareSolver запущен: +```bash +curl http://localhost:8191/v1 +``` + +### Проблема: Спутники не найдены в базе +**Решение**: Убедитесь, что спутники добавлены в базу данных. Используйте функцию "Добавление списка спутников" на странице действий. + +### Проблема: Долгое выполнение +**Решение**: Это нормально. Процесс может занять несколько минут на спутник. Начните с 1-2 спутников для тестирования. + +### Проблема: Ошибки при парсинге +**Решение**: Проверьте логи. Некоторые ошибки (например, некорректные частоты) не критичны и не прерывают процесс. + +## Откат изменений (если необходимо) + +Если нужно откатить изменения: + +```bash +# Откатить миграцию +python manage.py migrate lyngsatapp zero + +# Откатить изменения в коде +git checkout HEAD -- dbapp/ +``` + +## Дополнительная информация + +- Подробное руководство пользователя: `LYNGSAT_FILL_GUIDE.md` +- Сводка изменений: `CHANGES_SUMMARY.md` +- Документация по проекту: `README.md` diff --git a/DOCKER_README.md b/DOCKER_README.md index a05fb3d..c3cde75 100644 --- a/DOCKER_README.md +++ b/DOCKER_README.md @@ -1,262 +1,262 @@ -# Docker Setup для Django + PostGIS + TileServer GL - -## Структура проекта - -``` -. -├── dbapp/ # Django приложение -│ ├── Dockerfile # Универсальный Dockerfile -│ ├── entrypoint.sh # Скрипт запуска -│ └── ... -├── nginx/ # Конфигурация Nginx (только для prod) -│ └── conf.d/ -│ └── default.conf -├── tiles/ # Тайлы для TileServer GL -├── docker-compose.yaml # Development окружение -├── docker-compose.prod.yaml # Production окружение -├── .env.dev # Переменные для development -└── .env.prod # Переменные для production -``` - -## Быстрый старт - -### Development - -1. Скопируйте файл окружения: -```bash -cp .env.dev .env -``` - -2. Запустите контейнеры: -```bash -docker-compose up -d --build -``` - -3. Создайте суперпользователя: -```bash -docker-compose exec web python manage.py createsuperuser -``` - -4. Приложение доступно: - - Django: http://localhost:8000 - - TileServer GL: http://localhost:8080 - - PostgreSQL: localhost:5432 - -### Production - -1. Скопируйте и настройте файл окружения: -```bash -cp .env.prod .env -# Отредактируйте .env и измените SECRET_KEY, пароли и ALLOWED_HOSTS -``` - -2. Запустите контейнеры: -```bash -docker-compose -f docker-compose.prod.yaml up -d --build -``` - -3. Создайте суперпользователя: -```bash -docker-compose -f docker-compose.prod.yaml exec web python manage.py createsuperuser -``` - -4. Приложение доступно: - - Nginx: http://localhost (порт 80) - - Django (напрямую): http://localhost:8000 - - TileServer GL: http://localhost:8080 - - PostgreSQL: localhost:5432 - -## Основные команды - -### Development - -```bash -# Запуск -docker-compose up -d - -# Остановка -docker-compose down - -# Просмотр логов -docker-compose logs -f web - -# Выполнение команд Django -docker-compose exec web python manage.py migrate -docker-compose exec web python manage.py createsuperuser -docker-compose exec web python manage.py shell - -# Пересборка после изменений в Dockerfile -docker-compose up -d --build - -# Полная очистка (включая volumes) -docker-compose down -v -``` - -### Production - -```bash -# Запуск -docker-compose -f docker-compose.prod.yaml up -d - -# Остановка -docker-compose -f docker-compose.prod.yaml down - -# Просмотр логов -docker-compose -f docker-compose.prod.yaml logs -f web - -# Выполнение команд Django -docker-compose -f docker-compose.prod.yaml exec web python manage.py migrate -docker-compose -f docker-compose.prod.yaml exec web python manage.py createsuperuser - -# Пересборка -docker-compose -f docker-compose.prod.yaml up -d --build -``` - -## Различия между Dev и Prod - -### Development -- Django development server (runserver) -- DEBUG=True -- Код монтируется как volume (изменения применяются сразу) -- Без Nginx -- Простые пароли (для локальной разработки) - -### Production -- Gunicorn WSGI server -- DEBUG=False -- Код копируется в образ (не монтируется) -- Nginx как reverse proxy -- Сильные пароли и SECRET_KEY -- Сбор статики (collectstatic) -- Оптимизированные настройки безопасности - -## Переменные окружения - -### Основные переменные (.env) - -```bash -# Django -DEBUG=True/False -ENVIRONMENT=development/production -DJANGO_SETTINGS_MODULE=dbapp.settings.development/production -SECRET_KEY=your-secret-key - -# Database -DB_NAME=geodb -DB_USER=geralt -DB_PASSWORD=your-password -DB_HOST=db -DB_PORT=5432 - -# Allowed Hosts -ALLOWED_HOSTS=localhost,127.0.0.1,yourdomain.com - -# Gunicorn (только для production) -GUNICORN_WORKERS=3 -GUNICORN_TIMEOUT=120 -``` - -## Volumes - -### Development -- `postgres_data_dev` - данные PostgreSQL -- `static_volume_dev` - статические файлы -- `media_volume_dev` - медиа файлы -- `logs_volume_dev` - логи -- `./dbapp:/app` - код приложения (live reload) - -### Production -- `postgres_data_prod` - данные PostgreSQL -- `static_volume_prod` - статические файлы -- `media_volume_prod` - медиа файлы -- `logs_volume_prod` - логи - -## TileServer GL - -Для работы TileServer GL поместите ваши тайлы в директорию `./tiles/`. - -Пример структуры: -``` -tiles/ -├── config.json -└── your-tiles.mbtiles -``` - -## Backup и восстановление БД - -### Backup -```bash -# Development -docker-compose exec db pg_dump -U geralt geodb > backup.sql - -# Production -docker-compose -f docker-compose.prod.yaml exec db pg_dump -U geralt geodb > backup.sql -``` - -### Восстановление -```bash -# Development -docker-compose exec -T db psql -U geralt geodb < backup.sql - -# Production -docker-compose -f docker-compose.prod.yaml exec -T db psql -U geralt geodb < backup.sql -``` - -## Troubleshooting - -### Проблемы с миграциями -```bash -docker-compose exec web python manage.py migrate --fake-initial -``` - -### Проблемы с правами доступа -```bash -docker-compose exec -u root web chown -R app:app /app -``` - -### Очистка всех данных -```bash -docker-compose down -v -docker system prune -a -``` - -### Проверка логов -```bash -# Все сервисы -docker-compose logs -f - -# Конкретный сервис -docker-compose logs -f web -docker-compose logs -f db -``` - -## Безопасность для Production - -1. **Измените SECRET_KEY** - используйте длинный случайный ключ -2. **Измените пароли БД** - используйте сильные пароли -3. **Настройте ALLOWED_HOSTS** - укажите ваш домен -4. **Настройте SSL** - добавьте сертификаты в `nginx/ssl/` -5. **Ограничьте доступ к портам** - не открывайте порты БД наружу - -## Генерация SECRET_KEY - -```python -python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())" -``` - -## Мониторинг - -### Проверка статуса контейнеров -```bash -docker-compose ps -``` - -### Использование ресурсов -```bash -docker stats -``` - -### Healthcheck -```bash -curl http://localhost:8000/admin/ -``` +# Docker Setup для Django + PostGIS + TileServer GL + +## Структура проекта + +``` +. +├── dbapp/ # Django приложение +│ ├── Dockerfile # Универсальный Dockerfile +│ ├── entrypoint.sh # Скрипт запуска +│ └── ... +├── nginx/ # Конфигурация Nginx (только для prod) +│ └── conf.d/ +│ └── default.conf +├── tiles/ # Тайлы для TileServer GL +├── docker-compose.yaml # Development окружение +├── docker-compose.prod.yaml # Production окружение +├── .env.dev # Переменные для development +└── .env.prod # Переменные для production +``` + +## Быстрый старт + +### Development + +1. Скопируйте файл окружения: +```bash +cp .env.dev .env +``` + +2. Запустите контейнеры: +```bash +docker-compose up -d --build +``` + +3. Создайте суперпользователя: +```bash +docker-compose exec web python manage.py createsuperuser +``` + +4. Приложение доступно: + - Django: http://localhost:8000 + - TileServer GL: http://localhost:8080 + - PostgreSQL: localhost:5432 + +### Production + +1. Скопируйте и настройте файл окружения: +```bash +cp .env.prod .env +# Отредактируйте .env и измените SECRET_KEY, пароли и ALLOWED_HOSTS +``` + +2. Запустите контейнеры: +```bash +docker-compose -f docker-compose.prod.yaml up -d --build +``` + +3. Создайте суперпользователя: +```bash +docker-compose -f docker-compose.prod.yaml exec web python manage.py createsuperuser +``` + +4. Приложение доступно: + - Nginx: http://localhost (порт 80) + - Django (напрямую): http://localhost:8000 + - TileServer GL: http://localhost:8080 + - PostgreSQL: localhost:5432 + +## Основные команды + +### Development + +```bash +# Запуск +docker-compose up -d + +# Остановка +docker-compose down + +# Просмотр логов +docker-compose logs -f web + +# Выполнение команд Django +docker-compose exec web python manage.py migrate +docker-compose exec web python manage.py createsuperuser +docker-compose exec web python manage.py shell + +# Пересборка после изменений в Dockerfile +docker-compose up -d --build + +# Полная очистка (включая volumes) +docker-compose down -v +``` + +### Production + +```bash +# Запуск +docker-compose -f docker-compose.prod.yaml up -d + +# Остановка +docker-compose -f docker-compose.prod.yaml down + +# Просмотр логов +docker-compose -f docker-compose.prod.yaml logs -f web + +# Выполнение команд Django +docker-compose -f docker-compose.prod.yaml exec web python manage.py migrate +docker-compose -f docker-compose.prod.yaml exec web python manage.py createsuperuser + +# Пересборка +docker-compose -f docker-compose.prod.yaml up -d --build +``` + +## Различия между Dev и Prod + +### Development +- Django development server (runserver) +- DEBUG=True +- Код монтируется как volume (изменения применяются сразу) +- Без Nginx +- Простые пароли (для локальной разработки) + +### Production +- Gunicorn WSGI server +- DEBUG=False +- Код копируется в образ (не монтируется) +- Nginx как reverse proxy +- Сильные пароли и SECRET_KEY +- Сбор статики (collectstatic) +- Оптимизированные настройки безопасности + +## Переменные окружения + +### Основные переменные (.env) + +```bash +# Django +DEBUG=True/False +ENVIRONMENT=development/production +DJANGO_SETTINGS_MODULE=dbapp.settings.development/production +SECRET_KEY=your-secret-key + +# Database +DB_NAME=geodb +DB_USER=geralt +DB_PASSWORD=your-password +DB_HOST=db +DB_PORT=5432 + +# Allowed Hosts +ALLOWED_HOSTS=localhost,127.0.0.1,yourdomain.com + +# Gunicorn (только для production) +GUNICORN_WORKERS=3 +GUNICORN_TIMEOUT=120 +``` + +## Volumes + +### Development +- `postgres_data_dev` - данные PostgreSQL +- `static_volume_dev` - статические файлы +- `media_volume_dev` - медиа файлы +- `logs_volume_dev` - логи +- `./dbapp:/app` - код приложения (live reload) + +### Production +- `postgres_data_prod` - данные PostgreSQL +- `static_volume_prod` - статические файлы +- `media_volume_prod` - медиа файлы +- `logs_volume_prod` - логи + +## TileServer GL + +Для работы TileServer GL поместите ваши тайлы в директорию `./tiles/`. + +Пример структуры: +``` +tiles/ +├── config.json +└── your-tiles.mbtiles +``` + +## Backup и восстановление БД + +### Backup +```bash +# Development +docker-compose exec db pg_dump -U geralt geodb > backup.sql + +# Production +docker-compose -f docker-compose.prod.yaml exec db pg_dump -U geralt geodb > backup.sql +``` + +### Восстановление +```bash +# Development +docker-compose exec -T db psql -U geralt geodb < backup.sql + +# Production +docker-compose -f docker-compose.prod.yaml exec -T db psql -U geralt geodb < backup.sql +``` + +## Troubleshooting + +### Проблемы с миграциями +```bash +docker-compose exec web python manage.py migrate --fake-initial +``` + +### Проблемы с правами доступа +```bash +docker-compose exec -u root web chown -R app:app /app +``` + +### Очистка всех данных +```bash +docker-compose down -v +docker system prune -a +``` + +### Проверка логов +```bash +# Все сервисы +docker-compose logs -f + +# Конкретный сервис +docker-compose logs -f web +docker-compose logs -f db +``` + +## Безопасность для Production + +1. **Измените SECRET_KEY** - используйте длинный случайный ключ +2. **Измените пароли БД** - используйте сильные пароли +3. **Настройте ALLOWED_HOSTS** - укажите ваш домен +4. **Настройте SSL** - добавьте сертификаты в `nginx/ssl/` +5. **Ограничьте доступ к портам** - не открывайте порты БД наружу + +## Генерация SECRET_KEY + +```python +python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())" +``` + +## Мониторинг + +### Проверка статуса контейнеров +```bash +docker-compose ps +``` + +### Использование ресурсов +```bash +docker stats +``` + +### Healthcheck +```bash +curl http://localhost:8000/admin/ +``` diff --git a/DOCKER_SETUP.md b/DOCKER_SETUP.md index 678609c..f828048 100644 --- a/DOCKER_SETUP.md +++ b/DOCKER_SETUP.md @@ -1,307 +1,307 @@ -# Docker Setup - Полное руководство - -## 📋 Обзор - -Этот проект использует Docker для развертывания Django приложения с PostGIS и TileServer GL. - -**Основные компоненты:** -- Django 5.2 с PostGIS -- PostgreSQL 17 с расширением PostGIS 3.4 -- TileServer GL для работы с картографическими тайлами -- Nginx (только для production) -- Gunicorn WSGI сервер (production) - -## 🚀 Быстрый старт - -### Development -```bash -cp .env.dev .env -make dev-up -make createsuperuser -``` -Откройте http://localhost:8000 - -### Production -```bash -cp .env.prod .env -# Отредактируйте .env (SECRET_KEY, пароли, домены) -make prod-up -make prod-createsuperuser -``` -Откройте http://yourdomain.com - -## 📁 Структура файлов - -``` -. -├── dbapp/ # Django приложение -│ ├── Dockerfile # Универсальный Dockerfile -│ ├── entrypoint.sh # Скрипт инициализации -│ ├── .dockerignore # Исключения для Docker -│ └── ... -│ -├── nginx/ # Nginx конфигурация (prod) -│ ├── conf.d/ -│ │ ├── default.conf # HTTP конфигурация -│ │ └── ssl.conf.example # HTTPS конфигурация (пример) -│ └── ssl/ # SSL сертификаты -│ -├── tiles/ # Тайлы для TileServer GL -│ ├── README.md # Инструкция по настройке -│ ├── config.json.example # Пример конфигурации -│ └── .gitignore -│ -├── docker-compose.yaml # Development окружение -├── docker-compose.prod.yaml # Production окружение -│ -├── .env.dev # Переменные для dev -├── .env.prod # Переменные для prod (шаблон) -│ -├── Makefile # Удобные команды -├── generate_secret_key.py # Генератор SECRET_KEY -│ -└── Документация: - ├── QUICKSTART.md # Быстрый старт - ├── DOCKER_README.md # Подробная документация - ├── DEPLOYMENT_CHECKLIST.md # Чеклист для деплоя - └── DOCKER_SETUP.md # Этот файл -``` - -## 🔧 Конфигурация - -### Dockerfile - -**Один универсальный Dockerfile** для dev и prod: -- Multi-stage build для оптимизации размера -- Установка GDAL, PostGIS зависимостей -- Использование uv для управления зависимостями -- Non-root пользователь для безопасности -- Healthcheck для мониторинга - -### entrypoint.sh - -Скрипт автоматически: -- Ждет готовности PostgreSQL -- Выполняет миграции -- Собирает статику (только prod) -- Запускает runserver (dev) или Gunicorn (prod) - -Поведение определяется переменной `ENVIRONMENT`: -- `development` → Django development server -- `production` → Gunicorn WSGI server - -### docker-compose.yaml (Development) - -**Сервисы:** -- `db` - PostgreSQL с PostGIS -- `web` - Django приложение -- `tileserver` - TileServer GL - -**Особенности:** -- Код монтируется как volume (live reload) -- DEBUG=True -- Django development server -- Простые пароли для локальной разработки - -### docker-compose.prod.yaml (Production) - -**Сервисы:** -- `db` - PostgreSQL с PostGIS -- `web` - Django с Gunicorn -- `tileserver` - TileServer GL -- `nginx` - Reverse proxy - -**Особенности:** -- Код копируется в образ (не монтируется) -- DEBUG=False -- Gunicorn WSGI server -- Nginx для статики и проксирования -- Сильные пароли из .env -- Сбор статики (collectstatic) - -## 🔐 Безопасность - -### Для Production обязательно: - -1. **Сгенерируйте SECRET_KEY:** - ```bash - python generate_secret_key.py - ``` - -2. **Измените пароли БД** в `.env` - -3. **Настройте ALLOWED_HOSTS:** - ``` - ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com - ``` - -4. **Настройте SSL/HTTPS** (рекомендуется): - - Получите сертификаты (Let's Encrypt) - - Поместите в `nginx/ssl/` - - Используйте `nginx/conf.d/ssl.conf.example` - -5. **Ограничьте доступ к портам:** - - Открыть: 80, 443 - - Закрыть: 5432, 8000 - -## 📊 Мониторинг - -### Логи -```bash -# Development -make dev-logs - -# Production -make prod-logs - -# Конкретный сервис -docker-compose logs -f web -docker-compose logs -f db -``` - -### Статус -```bash -make status # Development -make prod-status # Production -docker stats # Использование ресурсов -``` - -### Healthcheck -```bash -curl http://localhost:8000/admin/ -``` - -## 💾 Backup и восстановление - -### Backup -```bash -make backup -# или -docker-compose exec db pg_dump -U geralt geodb > backup_$(date +%Y%m%d).sql -``` - -### Восстановление -```bash -docker-compose exec -T db psql -U geralt geodb < backup.sql -``` - -### Автоматический backup (cron) -```bash -# Добавьте в crontab -0 2 * * * cd /path/to/project && make backup -``` - -## 🔄 Обновление - -### Development -```bash -git pull -make dev-build -``` - -### Production -```bash -git pull -make prod-build -make prod-migrate -``` - -## 🗺️ TileServer GL - -Поместите `.mbtiles` файлы в директорию `tiles/`: - -```bash -tiles/ -├── world.mbtiles -└── satellite.mbtiles -``` - -Доступ: http://localhost:8080 - -Подробнее: [tiles/README.md](tiles/README.md) - -## 🛠️ Makefile команды - -### Development -```bash -make dev-up # Запустить -make dev-down # Остановить -make dev-build # Пересобрать -make dev-logs # Логи -make dev-restart # Перезапустить web -``` - -### Production -```bash -make prod-up # Запустить -make prod-down # Остановить -make prod-build # Пересобрать -make prod-logs # Логи -make prod-restart # Перезапустить web -``` - -### Django -```bash -make shell # Django shell -make migrate # Миграции -make makemigrations # Создать миграции -make createsuperuser # Создать суперпользователя -make collectstatic # Собрать статику -``` - -### Утилиты -```bash -make backup # Backup БД -make status # Статус контейнеров -make clean # Очистка (с volumes) -make clean-all # Полная очистка -``` - -## 📚 Дополнительная документация - -- **[QUICKSTART.md](QUICKSTART.md)** - Быстрый старт для нетерпеливых -- **[DOCKER_README.md](DOCKER_README.md)** - Подробная документация по Docker -- **[DEPLOYMENT_CHECKLIST.md](DEPLOYMENT_CHECKLIST.md)** - Чеклист для деплоя -- **[tiles/README.md](tiles/README.md)** - Настройка TileServer GL - -## ❓ Troubleshooting - -### Контейнеры не запускаются -```bash -docker-compose logs -docker-compose config -``` - -### База данных недоступна -```bash -docker-compose exec db pg_isready -U geralt -docker-compose logs db -``` - -### Статические файлы не загружаются -```bash -docker-compose exec web python manage.py collectstatic --noinput -docker-compose exec web ls -la /app/staticfiles -``` - -### 502 Bad Gateway -```bash -docker-compose ps web -docker-compose logs web -docker-compose exec nginx nginx -t -``` - -## 🎯 Следующие шаги - -1. ✅ Прочитайте [QUICKSTART.md](QUICKSTART.md) -2. ✅ Запустите development окружение -3. ✅ Изучите [DEPLOYMENT_CHECKLIST.md](DEPLOYMENT_CHECKLIST.md) перед деплоем -4. ✅ Настройте TileServer GL ([tiles/README.md](tiles/README.md)) -5. ✅ Настройте SSL для production - -## 📞 Поддержка - -При возникновении проблем: -1. Проверьте логи: `make dev-logs` или `make prod-logs` -2. Изучите документацию в этой директории -3. Проверьте [DOCKER_README.md](DOCKER_README.md) для подробностей +# Docker Setup - Полное руководство + +## 📋 Обзор + +Этот проект использует Docker для развертывания Django приложения с PostGIS и TileServer GL. + +**Основные компоненты:** +- Django 5.2 с PostGIS +- PostgreSQL 17 с расширением PostGIS 3.4 +- TileServer GL для работы с картографическими тайлами +- Nginx (только для production) +- Gunicorn WSGI сервер (production) + +## 🚀 Быстрый старт + +### Development +```bash +cp .env.dev .env +make dev-up +make createsuperuser +``` +Откройте http://localhost:8000 + +### Production +```bash +cp .env.prod .env +# Отредактируйте .env (SECRET_KEY, пароли, домены) +make prod-up +make prod-createsuperuser +``` +Откройте http://yourdomain.com + +## 📁 Структура файлов + +``` +. +├── dbapp/ # Django приложение +│ ├── Dockerfile # Универсальный Dockerfile +│ ├── entrypoint.sh # Скрипт инициализации +│ ├── .dockerignore # Исключения для Docker +│ └── ... +│ +├── nginx/ # Nginx конфигурация (prod) +│ ├── conf.d/ +│ │ ├── default.conf # HTTP конфигурация +│ │ └── ssl.conf.example # HTTPS конфигурация (пример) +│ └── ssl/ # SSL сертификаты +│ +├── tiles/ # Тайлы для TileServer GL +│ ├── README.md # Инструкция по настройке +│ ├── config.json.example # Пример конфигурации +│ └── .gitignore +│ +├── docker-compose.yaml # Development окружение +├── docker-compose.prod.yaml # Production окружение +│ +├── .env.dev # Переменные для dev +├── .env.prod # Переменные для prod (шаблон) +│ +├── Makefile # Удобные команды +├── generate_secret_key.py # Генератор SECRET_KEY +│ +└── Документация: + ├── QUICKSTART.md # Быстрый старт + ├── DOCKER_README.md # Подробная документация + ├── DEPLOYMENT_CHECKLIST.md # Чеклист для деплоя + └── DOCKER_SETUP.md # Этот файл +``` + +## 🔧 Конфигурация + +### Dockerfile + +**Один универсальный Dockerfile** для dev и prod: +- Multi-stage build для оптимизации размера +- Установка GDAL, PostGIS зависимостей +- Использование uv для управления зависимостями +- Non-root пользователь для безопасности +- Healthcheck для мониторинга + +### entrypoint.sh + +Скрипт автоматически: +- Ждет готовности PostgreSQL +- Выполняет миграции +- Собирает статику (только prod) +- Запускает runserver (dev) или Gunicorn (prod) + +Поведение определяется переменной `ENVIRONMENT`: +- `development` → Django development server +- `production` → Gunicorn WSGI server + +### docker-compose.yaml (Development) + +**Сервисы:** +- `db` - PostgreSQL с PostGIS +- `web` - Django приложение +- `tileserver` - TileServer GL + +**Особенности:** +- Код монтируется как volume (live reload) +- DEBUG=True +- Django development server +- Простые пароли для локальной разработки + +### docker-compose.prod.yaml (Production) + +**Сервисы:** +- `db` - PostgreSQL с PostGIS +- `web` - Django с Gunicorn +- `tileserver` - TileServer GL +- `nginx` - Reverse proxy + +**Особенности:** +- Код копируется в образ (не монтируется) +- DEBUG=False +- Gunicorn WSGI server +- Nginx для статики и проксирования +- Сильные пароли из .env +- Сбор статики (collectstatic) + +## 🔐 Безопасность + +### Для Production обязательно: + +1. **Сгенерируйте SECRET_KEY:** + ```bash + python generate_secret_key.py + ``` + +2. **Измените пароли БД** в `.env` + +3. **Настройте ALLOWED_HOSTS:** + ``` + ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com + ``` + +4. **Настройте SSL/HTTPS** (рекомендуется): + - Получите сертификаты (Let's Encrypt) + - Поместите в `nginx/ssl/` + - Используйте `nginx/conf.d/ssl.conf.example` + +5. **Ограничьте доступ к портам:** + - Открыть: 80, 443 + - Закрыть: 5432, 8000 + +## 📊 Мониторинг + +### Логи +```bash +# Development +make dev-logs + +# Production +make prod-logs + +# Конкретный сервис +docker-compose logs -f web +docker-compose logs -f db +``` + +### Статус +```bash +make status # Development +make prod-status # Production +docker stats # Использование ресурсов +``` + +### Healthcheck +```bash +curl http://localhost:8000/admin/ +``` + +## 💾 Backup и восстановление + +### Backup +```bash +make backup +# или +docker-compose exec db pg_dump -U geralt geodb > backup_$(date +%Y%m%d).sql +``` + +### Восстановление +```bash +docker-compose exec -T db psql -U geralt geodb < backup.sql +``` + +### Автоматический backup (cron) +```bash +# Добавьте в crontab +0 2 * * * cd /path/to/project && make backup +``` + +## 🔄 Обновление + +### Development +```bash +git pull +make dev-build +``` + +### Production +```bash +git pull +make prod-build +make prod-migrate +``` + +## 🗺️ TileServer GL + +Поместите `.mbtiles` файлы в директорию `tiles/`: + +```bash +tiles/ +├── world.mbtiles +└── satellite.mbtiles +``` + +Доступ: http://localhost:8080 + +Подробнее: [tiles/README.md](tiles/README.md) + +## 🛠️ Makefile команды + +### Development +```bash +make dev-up # Запустить +make dev-down # Остановить +make dev-build # Пересобрать +make dev-logs # Логи +make dev-restart # Перезапустить web +``` + +### Production +```bash +make prod-up # Запустить +make prod-down # Остановить +make prod-build # Пересобрать +make prod-logs # Логи +make prod-restart # Перезапустить web +``` + +### Django +```bash +make shell # Django shell +make migrate # Миграции +make makemigrations # Создать миграции +make createsuperuser # Создать суперпользователя +make collectstatic # Собрать статику +``` + +### Утилиты +```bash +make backup # Backup БД +make status # Статус контейнеров +make clean # Очистка (с volumes) +make clean-all # Полная очистка +``` + +## 📚 Дополнительная документация + +- **[QUICKSTART.md](QUICKSTART.md)** - Быстрый старт для нетерпеливых +- **[DOCKER_README.md](DOCKER_README.md)** - Подробная документация по Docker +- **[DEPLOYMENT_CHECKLIST.md](DEPLOYMENT_CHECKLIST.md)** - Чеклист для деплоя +- **[tiles/README.md](tiles/README.md)** - Настройка TileServer GL + +## ❓ Troubleshooting + +### Контейнеры не запускаются +```bash +docker-compose logs +docker-compose config +``` + +### База данных недоступна +```bash +docker-compose exec db pg_isready -U geralt +docker-compose logs db +``` + +### Статические файлы не загружаются +```bash +docker-compose exec web python manage.py collectstatic --noinput +docker-compose exec web ls -la /app/staticfiles +``` + +### 502 Bad Gateway +```bash +docker-compose ps web +docker-compose logs web +docker-compose exec nginx nginx -t +``` + +## 🎯 Следующие шаги + +1. ✅ Прочитайте [QUICKSTART.md](QUICKSTART.md) +2. ✅ Запустите development окружение +3. ✅ Изучите [DEPLOYMENT_CHECKLIST.md](DEPLOYMENT_CHECKLIST.md) перед деплоем +4. ✅ Настройте TileServer GL ([tiles/README.md](tiles/README.md)) +5. ✅ Настройте SSL для production + +## 📞 Поддержка + +При возникновении проблем: +1. Проверьте логи: `make dev-logs` или `make prod-logs` +2. Изучите документацию в этой директории +3. Проверьте [DOCKER_README.md](DOCKER_README.md) для подробностей diff --git a/FILES_OVERVIEW.md b/FILES_OVERVIEW.md index c9ce922..4bbd12e 100644 --- a/FILES_OVERVIEW.md +++ b/FILES_OVERVIEW.md @@ -1,240 +1,240 @@ -# Обзор созданных файлов Docker Setup - -## 🐳 Docker файлы - -### `dbapp/Dockerfile` -**Универсальный Dockerfile** для dev и prod окружений. -- Multi-stage build для оптимизации -- Установка GDAL, PostGIS, PostgreSQL клиента -- Использование uv для управления зависимостями -- Non-root пользователь для безопасности -- Healthcheck для мониторинга - -### `dbapp/entrypoint.sh` -**Скрипт инициализации контейнера.** -- Ожидание готовности PostgreSQL -- Автоматические миграции -- Сбор статики (только prod) -- Запуск runserver (dev) или Gunicorn (prod) - -### `dbapp/.dockerignore` -**Исключения для Docker build.** -- Исключает ненужные файлы из образа -- Уменьшает размер образа -- Ускоряет сборку - -## 🔧 Docker Compose файлы - -### `docker-compose.yaml` -**Development окружение.** -- PostgreSQL с PostGIS -- Django с development server -- TileServer GL -- Код монтируется как volume (live reload) -- DEBUG=True - -### `docker-compose.prod.yaml` -**Production окружение.** -- PostgreSQL с PostGIS -- Django с Gunicorn -- TileServer GL -- Nginx reverse proxy -- Код копируется в образ -- DEBUG=False -- Оптимизированные настройки - -## 🌐 Nginx конфигурация - -### `nginx/conf.d/default.conf` -**HTTP конфигурация для production.** -- Проксирование к Django -- Раздача статики и медиа -- Оптимизированные таймауты -- Кэширование статики - -### `nginx/conf.d/ssl.conf.example` -**HTTPS конфигурация (пример).** -- SSL/TLS настройки -- Редирект с HTTP на HTTPS -- Security headers -- Оптимизированные SSL параметры - -### `nginx/ssl/.gitkeep` -**Директория для SSL сертификатов.** -- Поместите сюда fullchain.pem и privkey.pem - -## 🗺️ TileServer GL - -### `tiles/README.md` -**Инструкция по настройке TileServer GL.** -- Как добавить тайлы -- Примеры конфигурации -- Использование в Django/Leaflet -- Где взять тайлы - -### `tiles/config.json.example` -**Пример конфигурации TileServer GL.** -- Настройки путей -- Форматы и качество -- Домены - -### `tiles/.gitignore` -**Исключения для git.** -- Игнорирует большие .mbtiles файлы -- Сохраняет примеры конфигурации - -## 🔐 Переменные окружения - -### `.env.dev` -**Переменные для development.** -- DEBUG=True -- Простые пароли для локальной разработки -- Настройки БД для dev - -### `.env.prod` -**Шаблон переменных для production.** -- DEBUG=False -- Требует изменения SECRET_KEY и паролей -- Настройки для production - -## 🛠️ Утилиты - -### `Makefile` -**Удобные команды для работы с Docker.** -- `make dev-up` - запуск dev -- `make prod-up` - запуск prod -- `make migrate` - миграции -- `make backup` - backup БД -- И многое другое - -### `generate_secret_key.py` -**Генератор Django SECRET_KEY.** -```bash -python generate_secret_key.py -``` - -## 📚 Документация - -### `QUICKSTART.md` -**Быстрый старт.** -- Минимальные команды для запуска -- Development и Production -- Основные команды - -### `DOCKER_README.md` -**Подробная документация.** -- Полное описание структуры -- Все команды с примерами -- Troubleshooting -- Backup и восстановление - -### `DOCKER_SETUP.md` -**Полное руководство.** -- Обзор всей системы -- Конфигурация -- Безопасность -- Мониторинг - -### `DEPLOYMENT_CHECKLIST.md` -**Чеклист для деплоя.** -- Пошаговая инструкция -- Проверка безопасности -- Настройка production -- Troubleshooting - -### `FILES_OVERVIEW.md` -**Этот файл.** -- Описание всех созданных файлов -- Назначение каждого файла - -## 📝 Обновленные файлы - -### `.gitignore` -**Обновлен для Docker.** -- Исключает .env файлы -- Исключает логи и backup -- Исключает временные файлы - -## 🎯 Как использовать - -### Для начала работы: -1. Прочитайте **QUICKSTART.md** -2. Выберите окружение (dev или prod) -3. Скопируйте соответствующий .env файл -4. Запустите с помощью Makefile - -### Для деплоя: -1. Прочитайте **DEPLOYMENT_CHECKLIST.md** -2. Следуйте чеклисту пошагово -3. Используйте **DOCKER_README.md** для справки - -### Для настройки TileServer: -1. Прочитайте **tiles/README.md** -2. Добавьте .mbtiles файлы -3. Настройте config.json (опционально) - -## 📊 Структура проекта - -``` -. -├── Docker конфигурация -│ ├── dbapp/Dockerfile -│ ├── dbapp/entrypoint.sh -│ ├── dbapp/.dockerignore -│ ├── docker-compose.yaml -│ └── docker-compose.prod.yaml -│ -├── Nginx -│ ├── nginx/conf.d/default.conf -│ ├── nginx/conf.d/ssl.conf.example -│ └── nginx/ssl/.gitkeep -│ -├── TileServer GL -│ ├── tiles/README.md -│ ├── tiles/config.json.example -│ └── tiles/.gitignore -│ -├── Переменные окружения -│ ├── .env.dev -│ └── .env.prod -│ -├── Утилиты -│ ├── Makefile -│ └── generate_secret_key.py -│ -└── Документация - ├── QUICKSTART.md - ├── DOCKER_README.md - ├── DOCKER_SETUP.md - ├── DEPLOYMENT_CHECKLIST.md - └── FILES_OVERVIEW.md -``` - -## ✅ Что было сделано - -1. ✅ Создан универсальный Dockerfile (один для dev и prod) -2. ✅ Настроен entrypoint.sh с автоматической инициализацией -3. ✅ Созданы docker-compose.yaml для dev и prod -4. ✅ Настроен Nginx для production -5. ✅ Добавлена поддержка TileServer GL -6. ✅ Созданы .env файлы для разных окружений -7. ✅ Добавлен Makefile с удобными командами -8. ✅ Написана подробная документация -9. ✅ Создан чеклист для деплоя -10. ✅ Добавлены утилиты (генератор SECRET_KEY) - -## 🚀 Следующие шаги - -1. Запустите development окружение -2. Протестируйте все функции -3. Подготовьте production окружение -4. Следуйте DEPLOYMENT_CHECKLIST.md -5. Настройте мониторинг и backup - -## 💡 Полезные ссылки - -- Django Documentation: https://docs.djangoproject.com/ -- Docker Documentation: https://docs.docker.com/ -- PostGIS Documentation: https://postgis.net/documentation/ -- TileServer GL: https://github.com/maptiler/tileserver-gl -- Nginx Documentation: https://nginx.org/en/docs/ +# Обзор созданных файлов Docker Setup + +## 🐳 Docker файлы + +### `dbapp/Dockerfile` +**Универсальный Dockerfile** для dev и prod окружений. +- Multi-stage build для оптимизации +- Установка GDAL, PostGIS, PostgreSQL клиента +- Использование uv для управления зависимостями +- Non-root пользователь для безопасности +- Healthcheck для мониторинга + +### `dbapp/entrypoint.sh` +**Скрипт инициализации контейнера.** +- Ожидание готовности PostgreSQL +- Автоматические миграции +- Сбор статики (только prod) +- Запуск runserver (dev) или Gunicorn (prod) + +### `dbapp/.dockerignore` +**Исключения для Docker build.** +- Исключает ненужные файлы из образа +- Уменьшает размер образа +- Ускоряет сборку + +## 🔧 Docker Compose файлы + +### `docker-compose.yaml` +**Development окружение.** +- PostgreSQL с PostGIS +- Django с development server +- TileServer GL +- Код монтируется как volume (live reload) +- DEBUG=True + +### `docker-compose.prod.yaml` +**Production окружение.** +- PostgreSQL с PostGIS +- Django с Gunicorn +- TileServer GL +- Nginx reverse proxy +- Код копируется в образ +- DEBUG=False +- Оптимизированные настройки + +## 🌐 Nginx конфигурация + +### `nginx/conf.d/default.conf` +**HTTP конфигурация для production.** +- Проксирование к Django +- Раздача статики и медиа +- Оптимизированные таймауты +- Кэширование статики + +### `nginx/conf.d/ssl.conf.example` +**HTTPS конфигурация (пример).** +- SSL/TLS настройки +- Редирект с HTTP на HTTPS +- Security headers +- Оптимизированные SSL параметры + +### `nginx/ssl/.gitkeep` +**Директория для SSL сертификатов.** +- Поместите сюда fullchain.pem и privkey.pem + +## 🗺️ TileServer GL + +### `tiles/README.md` +**Инструкция по настройке TileServer GL.** +- Как добавить тайлы +- Примеры конфигурации +- Использование в Django/Leaflet +- Где взять тайлы + +### `tiles/config.json.example` +**Пример конфигурации TileServer GL.** +- Настройки путей +- Форматы и качество +- Домены + +### `tiles/.gitignore` +**Исключения для git.** +- Игнорирует большие .mbtiles файлы +- Сохраняет примеры конфигурации + +## 🔐 Переменные окружения + +### `.env.dev` +**Переменные для development.** +- DEBUG=True +- Простые пароли для локальной разработки +- Настройки БД для dev + +### `.env.prod` +**Шаблон переменных для production.** +- DEBUG=False +- Требует изменения SECRET_KEY и паролей +- Настройки для production + +## 🛠️ Утилиты + +### `Makefile` +**Удобные команды для работы с Docker.** +- `make dev-up` - запуск dev +- `make prod-up` - запуск prod +- `make migrate` - миграции +- `make backup` - backup БД +- И многое другое + +### `generate_secret_key.py` +**Генератор Django SECRET_KEY.** +```bash +python generate_secret_key.py +``` + +## 📚 Документация + +### `QUICKSTART.md` +**Быстрый старт.** +- Минимальные команды для запуска +- Development и Production +- Основные команды + +### `DOCKER_README.md` +**Подробная документация.** +- Полное описание структуры +- Все команды с примерами +- Troubleshooting +- Backup и восстановление + +### `DOCKER_SETUP.md` +**Полное руководство.** +- Обзор всей системы +- Конфигурация +- Безопасность +- Мониторинг + +### `DEPLOYMENT_CHECKLIST.md` +**Чеклист для деплоя.** +- Пошаговая инструкция +- Проверка безопасности +- Настройка production +- Troubleshooting + +### `FILES_OVERVIEW.md` +**Этот файл.** +- Описание всех созданных файлов +- Назначение каждого файла + +## 📝 Обновленные файлы + +### `.gitignore` +**Обновлен для Docker.** +- Исключает .env файлы +- Исключает логи и backup +- Исключает временные файлы + +## 🎯 Как использовать + +### Для начала работы: +1. Прочитайте **QUICKSTART.md** +2. Выберите окружение (dev или prod) +3. Скопируйте соответствующий .env файл +4. Запустите с помощью Makefile + +### Для деплоя: +1. Прочитайте **DEPLOYMENT_CHECKLIST.md** +2. Следуйте чеклисту пошагово +3. Используйте **DOCKER_README.md** для справки + +### Для настройки TileServer: +1. Прочитайте **tiles/README.md** +2. Добавьте .mbtiles файлы +3. Настройте config.json (опционально) + +## 📊 Структура проекта + +``` +. +├── Docker конфигурация +│ ├── dbapp/Dockerfile +│ ├── dbapp/entrypoint.sh +│ ├── dbapp/.dockerignore +│ ├── docker-compose.yaml +│ └── docker-compose.prod.yaml +│ +├── Nginx +│ ├── nginx/conf.d/default.conf +│ ├── nginx/conf.d/ssl.conf.example +│ └── nginx/ssl/.gitkeep +│ +├── TileServer GL +│ ├── tiles/README.md +│ ├── tiles/config.json.example +│ └── tiles/.gitignore +│ +├── Переменные окружения +│ ├── .env.dev +│ └── .env.prod +│ +├── Утилиты +│ ├── Makefile +│ └── generate_secret_key.py +│ +└── Документация + ├── QUICKSTART.md + ├── DOCKER_README.md + ├── DOCKER_SETUP.md + ├── DEPLOYMENT_CHECKLIST.md + └── FILES_OVERVIEW.md +``` + +## ✅ Что было сделано + +1. ✅ Создан универсальный Dockerfile (один для dev и prod) +2. ✅ Настроен entrypoint.sh с автоматической инициализацией +3. ✅ Созданы docker-compose.yaml для dev и prod +4. ✅ Настроен Nginx для production +5. ✅ Добавлена поддержка TileServer GL +6. ✅ Созданы .env файлы для разных окружений +7. ✅ Добавлен Makefile с удобными командами +8. ✅ Написана подробная документация +9. ✅ Создан чеклист для деплоя +10. ✅ Добавлены утилиты (генератор SECRET_KEY) + +## 🚀 Следующие шаги + +1. Запустите development окружение +2. Протестируйте все функции +3. Подготовьте production окружение +4. Следуйте DEPLOYMENT_CHECKLIST.md +5. Настройте мониторинг и backup + +## 💡 Полезные ссылки + +- Django Documentation: https://docs.djangoproject.com/ +- Docker Documentation: https://docs.docker.com/ +- PostGIS Documentation: https://postgis.net/documentation/ +- TileServer GL: https://github.com/maptiler/tileserver-gl +- Nginx Documentation: https://nginx.org/en/docs/ diff --git a/INSTALLATION_GUIDE.md b/INSTALLATION_GUIDE.md index 36e3fa0..9373fc7 100644 --- a/INSTALLATION_GUIDE.md +++ b/INSTALLATION_GUIDE.md @@ -1,347 +1,347 @@ -# Руководство по установке асинхронной системы Lyngsat - -## Вариант 1: Полная установка с Celery (рекомендуется) - -### Шаг 1: Установка зависимостей - -```bash -pip install -r dbapp/requirements.txt -``` - -Это установит: -- `celery>=5.4.0` -- `django-celery-results>=2.5.1` -- И все остальные зависимости - -### Шаг 2: Применение миграций - -```bash -cd dbapp -python manage.py migrate -``` - -Это создаст: -- Таблицу `lyngsatapp_lyngsat` для данных Lyngsat -- Таблицы `django_celery_results_*` для результатов Celery - -### Шаг 3: Запуск сервисов - -```bash -# Запуск Redis и FlareSolver -docker-compose up -d redis flaresolverr - -# Проверка -redis-cli ping # Должно вернуть PONG -curl http://localhost:8191/v1 # Должно вернуть JSON -``` - -### Шаг 4: Запуск приложения - -**Терминал 1 - Django:** -```bash -cd dbapp -python manage.py runserver -``` - -**Терминал 2 - Celery Worker:** -```bash -cd dbapp -celery -A dbapp worker --loglevel=info -``` - -### Шаг 5: Тестирование - -1. Откройте `http://localhost:8000/actions/` -2. Нажмите "Заполнить данные Lyngsat" -3. Выберите спутники и регионы -4. Наблюдайте за прогрессом! - ---- - -## Вариант 2: Базовая установка без Celery - -Если вы не хотите использовать асинхронную обработку, система будет работать в синхронном режиме. - -### Шаг 1: Установка базовых зависимостей - -```bash -# Установите все зависимости кроме Celery -pip install -r dbapp/requirements.txt --ignore-installed celery django-celery-results -``` - -Или вручную удалите из `requirements.txt`: -- `celery>=5.4.0` -- `django-celery-results>=2.5.1` - -Затем: -```bash -pip install -r dbapp/requirements.txt -``` - -### Шаг 2: Применение миграций - -```bash -cd dbapp -python manage.py migrate -``` - -### Шаг 3: Запуск FlareSolver - -```bash -docker-compose up -d flaresolverr -``` - -### Шаг 4: Запуск Django - -```bash -cd dbapp -python manage.py runserver -``` - -### Ограничения базовой установки - -⚠️ **Внимание**: В синхронном режиме: -- Веб-интерфейс будет заблокирован во время обработки -- Нет отслеживания прогресса в реальном времени -- Нет детального логирования -- Обработка может занять много времени - ---- - -## Проверка установки - -### Проверка Django -```bash -python dbapp/manage.py check -# Должно вывести: System check identified no issues (0 silenced). -``` - -### Проверка Celery (если установлен) -```bash -celery -A dbapp inspect ping -# Должно вывести: pong -``` - -### Проверка Redis (если установлен) -```bash -redis-cli ping -# Должно вывести: PONG -``` - -### Проверка FlareSolver -```bash -curl http://localhost:8191/v1 -# Должно вернуть JSON с информацией о сервисе -``` - ---- - -## Решение проблем при установке - -### Проблема: ModuleNotFoundError: No module named 'celery' - -**Решение 1**: Установите Celery -```bash -pip install celery django-celery-results -``` - -**Решение 2**: Используйте базовую установку (см. Вариант 2) - -### Проблема: Redis connection refused - -**Решение**: Запустите Redis -```bash -docker-compose up -d redis -# или -sudo systemctl start redis -``` - -### Проблема: FlareSolver не отвечает - -**Решение**: Запустите FlareSolver -```bash -docker-compose up -d flaresolverr -# или -docker run -d -p 8191:8191 ghcr.io/flaresolverr/flaresolverr:latest -``` - -### Проблема: Миграции не применяются - -**Решение**: Проверьте подключение к базе данных -```bash -# Проверьте .env файл -cat dbapp/.env - -# Проверьте PostgreSQL -docker-compose up -d db -docker-compose logs db -``` - ---- - -## Переменные окружения - -Создайте файл `dbapp/.env` (если еще не создан): - -```bash -# Database -DB_ENGINE=django.contrib.gis.db.backends.postgis -DB_NAME=geodb -DB_USER=geralt -DB_PASSWORD=123456 -DB_HOST=localhost -DB_PORT=5432 - -# Django -SECRET_KEY=your-secret-key-here -DEBUG=True -ALLOWED_HOSTS=localhost,127.0.0.1 - -# Celery (опционально) -CELERY_BROKER_URL=redis://localhost:6379/0 - -# FlareSolver -FLARESOLVERR_URL=http://localhost:8191/v1 -``` - ---- - -## Следующие шаги - -После успешной установки: - -1. **Прочитайте документацию**: - - `QUICKSTART_ASYNC.md` - быстрый старт - - `ASYNC_LYNGSAT_GUIDE.md` - полное руководство - - `ASYNC_CHANGES_SUMMARY.md` - технические детали - -2. **Настройте production окружение** (если необходимо): - - Настройте Systemd/Supervisor для Celery - - Настройте Nginx/Apache - - Настройте SSL - - Настройте мониторинг - -3. **Добавьте данные**: - - Добавьте спутники через админ-панель - - Запустите заполнение данных Lyngsat - -4. **Настройте мониторинг**: - - Установите Flower для мониторинга Celery - - Настройте логирование - - Настройте алерты - ---- - -## Дополнительные инструменты - -### Flower - мониторинг Celery - -```bash -pip install flower -celery -A dbapp flower -# Откройте http://localhost:5555 -``` - -### Redis Commander - GUI для Redis - -```bash -docker run -d -p 8081:8081 --name redis-commander \ - --env REDIS_HOSTS=local:localhost:6379 \ - rediscommander/redis-commander -# Откройте http://localhost:8081 -``` - -### pgAdmin - GUI для PostgreSQL - -```bash -docker run -d -p 5050:80 --name pgadmin \ - -e PGADMIN_DEFAULT_EMAIL=admin@admin.com \ - -e PGADMIN_DEFAULT_PASSWORD=admin \ - dpage/pgadmin4 -# Откройте http://localhost:5050 -``` - ---- - -## Обновление системы - -### Обновление зависимостей - -```bash -pip install --upgrade -r dbapp/requirements.txt -``` - -### Применение новых миграций - -```bash -cd dbapp -python manage.py migrate -``` - -### Перезапуск сервисов - -```bash -# Перезапуск Docker контейнеров -docker-compose restart - -# Перезапуск Celery Worker -# Найдите PID процесса -ps aux | grep celery -# Остановите процесс -kill -# Запустите снова -celery -A dbapp worker --loglevel=info -``` - ---- - -## Удаление системы - -### Остановка сервисов - -```bash -# Остановка Docker контейнеров -docker-compose down - -# Остановка Celery Worker -pkill -f "celery worker" -``` - -### Удаление данных - -```bash -# Удаление Docker volumes -docker-compose down -v - -# Удаление виртуального окружения -rm -rf dbapp/.venv - -# Удаление миграций (опционально) -find dbapp -path "*/migrations/*.py" -not -name "__init__.py" -delete -find dbapp -path "*/migrations/*.pyc" -delete -``` - ---- - -## Поддержка - -Если у вас возникли проблемы: - -1. Проверьте логи: - - Django: консоль где запущен runserver - - Celery: `dbapp/logs/celery_worker.log` - - Docker: `docker-compose logs` - -2. Проверьте документацию: - - `ASYNC_LYNGSAT_GUIDE.md` - - `QUICKSTART_ASYNC.md` - - `ASYNC_CHANGES_SUMMARY.md` - -3. Проверьте статус сервисов: - ```bash - docker-compose ps - ps aux | grep celery - redis-cli ping - ``` - -4. Создайте issue в репозитории с описанием проблемы и логами +# Руководство по установке асинхронной системы Lyngsat + +## Вариант 1: Полная установка с Celery (рекомендуется) + +### Шаг 1: Установка зависимостей + +```bash +pip install -r dbapp/requirements.txt +``` + +Это установит: +- `celery>=5.4.0` +- `django-celery-results>=2.5.1` +- И все остальные зависимости + +### Шаг 2: Применение миграций + +```bash +cd dbapp +python manage.py migrate +``` + +Это создаст: +- Таблицу `lyngsatapp_lyngsat` для данных Lyngsat +- Таблицы `django_celery_results_*` для результатов Celery + +### Шаг 3: Запуск сервисов + +```bash +# Запуск Redis и FlareSolver +docker-compose up -d redis flaresolverr + +# Проверка +redis-cli ping # Должно вернуть PONG +curl http://localhost:8191/v1 # Должно вернуть JSON +``` + +### Шаг 4: Запуск приложения + +**Терминал 1 - Django:** +```bash +cd dbapp +python manage.py runserver +``` + +**Терминал 2 - Celery Worker:** +```bash +cd dbapp +celery -A dbapp worker --loglevel=info +``` + +### Шаг 5: Тестирование + +1. Откройте `http://localhost:8000/actions/` +2. Нажмите "Заполнить данные Lyngsat" +3. Выберите спутники и регионы +4. Наблюдайте за прогрессом! + +--- + +## Вариант 2: Базовая установка без Celery + +Если вы не хотите использовать асинхронную обработку, система будет работать в синхронном режиме. + +### Шаг 1: Установка базовых зависимостей + +```bash +# Установите все зависимости кроме Celery +pip install -r dbapp/requirements.txt --ignore-installed celery django-celery-results +``` + +Или вручную удалите из `requirements.txt`: +- `celery>=5.4.0` +- `django-celery-results>=2.5.1` + +Затем: +```bash +pip install -r dbapp/requirements.txt +``` + +### Шаг 2: Применение миграций + +```bash +cd dbapp +python manage.py migrate +``` + +### Шаг 3: Запуск FlareSolver + +```bash +docker-compose up -d flaresolverr +``` + +### Шаг 4: Запуск Django + +```bash +cd dbapp +python manage.py runserver +``` + +### Ограничения базовой установки + +⚠️ **Внимание**: В синхронном режиме: +- Веб-интерфейс будет заблокирован во время обработки +- Нет отслеживания прогресса в реальном времени +- Нет детального логирования +- Обработка может занять много времени + +--- + +## Проверка установки + +### Проверка Django +```bash +python dbapp/manage.py check +# Должно вывести: System check identified no issues (0 silenced). +``` + +### Проверка Celery (если установлен) +```bash +celery -A dbapp inspect ping +# Должно вывести: pong +``` + +### Проверка Redis (если установлен) +```bash +redis-cli ping +# Должно вывести: PONG +``` + +### Проверка FlareSolver +```bash +curl http://localhost:8191/v1 +# Должно вернуть JSON с информацией о сервисе +``` + +--- + +## Решение проблем при установке + +### Проблема: ModuleNotFoundError: No module named 'celery' + +**Решение 1**: Установите Celery +```bash +pip install celery django-celery-results +``` + +**Решение 2**: Используйте базовую установку (см. Вариант 2) + +### Проблема: Redis connection refused + +**Решение**: Запустите Redis +```bash +docker-compose up -d redis +# или +sudo systemctl start redis +``` + +### Проблема: FlareSolver не отвечает + +**Решение**: Запустите FlareSolver +```bash +docker-compose up -d flaresolverr +# или +docker run -d -p 8191:8191 ghcr.io/flaresolverr/flaresolverr:latest +``` + +### Проблема: Миграции не применяются + +**Решение**: Проверьте подключение к базе данных +```bash +# Проверьте .env файл +cat dbapp/.env + +# Проверьте PostgreSQL +docker-compose up -d db +docker-compose logs db +``` + +--- + +## Переменные окружения + +Создайте файл `dbapp/.env` (если еще не создан): + +```bash +# Database +DB_ENGINE=django.contrib.gis.db.backends.postgis +DB_NAME=geodb +DB_USER=geralt +DB_PASSWORD=123456 +DB_HOST=localhost +DB_PORT=5432 + +# Django +SECRET_KEY=your-secret-key-here +DEBUG=True +ALLOWED_HOSTS=localhost,127.0.0.1 + +# Celery (опционально) +CELERY_BROKER_URL=redis://localhost:6379/0 + +# FlareSolver +FLARESOLVERR_URL=http://localhost:8191/v1 +``` + +--- + +## Следующие шаги + +После успешной установки: + +1. **Прочитайте документацию**: + - `QUICKSTART_ASYNC.md` - быстрый старт + - `ASYNC_LYNGSAT_GUIDE.md` - полное руководство + - `ASYNC_CHANGES_SUMMARY.md` - технические детали + +2. **Настройте production окружение** (если необходимо): + - Настройте Systemd/Supervisor для Celery + - Настройте Nginx/Apache + - Настройте SSL + - Настройте мониторинг + +3. **Добавьте данные**: + - Добавьте спутники через админ-панель + - Запустите заполнение данных Lyngsat + +4. **Настройте мониторинг**: + - Установите Flower для мониторинга Celery + - Настройте логирование + - Настройте алерты + +--- + +## Дополнительные инструменты + +### Flower - мониторинг Celery + +```bash +pip install flower +celery -A dbapp flower +# Откройте http://localhost:5555 +``` + +### Redis Commander - GUI для Redis + +```bash +docker run -d -p 8081:8081 --name redis-commander \ + --env REDIS_HOSTS=local:localhost:6379 \ + rediscommander/redis-commander +# Откройте http://localhost:8081 +``` + +### pgAdmin - GUI для PostgreSQL + +```bash +docker run -d -p 5050:80 --name pgadmin \ + -e PGADMIN_DEFAULT_EMAIL=admin@admin.com \ + -e PGADMIN_DEFAULT_PASSWORD=admin \ + dpage/pgadmin4 +# Откройте http://localhost:5050 +``` + +--- + +## Обновление системы + +### Обновление зависимостей + +```bash +pip install --upgrade -r dbapp/requirements.txt +``` + +### Применение новых миграций + +```bash +cd dbapp +python manage.py migrate +``` + +### Перезапуск сервисов + +```bash +# Перезапуск Docker контейнеров +docker-compose restart + +# Перезапуск Celery Worker +# Найдите PID процесса +ps aux | grep celery +# Остановите процесс +kill +# Запустите снова +celery -A dbapp worker --loglevel=info +``` + +--- + +## Удаление системы + +### Остановка сервисов + +```bash +# Остановка Docker контейнеров +docker-compose down + +# Остановка Celery Worker +pkill -f "celery worker" +``` + +### Удаление данных + +```bash +# Удаление Docker volumes +docker-compose down -v + +# Удаление виртуального окружения +rm -rf dbapp/.venv + +# Удаление миграций (опционально) +find dbapp -path "*/migrations/*.py" -not -name "__init__.py" -delete +find dbapp -path "*/migrations/*.pyc" -delete +``` + +--- + +## Поддержка + +Если у вас возникли проблемы: + +1. Проверьте логи: + - Django: консоль где запущен runserver + - Celery: `dbapp/logs/celery_worker.log` + - Docker: `docker-compose logs` + +2. Проверьте документацию: + - `ASYNC_LYNGSAT_GUIDE.md` + - `QUICKSTART_ASYNC.md` + - `ASYNC_CHANGES_SUMMARY.md` + +3. Проверьте статус сервисов: + ```bash + docker-compose ps + ps aux | grep celery + redis-cli ping + ``` + +4. Создайте issue в репозитории с описанием проблемы и логами diff --git a/LYNGSAT_FILL_GUIDE.md b/LYNGSAT_FILL_GUIDE.md index 7a3f67f..4f849a6 100644 --- a/LYNGSAT_FILL_GUIDE.md +++ b/LYNGSAT_FILL_GUIDE.md @@ -1,78 +1,78 @@ -# Руководство по заполнению данных Lyngsat - -## Описание - -Новая функциональность позволяет автоматически загружать данные о транспондерах спутников с сайта Lyngsat. - -## Как использовать - -1. **Перейдите на страницу действий** - - Откройте главную страницу приложения - - Нажмите на "Действия" в меню навигации - -2. **Откройте форму заполнения данных Lyngsat** - - На странице действий найдите карточку "Заполнение данных Lyngsat" - - Нажмите кнопку "Заполнить данные Lyngsat" - -3. **Заполните форму** - - **Выберите спутники**: Выберите один или несколько спутников из списка (удерживайте Ctrl/Cmd для множественного выбора) - - **Выберите регионы**: Выберите регионы для парсинга (Europe, Asia, America, Atlantic) - -4. **Запустите процесс** - - Нажмите кнопку "Заполнить данные" - - Дождитесь завершения процесса (может занять несколько минут) - -## Что происходит при заполнении - -1. Система подключается к сайту Lyngsat через FlareSolver (требуется запущенный сервис) -2. Парсит данные о транспондерах для выбранных спутников -3. Создает или обновляет записи в базе данных: - - Частота - - Поляризация - - Модуляция - - Стандарт (DVB-S, DVB-S2 и т.д.) - - Символьная скорость - - FEC (коэффициент коррекции ошибок) - - Информация о канале - - Дата последнего обновления - -## Требования - -- **FlareSolver**: Должен быть запущен на `http://localhost:8191` -- **Спутники в базе**: Спутники должны быть предварительно добавлены в базу данных -- **Интернет-соединение**: Требуется для доступа к сайту Lyngsat - -## Результаты - -После завершения процесса вы увидите: -- Количество обработанных спутников -- Количество обработанных источников -- Количество созданных записей -- Количество обновленных записей -- Список ошибок (если есть) - -## Технические детали - -### Функция `fill_lyngsat_data` - -Функция была доработана для поддержки: -- Частичного заполнения данных -- Выбора регионов -- Детальной статистики обработки -- Обработки ошибок без прерывания процесса - -### Изменения в коде - -1. **Новая форма**: `FillLyngsatDataForm` в `mainapp/forms.py` -2. **Новый view**: `FillLyngsatDataView` в `mainapp/views.py` -3. **Новый URL**: `/fill-lyngsat-data/` в `mainapp/urls.py` -4. **Новый шаблон**: `fill_lyngsat_data.html` -5. **Обновленная функция**: `fill_lyngsat_data` в `lyngsatapp/utils.py` -6. **Обновленный шаблон**: `actions.html` (заменена карточка с картами) - -## Примечания - -- Процесс может занять продолжительное время в зависимости от количества выбранных спутников -- Рекомендуется выбирать небольшое количество спутников для первого запуска -- Существующие записи будут обновлены, новые - созданы -- Все ошибки логируются и отображаются пользователю +# Руководство по заполнению данных Lyngsat + +## Описание + +Новая функциональность позволяет автоматически загружать данные о транспондерах спутников с сайта Lyngsat. + +## Как использовать + +1. **Перейдите на страницу действий** + - Откройте главную страницу приложения + - Нажмите на "Действия" в меню навигации + +2. **Откройте форму заполнения данных Lyngsat** + - На странице действий найдите карточку "Заполнение данных Lyngsat" + - Нажмите кнопку "Заполнить данные Lyngsat" + +3. **Заполните форму** + - **Выберите спутники**: Выберите один или несколько спутников из списка (удерживайте Ctrl/Cmd для множественного выбора) + - **Выберите регионы**: Выберите регионы для парсинга (Europe, Asia, America, Atlantic) + +4. **Запустите процесс** + - Нажмите кнопку "Заполнить данные" + - Дождитесь завершения процесса (может занять несколько минут) + +## Что происходит при заполнении + +1. Система подключается к сайту Lyngsat через FlareSolver (требуется запущенный сервис) +2. Парсит данные о транспондерах для выбранных спутников +3. Создает или обновляет записи в базе данных: + - Частота + - Поляризация + - Модуляция + - Стандарт (DVB-S, DVB-S2 и т.д.) + - Символьная скорость + - FEC (коэффициент коррекции ошибок) + - Информация о канале + - Дата последнего обновления + +## Требования + +- **FlareSolver**: Должен быть запущен на `http://localhost:8191` +- **Спутники в базе**: Спутники должны быть предварительно добавлены в базу данных +- **Интернет-соединение**: Требуется для доступа к сайту Lyngsat + +## Результаты + +После завершения процесса вы увидите: +- Количество обработанных спутников +- Количество обработанных источников +- Количество созданных записей +- Количество обновленных записей +- Список ошибок (если есть) + +## Технические детали + +### Функция `fill_lyngsat_data` + +Функция была доработана для поддержки: +- Частичного заполнения данных +- Выбора регионов +- Детальной статистики обработки +- Обработки ошибок без прерывания процесса + +### Изменения в коде + +1. **Новая форма**: `FillLyngsatDataForm` в `mainapp/forms.py` +2. **Новый view**: `FillLyngsatDataView` в `mainapp/views.py` +3. **Новый URL**: `/fill-lyngsat-data/` в `mainapp/urls.py` +4. **Новый шаблон**: `fill_lyngsat_data.html` +5. **Обновленная функция**: `fill_lyngsat_data` в `lyngsatapp/utils.py` +6. **Обновленный шаблон**: `actions.html` (заменена карточка с картами) + +## Примечания + +- Процесс может занять продолжительное время в зависимости от количества выбранных спутников +- Рекомендуется выбирать небольшое количество спутников для первого запуска +- Существующие записи будут обновлены, новые - созданы +- Все ошибки логируются и отображаются пользователю diff --git a/Makefile b/Makefile index 758ef95..7ddd09b 100644 --- a/Makefile +++ b/Makefile @@ -1,99 +1,99 @@ -.PHONY: help dev-up dev-down dev-build dev-logs prod-up prod-down prod-build prod-logs shell migrate createsuperuser clean - -help: - @echo "Доступные команды:" - @echo " make dev-up - Запустить development окружение" - @echo " make dev-down - Остановить development окружение" - @echo " make dev-build - Пересобрать development контейнеры" - @echo " make dev-logs - Показать логи development" - @echo " make prod-up - Запустить production окружение" - @echo " make prod-down - Остановить production окружение" - @echo " make prod-build - Пересобрать production контейнеры" - @echo " make prod-logs - Показать логи production" - @echo " make shell - Открыть Django shell" - @echo " make migrate - Выполнить миграции" - @echo " make createsuperuser - Создать суперпользователя" - @echo " make clean - Удалить все контейнеры и volumes" - -# Development команды -dev-up: - docker-compose up -d - -dev-down: - docker-compose down - -dev-build: - docker-compose up -d --build - -dev-logs: - docker-compose logs -f - -dev-restart: - docker-compose restart web - -# Production команды -prod-up: - docker-compose -f docker-compose.prod.yaml up -d - -prod-down: - docker-compose -f docker-compose.prod.yaml down - -prod-build: - docker-compose -f docker-compose.prod.yaml up -d --build - -prod-logs: - docker-compose -f docker-compose.prod.yaml logs -f - -prod-restart: - docker-compose -f docker-compose.prod.yaml restart web - -# Django команды (для development по умолчанию) -shell: - docker-compose exec web python manage.py shell - -migrate: - docker-compose exec web python manage.py migrate - -makemigrations: - docker-compose exec web python manage.py makemigrations - -createsuperuser: - docker-compose exec web python manage.py createsuperuser - -collectstatic: - docker-compose exec web python manage.py collectstatic --noinput - -# Для production -prod-shell: - docker-compose -f docker-compose.prod.yaml exec web python manage.py shell - -prod-migrate: - docker-compose -f docker-compose.prod.yaml exec web python manage.py migrate - -prod-createsuperuser: - docker-compose -f docker-compose.prod.yaml exec web python manage.py createsuperuser - -# Backup и восстановление -backup: - docker-compose exec db pg_dump -U geralt geodb > backup_$(shell date +%Y%m%d_%H%M%S).sql - -restore: - @read -p "Введите имя файла backup: " file; \ - docker-compose exec -T db psql -U geralt geodb < $$file - -# Очистка -clean: - docker-compose down -v - docker system prune -f - -clean-all: - docker-compose down -v - docker-compose -f docker-compose.prod.yaml down -v - docker system prune -af --volumes - -# Проверка статуса -status: - docker-compose ps - -prod-status: - docker-compose -f docker-compose.prod.yaml ps +.PHONY: help dev-up dev-down dev-build dev-logs prod-up prod-down prod-build prod-logs shell migrate createsuperuser clean + +help: + @echo "Доступные команды:" + @echo " make dev-up - Запустить development окружение" + @echo " make dev-down - Остановить development окружение" + @echo " make dev-build - Пересобрать development контейнеры" + @echo " make dev-logs - Показать логи development" + @echo " make prod-up - Запустить production окружение" + @echo " make prod-down - Остановить production окружение" + @echo " make prod-build - Пересобрать production контейнеры" + @echo " make prod-logs - Показать логи production" + @echo " make shell - Открыть Django shell" + @echo " make migrate - Выполнить миграции" + @echo " make createsuperuser - Создать суперпользователя" + @echo " make clean - Удалить все контейнеры и volumes" + +# Development команды +dev-up: + docker-compose up -d + +dev-down: + docker-compose down + +dev-build: + docker-compose up -d --build + +dev-logs: + docker-compose logs -f + +dev-restart: + docker-compose restart web + +# Production команды +prod-up: + docker-compose -f docker-compose.prod.yaml up -d + +prod-down: + docker-compose -f docker-compose.prod.yaml down + +prod-build: + docker-compose -f docker-compose.prod.yaml up -d --build + +prod-logs: + docker-compose -f docker-compose.prod.yaml logs -f + +prod-restart: + docker-compose -f docker-compose.prod.yaml restart web + +# Django команды (для development по умолчанию) +shell: + docker-compose exec web python manage.py shell + +migrate: + docker-compose exec web python manage.py migrate + +makemigrations: + docker-compose exec web python manage.py makemigrations + +createsuperuser: + docker-compose exec web python manage.py createsuperuser + +collectstatic: + docker-compose exec web python manage.py collectstatic --noinput + +# Для production +prod-shell: + docker-compose -f docker-compose.prod.yaml exec web python manage.py shell + +prod-migrate: + docker-compose -f docker-compose.prod.yaml exec web python manage.py migrate + +prod-createsuperuser: + docker-compose -f docker-compose.prod.yaml exec web python manage.py createsuperuser + +# Backup и восстановление +backup: + docker-compose exec db pg_dump -U geralt geodb > backup_$(shell date +%Y%m%d_%H%M%S).sql + +restore: + @read -p "Введите имя файла backup: " file; \ + docker-compose exec -T db psql -U geralt geodb < $$file + +# Очистка +clean: + docker-compose down -v + docker system prune -f + +clean-all: + docker-compose down -v + docker-compose -f docker-compose.prod.yaml down -v + docker system prune -af --volumes + +# Проверка статуса +status: + docker-compose ps + +prod-status: + docker-compose -f docker-compose.prod.yaml ps diff --git a/QUICKSTART.md b/QUICKSTART.md index 475716d..ac873ab 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -1,106 +1,106 @@ -# Быстрый старт с Docker - -## Development (разработка) - -```bash -# 1. Скопировать переменные окружения -cp .env.dev .env - -# 2. Запустить контейнеры -make dev-up -# или -docker-compose up -d --build - -# 3. Создать суперпользователя -make createsuperuser -# или -docker-compose exec web python manage.py createsuperuser - -# 4. Открыть в браузере -# Django: http://localhost:8000 -# Admin: http://localhost:8000/admin -# TileServer: http://localhost:8080 -``` - -## Production (продакшн) - -```bash -# 1. Скопировать и настроить переменные -cp .env.prod .env -nano .env # Измените SECRET_KEY, пароли, ALLOWED_HOSTS - -# 2. Запустить контейнеры -make prod-up -# или -docker-compose -f docker-compose.prod.yaml up -d --build - -# 3. Создать суперпользователя -make prod-createsuperuser -# или -docker-compose -f docker-compose.prod.yaml exec web python manage.py createsuperuser - -# 4. Открыть в браузере -# Nginx: http://localhost -# Django: http://localhost:8000 -# TileServer: http://localhost:8080 -``` - -## Полезные команды - -```bash -# Просмотр логов -make dev-logs # development -make prod-logs # production - -# Остановка -make dev-down # development -make prod-down # production - -# Перезапуск после изменений -make dev-build # development -make prod-build # production - -# Django shell -make shell # development -make prod-shell # production - -# Миграции -make migrate # development -make prod-migrate # production - -# Backup БД -make backup - -# Статус контейнеров -make status # development -make prod-status # production -``` - -## Структура проекта - -``` -. -├── dbapp/ # Django приложение -│ ├── Dockerfile # Универсальный Dockerfile -│ ├── entrypoint.sh # Скрипт запуска -│ ├── manage.py -│ └── ... -├── nginx/ # Nginx (только prod) -│ └── conf.d/ -│ └── default.conf -├── tiles/ # Тайлы для TileServer GL -│ ├── README.md -│ └── config.json.example -├── docker-compose.yaml # Development -├── docker-compose.prod.yaml # Production -├── .env.dev # Переменные dev -├── .env.prod # Переменные prod -├── Makefile # Команды для удобства -└── DOCKER_README.md # Подробная документация -``` - -## Что дальше? - -1. Прочитайте [DOCKER_README.md](DOCKER_README.md) для подробной информации -2. Настройте TileServer GL - см. [tiles/README.md](tiles/README.md) -3. Для production настройте SSL сертификаты в `nginx/ssl/` +# Быстрый старт с Docker + +## Development (разработка) + +```bash +# 1. Скопировать переменные окружения +cp .env.dev .env + +# 2. Запустить контейнеры +make dev-up +# или +docker-compose up -d --build + +# 3. Создать суперпользователя +make createsuperuser +# или +docker-compose exec web python manage.py createsuperuser + +# 4. Открыть в браузере +# Django: http://localhost:8000 +# Admin: http://localhost:8000/admin +# TileServer: http://localhost:8080 +``` + +## Production (продакшн) + +```bash +# 1. Скопировать и настроить переменные +cp .env.prod .env +nano .env # Измените SECRET_KEY, пароли, ALLOWED_HOSTS + +# 2. Запустить контейнеры +make prod-up +# или +docker-compose -f docker-compose.prod.yaml up -d --build + +# 3. Создать суперпользователя +make prod-createsuperuser +# или +docker-compose -f docker-compose.prod.yaml exec web python manage.py createsuperuser + +# 4. Открыть в браузере +# Nginx: http://localhost +# Django: http://localhost:8000 +# TileServer: http://localhost:8080 +``` + +## Полезные команды + +```bash +# Просмотр логов +make dev-logs # development +make prod-logs # production + +# Остановка +make dev-down # development +make prod-down # production + +# Перезапуск после изменений +make dev-build # development +make prod-build # production + +# Django shell +make shell # development +make prod-shell # production + +# Миграции +make migrate # development +make prod-migrate # production + +# Backup БД +make backup + +# Статус контейнеров +make status # development +make prod-status # production +``` + +## Структура проекта + +``` +. +├── dbapp/ # Django приложение +│ ├── Dockerfile # Универсальный Dockerfile +│ ├── entrypoint.sh # Скрипт запуска +│ ├── manage.py +│ └── ... +├── nginx/ # Nginx (только prod) +│ └── conf.d/ +│ └── default.conf +├── tiles/ # Тайлы для TileServer GL +│ ├── README.md +│ └── config.json.example +├── docker-compose.yaml # Development +├── docker-compose.prod.yaml # Production +├── .env.dev # Переменные dev +├── .env.prod # Переменные prod +├── Makefile # Команды для удобства +└── DOCKER_README.md # Подробная документация +``` + +## Что дальше? + +1. Прочитайте [DOCKER_README.md](DOCKER_README.md) для подробной информации +2. Настройте TileServer GL - см. [tiles/README.md](tiles/README.md) +3. Для production настройте SSL сертификаты в `nginx/ssl/` diff --git a/QUICKSTART_ASYNC.md b/QUICKSTART_ASYNC.md index a5009ae..0dea2d2 100644 --- a/QUICKSTART_ASYNC.md +++ b/QUICKSTART_ASYNC.md @@ -1,117 +1,117 @@ -# Быстрый старт: Асинхронное заполнение данных Lyngsat - -## Минимальная настройка (5 минут) - -### 1. Установите зависимости -```bash -pip install -r dbapp/requirements.txt -``` - -### 2. Примените миграции -```bash -cd dbapp -python manage.py migrate -``` - -### 3. Запустите необходимые сервисы - -**Терминал 1 - Redis и FlareSolver:** -```bash -docker-compose up -d redis flaresolverr -``` - -**Терминал 2 - Django:** -```bash -cd dbapp -python manage.py runserver -``` - -**Терминал 3 - Celery Worker:** -```bash -cd dbapp -celery -A dbapp worker --loglevel=info -``` - -### 4. Используйте систему - -1. Откройте браузер: `http://localhost:8000/actions/` -2. Нажмите "Заполнить данные Lyngsat" -3. Выберите 1-2 спутника для теста -4. Выберите регион (например, Europe) -5. Нажмите "Заполнить данные" -6. Наблюдайте за прогрессом в реальном времени! - -## Проверка работоспособности - -### Redis -```bash -redis-cli ping -# Должно вернуть: PONG -``` - -### FlareSolver -```bash -curl http://localhost:8191/v1 -# Должно вернуть JSON с информацией о сервисе -``` - -### Celery Worker -Проверьте вывод в терминале 3 - должны быть сообщения: -``` -[2024-01-15 10:30:00,000: INFO/MainProcess] Connected to redis://localhost:6379/0 -[2024-01-15 10:30:00,000: INFO/MainProcess] celery@hostname ready. -``` - -## Остановка сервисов - -```bash -# Остановить Docker контейнеры -docker-compose down - -# Остановить Django (Ctrl+C в терминале 2) - -# Остановить Celery Worker (Ctrl+C в терминале 3) -``` - -## Просмотр логов - -```bash -# Логи Celery Worker (если запущен с --logfile) -tail -f dbapp/logs/celery_worker.log - -# Логи Docker контейнеров -docker-compose logs -f redis -docker-compose logs -f flaresolverr -``` - -## Что дальше? - -- Прочитайте полную документацию: `ASYNC_LYNGSAT_GUIDE.md` -- Настройте production окружение -- Добавьте периодические задачи -- Настройте email уведомления - -## Решение проблем - -**Worker не запускается:** -```bash -# Проверьте Redis -redis-cli ping - -# Проверьте переменные окружения -echo $CELERY_BROKER_URL -``` - -**Задача не выполняется:** -```bash -# Проверьте FlareSolver -curl http://localhost:8191/v1 - -# Проверьте логи worker -tail -f dbapp/logs/celery_worker.log -``` - -**Прогресс не обновляется:** -- Откройте консоль браузера (F12) -- Проверьте Network tab на наличие ошибок -- Обновите страницу +# Быстрый старт: Асинхронное заполнение данных Lyngsat + +## Минимальная настройка (5 минут) + +### 1. Установите зависимости +```bash +pip install -r dbapp/requirements.txt +``` + +### 2. Примените миграции +```bash +cd dbapp +python manage.py migrate +``` + +### 3. Запустите необходимые сервисы + +**Терминал 1 - Redis и FlareSolver:** +```bash +docker-compose up -d redis flaresolverr +``` + +**Терминал 2 - Django:** +```bash +cd dbapp +python manage.py runserver +``` + +**Терминал 3 - Celery Worker:** +```bash +cd dbapp +celery -A dbapp worker --loglevel=info +``` + +### 4. Используйте систему + +1. Откройте браузер: `http://localhost:8000/actions/` +2. Нажмите "Заполнить данные Lyngsat" +3. Выберите 1-2 спутника для теста +4. Выберите регион (например, Europe) +5. Нажмите "Заполнить данные" +6. Наблюдайте за прогрессом в реальном времени! + +## Проверка работоспособности + +### Redis +```bash +redis-cli ping +# Должно вернуть: PONG +``` + +### FlareSolver +```bash +curl http://localhost:8191/v1 +# Должно вернуть JSON с информацией о сервисе +``` + +### Celery Worker +Проверьте вывод в терминале 3 - должны быть сообщения: +``` +[2024-01-15 10:30:00,000: INFO/MainProcess] Connected to redis://localhost:6379/0 +[2024-01-15 10:30:00,000: INFO/MainProcess] celery@hostname ready. +``` + +## Остановка сервисов + +```bash +# Остановить Docker контейнеры +docker-compose down + +# Остановить Django (Ctrl+C в терминале 2) + +# Остановить Celery Worker (Ctrl+C в терминале 3) +``` + +## Просмотр логов + +```bash +# Логи Celery Worker (если запущен с --logfile) +tail -f dbapp/logs/celery_worker.log + +# Логи Docker контейнеров +docker-compose logs -f redis +docker-compose logs -f flaresolverr +``` + +## Что дальше? + +- Прочитайте полную документацию: `ASYNC_LYNGSAT_GUIDE.md` +- Настройте production окружение +- Добавьте периодические задачи +- Настройте email уведомления + +## Решение проблем + +**Worker не запускается:** +```bash +# Проверьте Redis +redis-cli ping + +# Проверьте переменные окружения +echo $CELERY_BROKER_URL +``` + +**Задача не выполняется:** +```bash +# Проверьте FlareSolver +curl http://localhost:8191/v1 + +# Проверьте логи worker +tail -f dbapp/logs/celery_worker.log +``` + +**Прогресс не обновляется:** +- Откройте консоль браузера (F12) +- Проверьте Network tab на наличие ошибок +- Обновите страницу diff --git a/dbapp/.dockerignore b/dbapp/.dockerignore index c5247a8..7dd9509 100644 --- a/dbapp/.dockerignore +++ b/dbapp/.dockerignore @@ -1,60 +1,60 @@ -# Git -.git -.gitignore -.gitattributes - -# Python -__pycache__ -*.py[cod] -*$py.class -*.so -.Python -*.egg-info -dist/ -build/ -*.egg - -# Virtual environments -venv/ -env/ -ENV/ -.venv - -# IDE -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# Django -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal -/staticfiles/ -/media/ - -# Environment -.env -.env.local -.env.*.local - -# Testing -.pytest_cache/ -.coverage -htmlcov/ -.tox/ - -# Documentation -*.md -docs/ - -# OS -.DS_Store -Thumbs.db - -# Docker -Dockerfile* -docker-compose*.yaml -.dockerignore +# Git +.git +.gitignore +.gitattributes + +# Python +__pycache__ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info +dist/ +build/ +*.egg + +# Virtual environments +venv/ +env/ +ENV/ +.venv + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Django +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal +/staticfiles/ +/media/ + +# Environment +.env +.env.local +.env.*.local + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# Documentation +*.md +docs/ + +# OS +.DS_Store +Thumbs.db + +# Docker +Dockerfile* +docker-compose*.yaml +.dockerignore diff --git a/dbapp/.env.example b/dbapp/.env.example index 53079cf..b01631d 100644 --- a/dbapp/.env.example +++ b/dbapp/.env.example @@ -1,10 +1,10 @@ -# Production environment variables -DEBUG=False -ENVIRONMENT=production -SECRET_KEY=your_very_long_secret_key_here_change_this_to_something_secure -DB_NAME=geodb -DB_USER=geralt -DB_PASSWORD=123456 -DB_HOST=db -DB_PORT=5432 +# Production environment variables +DEBUG=False +ENVIRONMENT=production +SECRET_KEY=your_very_long_secret_key_here_change_this_to_something_secure +DB_NAME=geodb +DB_USER=geralt +DB_PASSWORD=123456 +DB_HOST=db +DB_PORT=5432 ALLOWED_HOSTS=localhost,yourdomain.com \ No newline at end of file diff --git a/dbapp/.python-version b/dbapp/.python-version new file mode 100644 index 0000000..7acdc73 --- /dev/null +++ b/dbapp/.python-version @@ -0,0 +1 @@ +3.13.7 \ No newline at end of file diff --git a/dbapp/CELERY_SETUP.md b/dbapp/CELERY_SETUP.md new file mode 100644 index 0000000..0ab4512 --- /dev/null +++ b/dbapp/CELERY_SETUP.md @@ -0,0 +1,217 @@ +# Celery Setup and Testing Instructions + +## Prerequisites + +Make sure you have Redis running (it's already configured in your docker-compose.yaml): + +```bash +# Start Redis and other services +cd /home/vesemir/DataStorage +docker-compose up -d redis +``` + +## Installing Dependencies + +```bash +pip install -r requirements.txt +``` + +## Database Setup + +Since we're using django-celery-results and celery-beat, you need to run migrations: + +```bash +python manage.py migrate +``` + +This will create the necessary tables for storing Celery results and managing periodic tasks. + +## Running Celery + +### 1. Start Celery Worker + +```bash +# From the dbapp directory +cd /home/vesemir/DataStorage/dbapp + +# Run with development settings +python -m celery -A dbapp worker --loglevel=info + +# Or with environment variable +DJANGO_SETTINGS_MODULE=dbapp.settings.development celery -A dbapp worker --loglevel=info +``` + +### 2. Start Celery Beat (for periodic tasks) + +```bash +# From the dbapp directory +cd /home/vesemir/DataStorage/dbapp + +# Run with development settings +python -m celery -A dbapp beat --loglevel=info + +# Or with environment variable +DJANGO_SETTINGS_MODULE=dbapp.settings.development celery -A dbapp beat --loglevel=info +``` + +### 3. Start Flower (Optional - for monitoring) + +```bash +# Install flower if not already installed +pip install flower + +# Run flower to monitor tasks +celery -A dbapp flower +``` + +## Testing Celery + +### Method 1: Using Django Shell + +```bash +cd /home/vesemir/DataStorage/dbapp +python manage.py shell +``` + +```python +# In the Django shell +from mainapp.tasks import test_celery_connection, add_numbers +from lyngsatapp.tasks import fill_lyngsat_data_task + +# Test simple connection +result = test_celery_connection.delay("Test message!") +print(result.id) # Task ID +print(result.get(timeout=10)) # Wait for result and print + +# Test addition +result = add_numbers.delay(5, 7) +print(result.get(timeout=10)) + +# Check task state +print(result.state) # Should be 'SUCCESS' +print(result.ready()) # Should be True +print(result.successful()) # Should be True +``` + +### Method 2: Using Django Management Command + +Create a management command to test: + +```bash +mkdir -p dbapp/management/commands +``` + +Create `/home/vesemir/DataStorage/dbapp/dbapp/management/commands/test_celery.py`: + +```python +from django.core.management.base import BaseCommand +from mainapp.tasks import test_celery_connection, add_numbers + + +class Command(BaseCommand): + help = 'Test Celery functionality' + + def handle(self, *args, **options): + self.stdout.write('Testing Celery connection...') + + # Test simple task + result = test_celery_connection.delay("Hello from test command!") + self.stdout.write(f'Task ID: {result.id}') + + # Wait for result + task_result = result.get(timeout=10) + self.stdout.write(self.style.SUCCESS(f'Task result: {task_result}')) + + # Test math task + math_result = add_numbers.delay(10, 20) + sum_result = math_result.get(timeout=10) + self.stdout.write(self.style.SUCCESS(f'10 + 20 = {sum_result}')) + + self.stdout.write(self.style.SUCCESS('All tests passed!')) +``` + +Then run: +```bash +python manage.py test_celery +``` + +## Troubleshooting + +### Common Issues + +1. **Connection Error with Redis**: Make sure Redis is running + ```bash + docker-compose up -d redis + ``` + +2. **Module Not Found Errors**: Ensure all dependencies are installed + ```bash + pip install -r requirements.txt + ``` + +3. **Settings Module Error**: Make sure DJANGO_SETTINGS_MODULE is set properly + ```bash + export DJANGO_SETTINGS_MODULE=dbapp.settings.development + ``` + +4. **Database Tables Missing**: Run migrations + ```bash + python manage.py migrate + ``` + +### Debugging + +Check if Celery can connect to Redis: + +```bash +# Test Redis connection +redis-cli ping +``` + +Check Celery configuration: +```python +# In Django shell +from django.conf import settings +print(settings.CELERY_BROKER_URL) +print(settings.CELERY_RESULT_BACKEND) +``` + +### Environment Variables + +Make sure your `.env` file contains: + +``` +CELERY_BROKER_URL=redis://localhost:6379/0 +DJANGO_SETTINGS_MODULE=dbapp.settings.development +``` + +## Running in Production + +For production, ensure you have: + +1. A production Redis instance +2. Proper security settings +3. Daemonized Celery workers + +Example systemd service file for Celery worker (save as `/etc/systemd/system/celery.service`): + +``` +[Unit] +Description=Celery Service +After=network.target + +[Service] +Type=forking +User=www-data +Group=www-data +EnvironmentFile=/path/to/your/.env +WorkingDirectory=/home/vesemir/DataStorage/dbapp +ExecStart=/path/to/your/venv/bin/celery -A dbapp worker --loglevel=info --pidfile=/var/run/celery/worker.pid --logfile=/var/log/celery/worker.log +ExecReload=/bin/kill -HUP $MAINPID +KillSignal=SIGTERM +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=multi-user.target +``` \ No newline at end of file diff --git a/dbapp/Dockerfile b/dbapp/Dockerfile index 74c2bd5..1a4f298 100644 --- a/dbapp/Dockerfile +++ b/dbapp/Dockerfile @@ -1,57 +1,57 @@ -FROM python:3.13-slim - -# Install system dependencies -RUN apt-get update && apt-get install -y \ - gdal-bin \ - libgdal-dev \ - proj-bin \ - proj-data \ - libproj-dev \ - libproj25 \ - libgeos-dev \ - libgeos-c1v5 \ - build-essential \ - postgresql-client \ - libpq-dev \ - libpq5 \ - netcat-openbsd \ - gcc \ - g++ \ - && rm -rf /var/lib/apt/lists/* - -# Set environment variables -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 - -# Set work directory -WORKDIR /app - -# Upgrade pip -RUN pip install --upgrade pip - -# Copy requirements file -COPY requirements.txt ./ - -# Install dependencies -RUN pip install --no-cache-dir -r requirements.txt - -# Copy project files -COPY . . - -# Create directories -RUN mkdir -p /app/staticfiles /app/logs /app/media - -# Set permissions for entrypoint -RUN chmod +x /app/entrypoint.sh - -# Create non-root user -RUN useradd --create-home --shell /bin/bash app && \ - chown -R app:app /app - -USER app - -# Expose port -EXPOSE 8000 - -# Run entrypoint script -ENTRYPOINT ["/app/entrypoint.sh"] +FROM python:3.13-slim + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gdal-bin \ + libgdal-dev \ + proj-bin \ + proj-data \ + libproj-dev \ + libproj25 \ + libgeos-dev \ + libgeos-c1v5 \ + build-essential \ + postgresql-client \ + libpq-dev \ + libpq5 \ + netcat-openbsd \ + gcc \ + g++ \ + && rm -rf /var/lib/apt/lists/* + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +# Set work directory +WORKDIR /app + +# Upgrade pip +RUN pip install --upgrade pip + +# Copy requirements file +COPY requirements.txt ./ + +# Install dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy project files +COPY . . + +# Create directories +RUN mkdir -p /app/staticfiles /app/logs /app/media + +# Set permissions for entrypoint +RUN chmod +x /app/entrypoint.sh + +# Create non-root user +RUN useradd --create-home --shell /bin/bash app && \ + chown -R app:app /app + +USER app + +# Expose port +EXPOSE 8000 + +# Run entrypoint script +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/dbapp/dbapp/__init__.py b/dbapp/dbapp/__init__.py index 0c8d2b7..496bebd 100644 --- a/dbapp/dbapp/__init__.py +++ b/dbapp/dbapp/__init__.py @@ -1,8 +1,7 @@ -# This will make sure the app is always imported when -# Django starts so that shared_task will use this app. -try: - from .celery import app as celery_app - __all__ = ('celery_app',) -except ImportError: - # Celery is not installed, skip initialization - pass +# This will make sure the app is always imported when +# Django starts so that shared_task will use this app. +try: + from .celery import app as celery_app + __all__ = ('celery_app',) +except ImportError: + pass diff --git a/dbapp/dbapp/asgi.py b/dbapp/dbapp/asgi.py index 39a11d1..0208f17 100644 --- a/dbapp/dbapp/asgi.py +++ b/dbapp/dbapp/asgi.py @@ -1,16 +1,16 @@ -""" -ASGI config for dbapp project. - -It exposes the ASGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/ -""" - -import os - -from django.core.asgi import get_asgi_application - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dbapp.settings') - -application = get_asgi_application() +""" +ASGI config for dbapp project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dbapp.settings') + +application = get_asgi_application() diff --git a/dbapp/dbapp/celery.py b/dbapp/dbapp/celery.py index cc1657c..73e830d 100644 --- a/dbapp/dbapp/celery.py +++ b/dbapp/dbapp/celery.py @@ -4,8 +4,8 @@ Celery configuration for dbapp project. import os from celery import Celery -# Set the default Django settings module for the 'celery' program. -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dbapp.settings.development') +# Use the environment variable to determine the settings module +os.environ.setdefault('DJANGO_SETTINGS_MODULE', os.getenv('DJANGO_SETTINGS_MODULE', 'dbapp.settings.development')) app = Celery('dbapp') diff --git a/dbapp/dbapp/settings/__init__.py b/dbapp/dbapp/settings/__init__.py index a6e25bf..797a622 100644 --- a/dbapp/dbapp/settings/__init__.py +++ b/dbapp/dbapp/settings/__init__.py @@ -1,25 +1,25 @@ -""" -Settings module initialization. - -Automatically determines the environment and loads appropriate settings. -Set DJANGO_ENVIRONMENT environment variable to 'production' or 'development'. -Defaults to 'development' if not set. -""" - -import os - -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -# Determine the environment from DJANGO_ENVIRONMENT variable -# Defaults to 'development' for safety -ENVIRONMENT = os.getenv('DJANGO_ENVIRONMENT', 'development').lower() - -if ENVIRONMENT == 'production': - from .production import * - print("Loading production settings...") -else: - from .development import * +""" +Settings module initialization. + +Automatically determines the environment and loads appropriate settings. +Set DJANGO_ENVIRONMENT environment variable to 'production' or 'development'. +Defaults to 'development' if not set. +""" + +import os + +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +# Determine the environment from DJANGO_ENVIRONMENT variable +# Defaults to 'development' for safety +ENVIRONMENT = os.getenv('DJANGO_ENVIRONMENT', 'development').lower() + +if ENVIRONMENT == 'production': + from .production import * + print("Loading production settings...") +else: + from .development import * print("Loading development settings...") \ No newline at end of file diff --git a/dbapp/dbapp/settings/base.py b/dbapp/dbapp/settings/base.py index c6b92ef..3d936d8 100644 --- a/dbapp/dbapp/settings/base.py +++ b/dbapp/dbapp/settings/base.py @@ -73,22 +73,13 @@ INSTALLED_APPS = [ "django_admin_multiple_choice_list_filter", "more_admin_filters", "import_export", + "django_celery_results", # Project apps "mainapp", "mapsapp", "lyngsatapp", ] -# Add Celery results app if available -try: - import django_celery_results - - INSTALLED_APPS.append("django_celery_results") -except ImportError: - pass - -# Note: Custom user model is implemented via OneToOneField relationship -# If you need a custom user model, uncomment and configure: # AUTH_USER_MODEL = 'mainapp.CustomUser' # ============================================================================ @@ -240,7 +231,7 @@ LEAFLET_CONFIG = { # Celery Configuration Options CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0") -CELERY_RESULT_BACKEND = "django-db" +CELERY_RESULT_BACKEND = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0") # Use Redis for results CELERY_CACHE_BACKEND = "default" # Celery Task Configuration diff --git a/dbapp/dbapp/settings/development.py b/dbapp/dbapp/settings/development.py index a94b1eb..3577cbf 100644 --- a/dbapp/dbapp/settings/development.py +++ b/dbapp/dbapp/settings/development.py @@ -1,48 +1,48 @@ -""" -Development-specific settings. -""" - -from .base import * - -# ============================================================================ -# DEBUG CONFIGURATION -# ============================================================================ - -DEBUG = True - -# ============================================================================ -# ALLOWED HOSTS -# ============================================================================ - -# Allow all hosts in development -ALLOWED_HOSTS = ['*'] - -# ============================================================================ -# INSTALLED APPS - Development additions -# ============================================================================ - -INSTALLED_APPS += [ - 'debug_toolbar', -] - -# ============================================================================ -# MIDDLEWARE - Development additions -# ============================================================================ - -# Add debug toolbar middleware at the beginning -MIDDLEWARE = ['debug_toolbar.middleware.DebugToolbarMiddleware'] + MIDDLEWARE - -# ============================================================================ -# DEBUG TOOLBAR CONFIGURATION -# ============================================================================ - -INTERNAL_IPS = [ - '127.0.0.1', -] - -# ============================================================================ -# EMAIL CONFIGURATION -# ============================================================================ - -# Use console backend for development +""" +Development-specific settings. +""" + +from .base import * + +# ============================================================================ +# DEBUG CONFIGURATION +# ============================================================================ + +DEBUG = True + +# ============================================================================ +# ALLOWED HOSTS +# ============================================================================ + +# Allow all hosts in development +ALLOWED_HOSTS = ['*'] + +# ============================================================================ +# INSTALLED APPS - Development additions +# ============================================================================ + +INSTALLED_APPS += [ + 'debug_toolbar', +] + +# ============================================================================ +# MIDDLEWARE - Development additions +# ============================================================================ + +# Add debug toolbar middleware at the beginning +MIDDLEWARE = ['debug_toolbar.middleware.DebugToolbarMiddleware'] + MIDDLEWARE + +# ============================================================================ +# DEBUG TOOLBAR CONFIGURATION +# ============================================================================ + +INTERNAL_IPS = [ + '127.0.0.1', +] + +# ============================================================================ +# EMAIL CONFIGURATION +# ============================================================================ + +# Use console backend for development EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' \ No newline at end of file diff --git a/dbapp/dbapp/settings/production.py b/dbapp/dbapp/settings/production.py index 6158ca5..d3e75f6 100644 --- a/dbapp/dbapp/settings/production.py +++ b/dbapp/dbapp/settings/production.py @@ -1,135 +1,135 @@ -""" -Production-specific settings. -""" - -import os - -from .base import * - -# ============================================================================ -# DEBUG CONFIGURATION -# ============================================================================ - -DEBUG = False - -# ============================================================================ -# ALLOWED HOSTS -# ============================================================================ - -# In production, specify allowed hosts explicitly from environment variable -ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") - -# ============================================================================ -# SECURITY SETTINGS -# ============================================================================ - -# SSL/HTTPS settings -SECURE_SSL_REDIRECT = True -SESSION_COOKIE_SECURE = True -CSRF_COOKIE_SECURE = True - -# Security headers -SECURE_BROWSER_XSS_FILTER = True -SECURE_CONTENT_TYPE_NOSNIFF = True - -# HSTS settings -SECURE_HSTS_SECONDS = 31536000 # 1 year -SECURE_HSTS_INCLUDE_SUBDOMAINS = True -SECURE_HSTS_PRELOAD = True - -# Additional security settings -SECURE_REDIRECT_EXEMPT = [] -X_FRAME_OPTIONS = "DENY" - -# ============================================================================ -# TEMPLATE CACHING -# ============================================================================ - -TEMPLATES = [ - { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [ - BASE_DIR / "templates", - ], - "APP_DIRS": True, - "OPTIONS": { - "context_processors": [ - "django.template.context_processors.debug", - "django.template.context_processors.request", - "django.contrib.auth.context_processors.auth", - "django.contrib.messages.context_processors.messages", - ], - "loaders": [ - ( - "django.template.loaders.cached.Loader", - [ - "django.template.loaders.filesystem.Loader", - "django.template.loaders.app_directories.Loader", - ], - ), - ], - }, - }, -] - -# ============================================================================ -# STATIC FILES CONFIGURATION -# ============================================================================ - -STATIC_ROOT = BASE_DIR.parent / "staticfiles" -STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage" - -# ============================================================================ -# LOGGING CONFIGURATION -# ============================================================================ - -LOGGING = { - "version": 1, - "disable_existing_loggers": False, - "formatters": { - "verbose": { - "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}", - "style": "{", - }, - "simple": { - "format": "{levelname} {message}", - "style": "{", - }, - }, - "filters": { - "require_debug_false": { - "()": "django.utils.log.RequireDebugFalse", - }, - }, - "handlers": { - "console": { - "level": "INFO", - "class": "logging.StreamHandler", - "formatter": "simple", - }, - "file": { - "level": "ERROR", - "class": "logging.FileHandler", - "filename": BASE_DIR.parent / "logs" / "django_errors.log", - "formatter": "verbose", - }, - "mail_admins": { - "level": "ERROR", - "class": "django.utils.log.AdminEmailHandler", - "filters": ["require_debug_false"], - "formatter": "verbose", - }, - }, - "loggers": { - "django": { - "handlers": ["console", "file"], - "level": "INFO", - "propagate": True, - }, - "django.request": { - "handlers": ["mail_admins", "file"], - "level": "ERROR", - "propagate": False, - }, - }, -} +""" +Production-specific settings. +""" + +import os + +from .base import * + +# ============================================================================ +# DEBUG CONFIGURATION +# ============================================================================ + +DEBUG = False + +# ============================================================================ +# ALLOWED HOSTS +# ============================================================================ + +# In production, specify allowed hosts explicitly from environment variable +ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") + +# ============================================================================ +# SECURITY SETTINGS +# ============================================================================ + +# SSL/HTTPS settings +SECURE_SSL_REDIRECT = True +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True + +# Security headers +SECURE_BROWSER_XSS_FILTER = True +SECURE_CONTENT_TYPE_NOSNIFF = True + +# HSTS settings +SECURE_HSTS_SECONDS = 31536000 # 1 year +SECURE_HSTS_INCLUDE_SUBDOMAINS = True +SECURE_HSTS_PRELOAD = True + +# Additional security settings +SECURE_REDIRECT_EXEMPT = [] +X_FRAME_OPTIONS = "DENY" + +# ============================================================================ +# TEMPLATE CACHING +# ============================================================================ + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [ + BASE_DIR / "templates", + ], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + "loaders": [ + ( + "django.template.loaders.cached.Loader", + [ + "django.template.loaders.filesystem.Loader", + "django.template.loaders.app_directories.Loader", + ], + ), + ], + }, + }, +] + +# ============================================================================ +# STATIC FILES CONFIGURATION +# ============================================================================ + +STATIC_ROOT = BASE_DIR.parent / "staticfiles" +STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage" + +# ============================================================================ +# LOGGING CONFIGURATION +# ============================================================================ + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}", + "style": "{", + }, + "simple": { + "format": "{levelname} {message}", + "style": "{", + }, + }, + "filters": { + "require_debug_false": { + "()": "django.utils.log.RequireDebugFalse", + }, + }, + "handlers": { + "console": { + "level": "INFO", + "class": "logging.StreamHandler", + "formatter": "simple", + }, + "file": { + "level": "ERROR", + "class": "logging.FileHandler", + "filename": BASE_DIR.parent / "logs" / "django_errors.log", + "formatter": "verbose", + }, + "mail_admins": { + "level": "ERROR", + "class": "django.utils.log.AdminEmailHandler", + "filters": ["require_debug_false"], + "formatter": "verbose", + }, + }, + "loggers": { + "django": { + "handlers": ["console", "file"], + "level": "INFO", + "propagate": True, + }, + "django.request": { + "handlers": ["mail_admins", "file"], + "level": "ERROR", + "propagate": False, + }, + }, +} diff --git a/dbapp/dbapp/urls.py b/dbapp/dbapp/urls.py index dacb64a..e33f309 100644 --- a/dbapp/dbapp/urls.py +++ b/dbapp/dbapp/urls.py @@ -1,30 +1,30 @@ -""" -URL configuration for dbapp project. - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/5.2/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.urls import include, path - 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) -""" -from django.contrib import admin -from django.urls import path, include -from mainapp import views -from django.contrib.auth import views as auth_views -from debug_toolbar.toolbar import debug_toolbar_urls - -urlpatterns = [ - path('admin/', admin.site.urls, name='admin'), - path('', include('mainapp.urls', namespace='mainapp')), - path('', include('mapsapp.urls', namespace='mapsapp')), - # Authentication URLs - path('login/', auth_views.LoginView.as_view(), name='login'), - path('logout/', views.custom_logout, name='logout'), -] + debug_toolbar_urls() +""" +URL configuration for dbapp project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, include +from mainapp import views +from django.contrib.auth import views as auth_views +from debug_toolbar.toolbar import debug_toolbar_urls + +urlpatterns = [ + path('admin/', admin.site.urls, name='admin'), + path('', include('mainapp.urls', namespace='mainapp')), + path('', include('mapsapp.urls', namespace='mapsapp')), + # Authentication URLs + path('login/', auth_views.LoginView.as_view(), name='login'), + path('logout/', views.custom_logout, name='logout'), +] + debug_toolbar_urls() diff --git a/dbapp/dbapp/wsgi.py b/dbapp/dbapp/wsgi.py index 7303f4c..01d239e 100644 --- a/dbapp/dbapp/wsgi.py +++ b/dbapp/dbapp/wsgi.py @@ -1,16 +1,16 @@ -""" -WSGI config for dbapp project. - -It exposes the WSGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/ -""" - -import os - -from django.core.wsgi import get_wsgi_application - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dbapp.settings') - -application = get_wsgi_application() +""" +WSGI config for dbapp project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dbapp.settings') + +application = get_wsgi_application() diff --git a/dbapp/entrypoint.sh b/dbapp/entrypoint.sh index e93964d..7849f00 100755 --- a/dbapp/entrypoint.sh +++ b/dbapp/entrypoint.sh @@ -1,37 +1,37 @@ -#!/bin/bash -set -e - -# Определяем окружение (по умолчанию production) -ENVIRONMENT=${ENVIRONMENT:-production} - -echo "Starting in $ENVIRONMENT mode..." - -# Ждем PostgreSQL -echo "Waiting for PostgreSQL..." -while ! nc -z $DB_HOST $DB_PORT; do - sleep 0.1 -done -echo "PostgreSQL started" - -# Выполняем миграции -echo "Running migrations..." -python manage.py migrate --noinput - -# Собираем статику (только для production) -if [ "$ENVIRONMENT" = "production" ]; then - echo "Collecting static files..." - python manage.py collectstatic --noinput -fi - -# Запускаем сервер в зависимости от окружения -if [ "$ENVIRONMENT" = "development" ]; then - echo "Starting Django development server..." - exec python manage.py runserver 0.0.0.0:8000 -else - echo "Starting Gunicorn..." - exec gunicorn --bind 0.0.0.0:8000 \ - --workers ${GUNICORN_WORKERS:-3} \ - --timeout ${GUNICORN_TIMEOUT:-120} \ - --reload \ - dbapp.wsgi:application -fi +#!/bin/bash +set -e + +# Определяем окружение (по умолчанию production) +ENVIRONMENT=${ENVIRONMENT:-production} + +echo "Starting in $ENVIRONMENT mode..." + +# Ждем PostgreSQL +echo "Waiting for PostgreSQL..." +while ! nc -z $DB_HOST $DB_PORT; do + sleep 0.1 +done +echo "PostgreSQL started" + +# Выполняем миграции +echo "Running migrations..." +python manage.py migrate --noinput + +# Собираем статику (только для production) +if [ "$ENVIRONMENT" = "production" ]; then + echo "Collecting static files..." + python manage.py collectstatic --noinput +fi + +# Запускаем сервер в зависимости от окружения +if [ "$ENVIRONMENT" = "development" ]; then + echo "Starting Django development server..." + exec python manage.py runserver 0.0.0.0:8000 +else + echo "Starting Gunicorn..." + exec gunicorn --bind 0.0.0.0:8000 \ + --workers ${GUNICORN_WORKERS:-3} \ + --timeout ${GUNICORN_TIMEOUT:-120} \ + --reload \ + dbapp.wsgi:application +fi diff --git a/dbapp/lyngsatapp/admin.py b/dbapp/lyngsatapp/admin.py index 82835d8..dfee93a 100644 --- a/dbapp/lyngsatapp/admin.py +++ b/dbapp/lyngsatapp/admin.py @@ -1,10 +1,10 @@ -from django.contrib import admin -from .models import LyngSat - -@admin.register(LyngSat) -class LyngSatAdmin(admin.ModelAdmin): - list_display = ("id_satellite", "frequency", "polarization", "modulation", "last_update") - search_fields = ("id_satellite__name", "channel_info") - list_filter = ("id_satellite", "polarization", "modulation", "standard") - ordering = ("-last_update",) +from django.contrib import admin +from .models import LyngSat + +@admin.register(LyngSat) +class LyngSatAdmin(admin.ModelAdmin): + list_display = ("id_satellite", "frequency", "polarization", "modulation", "last_update") + search_fields = ("id_satellite__name", "channel_info") + list_filter = ("id_satellite", "polarization", "modulation", "standard") + ordering = ("-last_update",) readonly_fields = ("last_update",) \ No newline at end of file diff --git a/dbapp/lyngsatapp/apps.py b/dbapp/lyngsatapp/apps.py index 28cba5e..eb805e3 100644 --- a/dbapp/lyngsatapp/apps.py +++ b/dbapp/lyngsatapp/apps.py @@ -1,6 +1,6 @@ -from django.apps import AppConfig - - -class LyngsatappConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'lyngsatapp' +from django.apps import AppConfig + + +class LyngsatappConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'lyngsatapp' diff --git a/dbapp/lyngsatapp/migrations/0001_initial.py b/dbapp/lyngsatapp/migrations/0001_initial.py index f928f51..7a17001 100644 --- a/dbapp/lyngsatapp/migrations/0001_initial.py +++ b/dbapp/lyngsatapp/migrations/0001_initial.py @@ -1,37 +1,37 @@ -# Generated by Django 5.2.7 on 2025-11-10 20:03 - -import django.db.models.deletion -import mainapp.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('mainapp', '0007_remove_parameter_objitems_parameter_objitem'), - ] - - operations = [ - migrations.CreateModel( - name='LyngSat', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('frequency', models.FloatField(blank=True, default=0, null=True, verbose_name='Частота, МГц')), - ('sym_velocity', models.FloatField(blank=True, default=0, null=True, verbose_name='Символьная скорость, БОД')), - ('last_update', models.DateTimeField(blank=True, null=True, verbose_name='Время')), - ('channel_info', models.CharField(blank=True, max_length=20, null=True, verbose_name='Описание источника')), - ('fec', models.CharField(blank=True, max_length=30, null=True, verbose_name='Коэффициент коррекции ошибок')), - ('url', models.URLField(blank=True, null=True, verbose_name='Ссылка на страницу')), - ('id_satellite', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='lyngsat', to='mainapp.satellite', verbose_name='Спутник')), - ('modulation', models.ForeignKey(blank=True, default=mainapp.models.get_default_modulation, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='lyngsat', to='mainapp.modulation', verbose_name='Модуляция')), - ('polarization', models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='lyngsat', to='mainapp.polarization', verbose_name='Поляризация')), - ('standard', models.ForeignKey(blank=True, default=mainapp.models.get_default_standard, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='lyngsat', to='mainapp.standard', verbose_name='Стандарт')), - ], - options={ - 'verbose_name': 'Источник LyngSat', - 'verbose_name_plural': 'Источники LyngSat', - }, - ), - ] +# Generated by Django 5.2.7 on 2025-11-10 20:03 + +import django.db.models.deletion +import mainapp.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('mainapp', '0007_remove_parameter_objitems_parameter_objitem'), + ] + + operations = [ + migrations.CreateModel( + name='LyngSat', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('frequency', models.FloatField(blank=True, default=0, null=True, verbose_name='Частота, МГц')), + ('sym_velocity', models.FloatField(blank=True, default=0, null=True, verbose_name='Символьная скорость, БОД')), + ('last_update', models.DateTimeField(blank=True, null=True, verbose_name='Время')), + ('channel_info', models.CharField(blank=True, max_length=20, null=True, verbose_name='Описание источника')), + ('fec', models.CharField(blank=True, max_length=30, null=True, verbose_name='Коэффициент коррекции ошибок')), + ('url', models.URLField(blank=True, null=True, verbose_name='Ссылка на страницу')), + ('id_satellite', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='lyngsat', to='mainapp.satellite', verbose_name='Спутник')), + ('modulation', models.ForeignKey(blank=True, default=mainapp.models.get_default_modulation, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='lyngsat', to='mainapp.modulation', verbose_name='Модуляция')), + ('polarization', models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='lyngsat', to='mainapp.polarization', verbose_name='Поляризация')), + ('standard', models.ForeignKey(blank=True, default=mainapp.models.get_default_standard, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='lyngsat', to='mainapp.standard', verbose_name='Стандарт')), + ], + options={ + 'verbose_name': 'Источник LyngSat', + 'verbose_name_plural': 'Источники LyngSat', + }, + ), + ] diff --git a/dbapp/lyngsatapp/migrations/0002_alter_lyngsat_last_update.py b/dbapp/lyngsatapp/migrations/0002_alter_lyngsat_last_update.py new file mode 100644 index 0000000..422a7dd --- /dev/null +++ b/dbapp/lyngsatapp/migrations/0002_alter_lyngsat_last_update.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2025-11-11 13:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('lyngsatapp', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='lyngsat', + name='last_update', + field=models.DateTimeField(blank=True, null=True, verbose_name='Дата посленего обновления'), + ), + ] diff --git a/dbapp/lyngsatapp/models.py b/dbapp/lyngsatapp/models.py index 003a6bb..7d4a53f 100644 --- a/dbapp/lyngsatapp/models.py +++ b/dbapp/lyngsatapp/models.py @@ -1,37 +1,37 @@ -from django.db import models -from mainapp.models import ( - Satellite, - Polarization, - Modulation, - Standard, - get_default_polarization, - get_default_modulation, - get_default_standard -) - -class LyngSat(models.Model): - id_satellite = models.ForeignKey(Satellite, on_delete=models.PROTECT, related_name="lyngsat", verbose_name="Спутник", null=True) - polarization = models.ForeignKey( - Polarization, default=get_default_polarization, on_delete=models.SET_DEFAULT, related_name="lyngsat", null=True, blank=True, verbose_name="Поляризация" - ) - modulation = models.ForeignKey( - Modulation, default=get_default_modulation, on_delete=models.SET_DEFAULT, related_name="lyngsat", null=True, blank=True, verbose_name="Модуляция" - ) - standard = models.ForeignKey( - Standard, default=get_default_standard, on_delete=models.SET_DEFAULT, related_name="lyngsat", null=True, blank=True, verbose_name="Стандарт" - ) - frequency = models.FloatField(default=0, null=True, blank=True, verbose_name="Частота, МГц") - sym_velocity = models.FloatField(default=0, null=True, blank=True, verbose_name="Символьная скорость, БОД") - last_update = models.DateTimeField(null=True, blank=True, verbose_name="Время") - channel_info = models.CharField(max_length=20, blank=True, null=True, verbose_name="Описание источника") - fec = models.CharField(max_length=30, blank=True, null=True, verbose_name="Коэффициент коррекции ошибок") - url = models.URLField(max_length = 200, blank=True, null=True, verbose_name="Ссылка на страницу") - - def __str__(self): - return f"Ист {self.frequency}, {self.polarization}" - - class Meta: - verbose_name = "Источник LyngSat" - verbose_name_plural = "Источники LyngSat" - - +from django.db import models +from mainapp.models import ( + Satellite, + Polarization, + Modulation, + Standard, + get_default_polarization, + get_default_modulation, + get_default_standard +) + +class LyngSat(models.Model): + id_satellite = models.ForeignKey(Satellite, on_delete=models.PROTECT, related_name="lyngsat", verbose_name="Спутник", null=True) + polarization = models.ForeignKey( + Polarization, default=get_default_polarization, on_delete=models.SET_DEFAULT, related_name="lyngsat", null=True, blank=True, verbose_name="Поляризация" + ) + modulation = models.ForeignKey( + Modulation, default=get_default_modulation, on_delete=models.SET_DEFAULT, related_name="lyngsat", null=True, blank=True, verbose_name="Модуляция" + ) + standard = models.ForeignKey( + Standard, default=get_default_standard, on_delete=models.SET_DEFAULT, related_name="lyngsat", null=True, blank=True, verbose_name="Стандарт" + ) + frequency = models.FloatField(default=0, null=True, blank=True, verbose_name="Частота, МГц") + sym_velocity = models.FloatField(default=0, null=True, blank=True, verbose_name="Символьная скорость, БОД") + last_update = models.DateTimeField(null=True, blank=True, verbose_name="Дата посленего обновления") + channel_info = models.CharField(max_length=20, blank=True, null=True, verbose_name="Описание источника") + fec = models.CharField(max_length=30, blank=True, null=True, verbose_name="Коэффициент коррекции ошибок") + url = models.URLField(max_length = 200, blank=True, null=True, verbose_name="Ссылка на страницу") + + def __str__(self): + return f"Ист {self.frequency}, {self.polarization}" + + class Meta: + verbose_name = "Источник LyngSat" + verbose_name_plural = "Источники LyngSat" + + diff --git a/dbapp/lyngsatapp/parser.py b/dbapp/lyngsatapp/parser.py index 0bf5289..26bc3eb 100644 --- a/dbapp/lyngsatapp/parser.py +++ b/dbapp/lyngsatapp/parser.py @@ -1,405 +1,437 @@ -import requests -from bs4 import BeautifulSoup -from datetime import datetime -import re -import time - - -class LyngSatParser: - """Парсер данных для LyngSat(Для работы нужен flaresolver)""" - - def __init__( - self, - flaresolver_url: str = "http://localhost:8191/v1", - regions: list[str] | None = None, - target_sats: list[str] | None = None, - ): - self.flaresolver_url = flaresolver_url - self.regions = regions - self.target_sats = ( - list(map(lambda sat: sat.strip().lower(), target_sats)) if regions else None - ) - self.regions = regions if regions else ["europe", "asia", "america", "atlantic"] - self.BASE_URL = "https://www.lyngsat.com" - - def parse_metadata(self, metadata: str) -> dict: - if not metadata or not metadata.strip(): - return { - "standard": None, - "modulation": None, - "symbol_rate": None, - "fec": None, - } - normalized = re.sub(r"\s+", "", metadata.strip()) - fec_match = re.search(r"([1-9]/[1-9])$", normalized) - fec = fec_match.group(1) if fec_match else None - if fec_match: - core = normalized[: fec_match.start()] - else: - core = normalized - std_match = re.match(r"(DVB-S2?|ABS-S|DVB-T2?|ATSC|ISDB)", core) - standard = std_match.group(1) if std_match else None - rest = core[len(standard) :] if standard else core - modulation = None - mod_match = re.match(r"(8PSK|QPSK|16APSK|32APSK|64QAM|256QAM|BPSK)", rest) - if mod_match: - modulation = mod_match.group(1) - rest = rest[len(modulation) :] - symbol_rate = None - sr_match = re.search(r"(\d+)$", rest) - if sr_match: - try: - symbol_rate = int(sr_match.group(1)) - except ValueError: - pass - - return { - "standard": standard, - "modulation": modulation, - "symbol_rate": symbol_rate, - "fec": fec, - } - - def extract_date(self, s: str) -> datetime | None: - s = s.strip() - match = re.search(r"(\d{6})$", s) - if not match: - return None - yymmdd = match.group(1) - try: - return datetime.strptime(yymmdd, "%y%m%d").date() - except ValueError: - return None - - def convert_polarization(self, polarization: str) -> str: - """Преобразовать код поляризации в понятное название на русском""" - polarization_map = { - "V": "Вертикальная", - "H": "Горизонтальная", - "R": "Правая", - "L": "Левая", - } - return polarization_map.get(polarization.upper(), polarization) - - def get_region_pages(self, regions: list[str] | None = None) -> list[str]: - html_regions = [] - if regions is None: - regions = self.regions - for region in regions: - url = f"{self.BASE_URL}/{region}.html" - payload = {"cmd": "request.get", "url": url, "maxTimeout": 60000} - response = requests.post(self.flaresolver_url, json=payload) - if response.status_code != 200: - continue - html_content = response.json().get("solution", {}).get("response", "") - html_regions.append(html_content) - print(f"Обработал страницу по {region}") - return html_regions - - def get_satellite_urls(self, html_regions: list[str]): - sat_names = [] - sat_urls = [] - for region_page in html_regions: - soup = BeautifulSoup(region_page, "html.parser") - - col_table = soup.find_all("div", class_="desktab")[0] - - tables = col_table.find_next_sibling("table").find_all("table") - trs = [] - for table in tables: - trs.extend(table.find_all("tr")) - for tr in trs: - sat_name = tr.find("span").text - if self.target_sats is not None: - if sat_name.strip().lower() not in self.target_sats: - continue - try: - sat_url = tr.find_all("a")[2]["href"] - except IndexError: - sat_url = tr.find_all("a")[0]["href"] - sat_names.append(sat_name) - sat_urls.append(sat_url) - return sat_names, sat_urls - - def get_satellites_data(self) -> dict[dict]: - sat_data = {} - for region_page in self.get_region_pages(self.regions): - soup = BeautifulSoup(region_page, "html.parser") - - col_table = soup.find_all("div", class_="desktab")[0] - - tables = col_table.find_next_sibling("table").find_all("table") - trs = [] - for table in tables: - trs.extend(table.find_all("tr")) - for tr in trs: - sat_name = tr.find("span").text - if self.target_sats is not None: - if sat_name.strip().lower() not in self.target_sats: - continue - try: - sat_url = tr.find_all("a")[2]["href"] - except IndexError: - sat_url = tr.find_all("a")[0]["href"] - - update_date = tr.find_all("td")[-1].text - sat_response = requests.post( - self.flaresolver_url, - json={ - "cmd": "request.get", - "url": f"{self.BASE_URL}/{sat_url}", - "maxTimeout": 60000, - }, - ) - html_content = ( - sat_response.json().get("solution", {}).get("response", "") - ) - sat_page_data = self.get_satellite_content(html_content) - sat_data[sat_name] = { - "url": f"{self.BASE_URL}/{sat_url}", - "update_date": datetime.strptime(update_date, "%y%m%d").date(), - "sources": sat_page_data, - } - return sat_data - - def get_satellite_content(self, html_content: str) -> dict: - sat_soup = BeautifulSoup(html_content, "html.parser") - big_table = sat_soup.find("table", class_="bigtable") - all_tables = big_table.find_all("div", class_="desktab")[:-1] - data = [] - for table in all_tables: - trs = table.find_next_sibling("table").find_all("tr") - for idx, tr in enumerate(trs): - tds = tr.find_all("td") - if len(tds) < 9 or idx < 2: - continue - freq, polarization = tds[0].find("b").text.strip().split("\xa0") - polarization = self.convert_polarization(polarization) - meta = self.parse_metadata(tds[1].text) - provider_name = tds[3].text - last_update = self.extract_date(tds[-1].text) - data.append( - { - "freq": freq, - "pol": polarization, - "metadata": meta, - "provider_name": provider_name, - "last_update": last_update, - } - ) - return data - - -class KingOfSatParser: - def __init__(self, base_url="https://ru.kingofsat.net", max_satellites=0): - """ - Инициализация парсера - :param base_url: Базовый URL сайта - :param max_satellites: Максимальное количество спутников для парсинга (0 - все) - """ - self.base_url = base_url - self.max_satellites = max_satellites - self.session = requests.Session() - self.session.headers.update( - { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" - } - ) - - def convert_polarization(self, polarization): - """Преобразовать код поляризации в понятное название на русском""" - polarization_map = { - "V": "Вертикальная", - "H": "Горизонтальная", - "R": "Правая", - "L": "Левая", - } - return polarization_map.get(polarization.upper(), polarization) - - def fetch_page(self, url): - """Получить HTML страницу""" - try: - response = self.session.get(url, timeout=30) - response.raise_for_status() - return response.text - except Exception as e: - print(f"Ошибка при получении страницы {url}: {e}") - return None - - def parse_satellite_table(self, html_content): - """Распарсить таблицу со спутниками""" - soup = BeautifulSoup(html_content, "html.parser") - satellites = [] - table = soup.find("table") - if not table: - print("Таблица не найдена") - return satellites - - rows = table.find_all("tr")[1:] - - for row in rows: - cols = row.find_all("td") - if len(cols) < 13: - continue - - try: - position_cell = cols[0].text.strip() - position_match = re.search(r"([\d\.]+)°([EW])", position_cell) - if position_match: - position_value = position_match.group(1) - position_direction = position_match.group(2) - position = f"{position_value}{position_direction}" - else: - position = None - - # Название спутника (2-я колонка) - satellite_cell = cols[1] - satellite_name = satellite_cell.get_text(strip=True) - # Удаляем возможные лишние символы или пробелы - satellite_name = re.sub(r"\s+", " ", satellite_name).strip() - - # NORAD (3-я колонка) - norad = cols[2].text.strip() - if not norad or norad == "-": - norad = None - - ini_link = None - ini_cell = cols[3] - ini_img = ini_cell.find("img", src=lambda x: x and "disquette.gif" in x) - if ini_img and position: - ini_link = f"https://ru.kingofsat.net/dl.php?pos={position}&fkhz=0" - - update_date = cols[12].text.strip() if len(cols) > 12 else None - - if satellite_name and ini_link and position: - satellites.append( - { - "position": position, - "name": satellite_name, - "norad": norad, - "ini_url": ini_link, - "update_date": update_date, - } - ) - - except Exception as e: - print(f"Ошибка при обработке строки таблицы: {e}") - continue - - return satellites - - def parse_ini_file(self, ini_content): - """Распарсить содержимое .ini файла""" - data = {"metadata": {}, "sattype": {}, "dvb": {}} - - # # Извлекаем метаданные из комментариев - # metadata_match = re.search(r'\[ downloaded from www\.kingofsat\.net \(c\) (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \]', ini_content) - # if metadata_match: - # data['metadata']['downloaded'] = metadata_match.group(1) - - # Парсим секцию [SATTYPE] - sattype_match = re.search(r"\[SATTYPE\](.*?)\n\[", ini_content, re.DOTALL) - if sattype_match: - sattype_content = sattype_match.group(1).strip() - for line in sattype_content.split("\n"): - line = line.strip() - if "=" in line: - key, value = line.split("=", 1) - data["sattype"][key.strip()] = value.strip() - - # Парсим секцию [DVB] - dvb_match = re.search(r"\[DVB\](.*?)(?:\n\[|$)", ini_content, re.DOTALL) - if dvb_match: - dvb_content = dvb_match.group(1).strip() - for line in dvb_content.split("\n"): - line = line.strip() - if "=" in line: - key, value = line.split("=", 1) - params = [p.strip() for p in value.split(",")] - polarization = params[1] if len(params) > 1 else "" - if polarization: - polarization = self.convert_polarization(polarization) - - data["dvb"][key.strip()] = { - "frequency": params[0] if len(params) > 0 else "", - "polarization": polarization, - "symbol_rate": params[2] if len(params) > 2 else "", - "fec": params[3] if len(params) > 3 else "", - "standard": params[4] if len(params) > 4 else "", - "modulation": params[5] if len(params) > 5 else "", - } - - return data - - def download_ini_file(self, url): - """Скачать содержимое .ini файла""" - try: - response = self.session.get(url, timeout=30) - response.raise_for_status() - return response.text - except Exception as e: - print(f"Ошибка при скачивании .ini файла {url}: {e}") - return None - - def get_all_satellites_data(self): - """Получить данные всех спутников с учетом ограничения max_satellites""" - html_content = self.fetch_page(self.base_url + "/satellites") - if not html_content: - return [] - - satellites = self.parse_satellite_table(html_content) - - if self.max_satellites > 0 and len(satellites) > self.max_satellites: - satellites = satellites[: self.max_satellites] - - results = [] - processed_count = 0 - - for satellite in satellites: - print(f"Обработка спутника: {satellite['name']} ({satellite['position']})") - - ini_content = self.download_ini_file(satellite["ini_url"]) - if not ini_content: - print(f"Не удалось скачать .ini файл для {satellite['name']}") - continue - - parsed_ini = self.parse_ini_file(ini_content) - - result = { - "satellite_name": satellite["name"], - "position": satellite["position"], - "norad": satellite["norad"], - "update_date": satellite["update_date"], - "ini_url": satellite["ini_url"], - "ini_data": parsed_ini, - } - - results.append(result) - processed_count += 1 - - if self.max_satellites > 0 and processed_count >= self.max_satellites: - break - - time.sleep(1) - - return results - - def create_satellite_dict(self, satellites_data): - """Создать словарь с данными спутников""" - satellite_dict = {} - - for data in satellites_data: - key = f"{data['position']}_{data['satellite_name'].replace(' ', '_').replace('/', '_')}" - satellite_dict[key] = { - "name": data["satellite_name"], - "position": data["position"], - "norad": data["norad"], - "update_date": data["update_date"], - "ini_url": data["ini_url"], - "transponders_count": len(data["ini_data"]["dvb"]), - "transponders": data["ini_data"]["dvb"], - "sattype_info": data["ini_data"]["sattype"], - "metadata": data["ini_data"]["metadata"], - } - - return satellite_dict +import requests +from bs4 import BeautifulSoup +from datetime import datetime +import re +import time + +def parse_satellite_names(satellite_string: str) -> list[str]: + slash_parts = [part.strip() for part in satellite_string.split('/')] + all_names = [] + for part in slash_parts: + main_match = re.match(r'^([^(]+)', part) + if main_match: + main_name = main_match.group(1).strip() + if main_name: + all_names.append(main_name) + bracket_match = re.search(r'\(([^)]+)\)', part) + if bracket_match: + bracket_name = bracket_match.group(1).strip() + if bracket_name: + all_names.append(bracket_name) + seen = set() + result = [] + for name in all_names: + if name not in seen: + seen.add(name) + result.append(name.strip().lower()) + return result + + +class LyngSatParser: + """Парсер данных для LyngSat(Для работы нужен flaresolver)""" + + def __init__( + self, + flaresolver_url: str = "http://localhost:8191/v1", + regions: list[str] | None = None, + target_sats: list[str] | None = None, + ): + self.flaresolver_url = flaresolver_url + self.regions = regions + self.target_sats = ( + list(map(lambda sat: sat.strip().lower(), target_sats)) if regions else None + ) + self.regions = regions if regions else ["europe", "asia", "america", "atlantic"] + self.BASE_URL = "https://www.lyngsat.com" + + def parse_metadata(self, metadata: str) -> dict: + if not metadata or not metadata.strip(): + return { + "standard": None, + "modulation": None, + "symbol_rate": None, + "fec": None, + } + normalized = re.sub(r"\s+", "", metadata.strip()) + fec_match = re.search(r"([1-9]/[1-9])$", normalized) + fec = fec_match.group(1) if fec_match else None + if fec_match: + core = normalized[: fec_match.start()] + else: + core = normalized + std_match = re.match(r"(DVB-S2?|ABS-S|DVB-T2?|ATSC|ISDB)", core) + standard = std_match.group(1) if std_match else None + rest = core[len(standard) :] if standard else core + modulation = None + mod_match = re.match(r"(8PSK|QPSK|16APSK|32APSK|64QAM|256QAM|BPSK)", rest) + if mod_match: + modulation = mod_match.group(1) + rest = rest[len(modulation) :] + symbol_rate = None + sr_match = re.search(r"(\d+)$", rest) + if sr_match: + try: + symbol_rate = int(sr_match.group(1)) + except ValueError: + pass + + return { + "standard": standard, + "modulation": modulation, + "symbol_rate": symbol_rate, + "fec": fec, + } + + def extract_date(self, s: str) -> datetime | None: + s = s.strip() + match = re.search(r"(\d{6})$", s) + if not match: + return None + yymmdd = match.group(1) + try: + return datetime.strptime(yymmdd, "%y%m%d").date() + except ValueError: + return None + + def convert_polarization(self, polarization: str) -> str: + """Преобразовать код поляризации в понятное название на русском""" + polarization_map = { + "V": "Вертикальная", + "H": "Горизонтальная", + "R": "Правая", + "L": "Левая", + } + return polarization_map.get(polarization.upper(), polarization) + + def get_region_pages(self, regions: list[str] | None = None) -> list[str]: + html_regions = [] + if regions is None: + regions = self.regions + for region in regions: + url = f"{self.BASE_URL}/{region}.html" + payload = {"cmd": "request.get", "url": url, "maxTimeout": 60000} + response = requests.post(self.flaresolver_url, json=payload) + if response.status_code != 200: + continue + html_content = response.json().get("solution", {}).get("response", "") + html_regions.append(html_content) + print(f"Обработал страницу по {region}") + return html_regions + + def get_satellite_urls(self, html_regions: list[str]): + sat_names = [] + sat_urls = [] + for region_page in html_regions: + soup = BeautifulSoup(region_page, "html.parser") + + col_table = soup.find_all("div", class_="desktab")[0] + + tables = col_table.find_next_sibling("table").find_all("table") + trs = [] + for table in tables: + trs.extend(table.find_all("tr")) + for tr in trs: + sat_name = tr.find("span").text + if self.target_sats is not None: + if sat_name.strip().lower() not in self.target_sats: + continue + try: + sat_url = tr.find_all("a")[2]["href"] + except IndexError: + sat_url = tr.find_all("a")[0]["href"] + sat_names.append(sat_name) + sat_urls.append(sat_url) + return sat_names, sat_urls + + def get_satellites_data(self) -> dict[dict]: + sat_data = {} + for region_page in self.get_region_pages(self.regions): + soup = BeautifulSoup(region_page, "html.parser") + + col_table = soup.find_all("div", class_="desktab")[0] + + tables = col_table.find_next_sibling("table").find_all("table") + trs = [] + for table in tables: + trs.extend(table.find_all("tr")) + for tr in trs: + sat_name = tr.find("span").text.replace("ü", "u").strip().lower() + if self.target_sats is not None: + names = parse_satellite_names(sat_name) + if len(names) == 1: + sat_name = names[0] + else: + for name in names: + if name in self.target_sats: + sat_name = name + if sat_name not in self.target_sats: + continue + try: + sat_url = tr.find_all("a")[2]["href"] + except IndexError: + sat_url = tr.find_all("a")[0]["href"] + + update_date = tr.find_all("td")[-1].text + sat_response = requests.post( + self.flaresolver_url, + json={ + "cmd": "request.get", + "url": f"{self.BASE_URL}/{sat_url}", + "maxTimeout": 60000, + }, + ) + html_content = ( + sat_response.json().get("solution", {}).get("response", "") + ) + sat_page_data = self.get_satellite_content(html_content) + sat_data[sat_name] = { + "url": f"{self.BASE_URL}/{sat_url}", + "update_date": datetime.strptime(update_date, "%y%m%d").date(), + "sources": sat_page_data, + } + return sat_data + + def get_satellite_content(self, html_content: str) -> list[dict]: + data = [] + sat_soup = BeautifulSoup(html_content, "html.parser") + try: + big_table = sat_soup.find("table", class_="bigtable") + all_tables = big_table.find_all("div", class_="desktab")[:-1] + for table in all_tables: + trs = table.find_next_sibling("table").find_all("tr") + for idx, tr in enumerate(trs): + tds = tr.find_all("td") + if len(tds) < 9 or idx < 2: + continue + freq, polarization = tds[0].find("b").text.strip().split("\xa0") + polarization = self.convert_polarization(polarization) + meta = self.parse_metadata(tds[1].text) + provider_name = tds[3].text + last_update = self.extract_date(tds[-1].text) + data.append( + { + "freq": freq, + "pol": polarization, + "metadata": meta, + "provider_name": provider_name, + "last_update": last_update, + } + ) + except Exception as e: + print(e) + return data if data else data[{}] + + +class KingOfSatParser: + def __init__(self, base_url="https://ru.kingofsat.net", max_satellites=0): + """ + Инициализация парсера + :param base_url: Базовый URL сайта + :param max_satellites: Максимальное количество спутников для парсинга (0 - все) + """ + self.base_url = base_url + self.max_satellites = max_satellites + self.session = requests.Session() + self.session.headers.update( + { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" + } + ) + + def convert_polarization(self, polarization): + """Преобразовать код поляризации в понятное название на русском""" + polarization_map = { + "V": "Вертикальная", + "H": "Горизонтальная", + "R": "Правая", + "L": "Левая", + } + return polarization_map.get(polarization.upper(), polarization) + + def fetch_page(self, url): + """Получить HTML страницу""" + try: + response = self.session.get(url, timeout=30) + response.raise_for_status() + return response.text + except Exception as e: + print(f"Ошибка при получении страницы {url}: {e}") + return None + + def parse_satellite_table(self, html_content): + """Распарсить таблицу со спутниками""" + soup = BeautifulSoup(html_content, "html.parser") + satellites = [] + table = soup.find("table") + if not table: + print("Таблица не найдена") + return satellites + + rows = table.find_all("tr")[1:] + + for row in rows: + cols = row.find_all("td") + if len(cols) < 13: + continue + + try: + position_cell = cols[0].text.strip() + position_match = re.search(r"([\d\.]+)°([EW])", position_cell) + if position_match: + position_value = position_match.group(1) + position_direction = position_match.group(2) + position = f"{position_value}{position_direction}" + else: + position = None + + # Название спутника (2-я колонка) + satellite_cell = cols[1] + satellite_name = satellite_cell.get_text(strip=True) + # Удаляем возможные лишние символы или пробелы + satellite_name = re.sub(r"\s+", " ", satellite_name).strip() + + # NORAD (3-я колонка) + norad = cols[2].text.strip() + if not norad or norad == "-": + norad = None + + ini_link = None + ini_cell = cols[3] + ini_img = ini_cell.find("img", src=lambda x: x and "disquette.gif" in x) + if ini_img and position: + ini_link = f"https://ru.kingofsat.net/dl.php?pos={position}&fkhz=0" + + update_date = cols[12].text.strip() if len(cols) > 12 else None + + if satellite_name and ini_link and position: + satellites.append( + { + "position": position, + "name": satellite_name, + "norad": norad, + "ini_url": ini_link, + "update_date": update_date, + } + ) + + except Exception as e: + print(f"Ошибка при обработке строки таблицы: {e}") + continue + + return satellites + + def parse_ini_file(self, ini_content): + """Распарсить содержимое .ini файла""" + data = {"metadata": {}, "sattype": {}, "dvb": {}} + + # # Извлекаем метаданные из комментариев + # metadata_match = re.search(r'\[ downloaded from www\.kingofsat\.net \(c\) (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \]', ini_content) + # if metadata_match: + # data['metadata']['downloaded'] = metadata_match.group(1) + + # Парсим секцию [SATTYPE] + sattype_match = re.search(r"\[SATTYPE\](.*?)\n\[", ini_content, re.DOTALL) + if sattype_match: + sattype_content = sattype_match.group(1).strip() + for line in sattype_content.split("\n"): + line = line.strip() + if "=" in line: + key, value = line.split("=", 1) + data["sattype"][key.strip()] = value.strip() + + # Парсим секцию [DVB] + dvb_match = re.search(r"\[DVB\](.*?)(?:\n\[|$)", ini_content, re.DOTALL) + if dvb_match: + dvb_content = dvb_match.group(1).strip() + for line in dvb_content.split("\n"): + line = line.strip() + if "=" in line: + key, value = line.split("=", 1) + params = [p.strip() for p in value.split(",")] + polarization = params[1] if len(params) > 1 else "" + if polarization: + polarization = self.convert_polarization(polarization) + + data["dvb"][key.strip()] = { + "frequency": params[0] if len(params) > 0 else "", + "polarization": polarization, + "symbol_rate": params[2] if len(params) > 2 else "", + "fec": params[3] if len(params) > 3 else "", + "standard": params[4] if len(params) > 4 else "", + "modulation": params[5] if len(params) > 5 else "", + } + + return data + + def download_ini_file(self, url): + """Скачать содержимое .ini файла""" + try: + response = self.session.get(url, timeout=30) + response.raise_for_status() + return response.text + except Exception as e: + print(f"Ошибка при скачивании .ini файла {url}: {e}") + return None + + def get_all_satellites_data(self): + """Получить данные всех спутников с учетом ограничения max_satellites""" + html_content = self.fetch_page(self.base_url + "/satellites") + if not html_content: + return [] + + satellites = self.parse_satellite_table(html_content) + + if self.max_satellites > 0 and len(satellites) > self.max_satellites: + satellites = satellites[: self.max_satellites] + + results = [] + processed_count = 0 + + for satellite in satellites: + print(f"Обработка спутника: {satellite['name']} ({satellite['position']})") + + ini_content = self.download_ini_file(satellite["ini_url"]) + if not ini_content: + print(f"Не удалось скачать .ini файл для {satellite['name']}") + continue + + parsed_ini = self.parse_ini_file(ini_content) + + result = { + "satellite_name": satellite["name"], + "position": satellite["position"], + "norad": satellite["norad"], + "update_date": satellite["update_date"], + "ini_url": satellite["ini_url"], + "ini_data": parsed_ini, + } + + results.append(result) + processed_count += 1 + + if self.max_satellites > 0 and processed_count >= self.max_satellites: + break + + time.sleep(1) + + return results + + def create_satellite_dict(self, satellites_data): + """Создать словарь с данными спутников""" + satellite_dict = {} + + for data in satellites_data: + key = f"{data['position']}_{data['satellite_name'].replace(' ', '_').replace('/', '_')}" + satellite_dict[key] = { + "name": data["satellite_name"], + "position": data["position"], + "norad": data["norad"], + "update_date": data["update_date"], + "ini_url": data["ini_url"], + "transponders_count": len(data["ini_data"]["dvb"]), + "transponders": data["ini_data"]["dvb"], + "sattype_info": data["ini_data"]["sattype"], + "metadata": data["ini_data"]["metadata"], + } + + return satellite_dict diff --git a/dbapp/lyngsatapp/tasks.py b/dbapp/lyngsatapp/tasks.py index 48c538c..b21b5b1 100644 --- a/dbapp/lyngsatapp/tasks.py +++ b/dbapp/lyngsatapp/tasks.py @@ -1,73 +1,73 @@ -""" -Celery tasks for Lyngsat data processing. -""" -import logging -from celery import shared_task -from django.core.cache import cache - -from .utils import fill_lyngsat_data - -logger = logging.getLogger(__name__) - - -@shared_task(bind=True, name='lyngsatapp.fill_lyngsat_data_async') -def fill_lyngsat_data_task(self, target_sats, regions=None): - """ - Асинхронная задача для заполнения данных Lyngsat. - - Args: - target_sats: Список названий спутников для обработки - regions: Список регионов для парсинга (по умолчанию все) - - Returns: - dict: Статистика обработки - """ - task_id = self.request.id - logger.info(f"[Task {task_id}] Начало обработки данных Lyngsat") - logger.info(f"[Task {task_id}] Спутники: {', '.join(target_sats)}") - logger.info(f"[Task {task_id}] Регионы: {', '.join(regions) if regions else 'все'}") - - # Обновляем статус задачи - self.update_state( - state='PROGRESS', - meta={ - 'current': 0, - 'total': len(target_sats), - 'status': 'Инициализация...' - } - ) - - try: - # Вызываем функцию заполнения данных - stats = fill_lyngsat_data( - target_sats=target_sats, - regions=regions, - task_id=task_id, - update_progress=lambda current, total, status: self.update_state( - state='PROGRESS', - meta={ - 'current': current, - 'total': total, - 'status': status - } - ) - ) - - logger.info(f"[Task {task_id}] Обработка завершена успешно") - logger.info(f"[Task {task_id}] Статистика: {stats}") - - # Сохраняем результат в кеш для отображения на странице - cache.set(f'lyngsat_task_{task_id}', stats, timeout=3600) - - return stats - - except Exception as e: - logger.error(f"[Task {task_id}] Ошибка при обработке: {str(e)}", exc_info=True) - self.update_state( - state='FAILURE', - meta={ - 'error': str(e), - 'status': 'Ошибка при обработке' - } - ) - raise +""" +Celery tasks for Lyngsat data processing. +""" +import logging +from celery import shared_task +from django.core.cache import cache + +from .utils import fill_lyngsat_data + +logger = logging.getLogger(__name__) + + +@shared_task(bind=True, name='lyngsatapp.fill_lyngsat_data_async') +def fill_lyngsat_data_task(self, target_sats, regions=None): + """ + Асинхронная задача для заполнения данных Lyngsat. + + Args: + target_sats: Список названий спутников для обработки + regions: Список регионов для парсинга (по умолчанию все) + + Returns: + dict: Статистика обработки + """ + task_id = self.request.id + logger.info(f"[Task {task_id}] Начало обработки данных Lyngsat") + logger.info(f"[Task {task_id}] Спутники: {', '.join(target_sats)}") + logger.info(f"[Task {task_id}] Регионы: {', '.join(regions) if regions else 'все'}") + + # Обновляем статус задачи + self.update_state( + state='PROGRESS', + meta={ + 'current': 0, + 'total': len(target_sats), + 'status': 'Инициализация...' + } + ) + + try: + # Вызываем функцию заполнения данных + stats = fill_lyngsat_data( + target_sats=target_sats, + regions=regions, + task_id=task_id, + update_progress=lambda current, total, status: self.update_state( + state='PROGRESS', + meta={ + 'current': current, + 'total': total, + 'status': status + } + ) + ) + + logger.info(f"[Task {task_id}] Обработка завершена успешно") + logger.info(f"[Task {task_id}] Статистика: {stats}") + + # Сохраняем результат в кеш для отображения на странице + cache.set(f'lyngsat_task_{task_id}', stats, timeout=3600) + + return stats + + except Exception as e: + logger.error(f"[Task {task_id}] Ошибка при обработке: {str(e)}", exc_info=True) + self.update_state( + state='FAILURE', + meta={ + 'error': str(e), + 'status': 'Ошибка при обработке' + } + ) + raise diff --git a/dbapp/lyngsatapp/tests.py b/dbapp/lyngsatapp/tests.py index 7ce503c..de8bdc0 100644 --- a/dbapp/lyngsatapp/tests.py +++ b/dbapp/lyngsatapp/tests.py @@ -1,3 +1,3 @@ -from django.test import TestCase - -# Create your tests here. +from django.test import TestCase + +# Create your tests here. diff --git a/dbapp/lyngsatapp/utils.py b/dbapp/lyngsatapp/utils.py index c4e13e3..851a0d7 100644 --- a/dbapp/lyngsatapp/utils.py +++ b/dbapp/lyngsatapp/utils.py @@ -1,170 +1,175 @@ -import logging -from .parser import LyngSatParser -from .models import LyngSat -from mainapp.models import Polarization, Standard, Modulation, Satellite - -logger = logging.getLogger(__name__) - - -def fill_lyngsat_data( - target_sats: list[str], - regions: list[str] = None, - task_id: str = None, - update_progress=None -): - """ - Заполняет данные Lyngsat для указанных спутников и регионов. - - Args: - target_sats: Список названий спутников для обработки - regions: Список регионов для парсинга (по умолчанию все) - task_id: ID задачи Celery для логирования - update_progress: Функция для обновления прогресса (current, total, status) - - Returns: - dict: Статистика обработки с ключами: - - total_satellites: общее количество спутников - - total_sources: общее количество источников - - created: количество созданных записей - - updated: количество обновленных записей - - errors: список ошибок - """ - log_prefix = f"[Task {task_id}]" if task_id else "[Lyngsat]" - stats = { - 'total_satellites': 0, - 'total_sources': 0, - 'created': 0, - 'updated': 0, - 'errors': [] - } - - if regions is None: - regions = ["europe", "asia", "america", "atlantic"] - - logger.info(f"{log_prefix} Начало парсинга данных") - logger.info(f"{log_prefix} Спутники: {', '.join(target_sats)}") - logger.info(f"{log_prefix} Регионы: {', '.join(regions)}") - - if update_progress: - update_progress(0, len(target_sats), "Инициализация парсера...") - - try: - parser = LyngSatParser( - target_sats=target_sats, - regions=regions - ) - - logger.info(f"{log_prefix} Получение данных со спутников...") - if update_progress: - update_progress(0, len(target_sats), "Получение данных со спутников...") - - lyngsat_data = parser.get_satellites_data() - stats['total_satellites'] = len(lyngsat_data) - - logger.info(f"{log_prefix} Получено данных по {stats['total_satellites']} спутникам") - - for idx, (sat_name, data) in enumerate(lyngsat_data.items(), 1): - logger.info(f"{log_prefix} Обработка спутника {idx}/{stats['total_satellites']}: {sat_name}") - - if update_progress: - update_progress(idx, stats['total_satellites'], f"Обработка {sat_name}...") - - url = data['url'] - sources = data['sources'] - stats['total_sources'] += len(sources) - - logger.info(f"{log_prefix} Найдено {len(sources)} источников для {sat_name}") - - # Находим спутник в базе - try: - sat_obj = Satellite.objects.get(name__icontains=sat_name) - logger.debug(f"{log_prefix} Спутник {sat_name} найден в базе (ID: {sat_obj.id})") - except Satellite.DoesNotExist: - error_msg = f"Спутник '{sat_name}' не найден в базе данных" - logger.warning(f"{log_prefix} {error_msg}") - stats['errors'].append(error_msg) - continue - except Satellite.MultipleObjectsReturned: - error_msg = f"Найдено несколько спутников с именем '{sat_name}'" - logger.warning(f"{log_prefix} {error_msg}") - stats['errors'].append(error_msg) - continue - - for source_idx, source in enumerate(sources, 1): - try: - # Парсим частоту - try: - freq = float(source['freq']) - except (ValueError, TypeError): - freq = -1.0 - error_msg = f"Некорректная частота для {sat_name}: {source.get('freq')}" - logger.debug(f"{log_prefix} {error_msg}") - stats['errors'].append(error_msg) - - last_update = source['last_update'] - fec = source['metadata'].get('fec') - modulation_name = source['metadata'].get('modulation') - standard_name = source['metadata'].get('standard') - symbol_velocity = source['metadata'].get('symbol_rate') - polarization_name = source['pol'] - channel_info = source['provider_name'] - - # Создаем или получаем связанные объекты - pol_obj, _ = Polarization.objects.get_or_create( - name=polarization_name if polarization_name else "-" - ) - - mod_obj, _ = Modulation.objects.get_or_create( - name=modulation_name if modulation_name else "-" - ) - - standard_obj, _ = Standard.objects.get_or_create( - name=standard_name if standard_name else "-" - ) - - # Создаем или обновляем запись Lyngsat - lyng_obj, created = LyngSat.objects.update_or_create( - id_satellite=sat_obj, - frequency=freq, - polarization=pol_obj, - defaults={ - "modulation": mod_obj, - "standard": standard_obj, - "sym_velocity": symbol_velocity if symbol_velocity else 0, - "channel_info": channel_info[:20] if channel_info else "", - "last_update": last_update, - "fec": fec[:30] if fec else "", - "url": url - } - ) - - if created: - stats['created'] += 1 - logger.debug(f"{log_prefix} Создана запись для {sat_name} {freq} МГц") - else: - stats['updated'] += 1 - logger.debug(f"{log_prefix} Обновлена запись для {sat_name} {freq} МГц") - - # Логируем прогресс каждые 10 источников - if source_idx % 10 == 0: - logger.info(f"{log_prefix} Обработано {source_idx}/{len(sources)} источников для {sat_name}") - - except Exception as e: - error_msg = f"Ошибка при обработке источника {sat_name}: {str(e)}" - logger.error(f"{log_prefix} {error_msg}", exc_info=True) - stats['errors'].append(error_msg) - continue - - logger.info(f"{log_prefix} Завершена обработка {sat_name}: создано {stats['created']}, обновлено {stats['updated']}") - - except Exception as e: - error_msg = f"Критическая ошибка: {str(e)}" - logger.error(f"{log_prefix} {error_msg}", exc_info=True) - stats['errors'].append(error_msg) - - logger.info(f"{log_prefix} Обработка завершена. Итого: создано {stats['created']}, обновлено {stats['updated']}, ошибок {len(stats['errors'])}") - - if update_progress: - update_progress(stats['total_satellites'], stats['total_satellites'], "Завершено") - - return stats +import logging +from .parser import LyngSatParser +from .models import LyngSat +from mainapp.models import Polarization, Standard, Modulation, Satellite + +logger = logging.getLogger(__name__) + + +def fill_lyngsat_data( + target_sats: list[str], + regions: list[str] = None, + task_id: str = None, + update_progress=None +): + """ + Заполняет данные Lyngsat для указанных спутников и регионов. + + Args: + target_sats: Список названий спутников для обработки + regions: Список регионов для парсинга (по умолчанию все) + task_id: ID задачи Celery для логирования + update_progress: Функция для обновления прогресса (current, total, status) + + Returns: + dict: Статистика обработки с ключами: + - total_satellites: общее количество спутников + - total_sources: общее количество источников + - created: количество созданных записей + - updated: количество обновленных записей + - errors: список ошибок + """ + log_prefix = f"[Task {task_id}]" if task_id else "[Lyngsat]" + stats = { + 'total_satellites': 0, + 'total_sources': 0, + 'created': 0, + 'updated': 0, + 'errors': [] + } + + if regions is None: + regions = ["europe", "asia", "america", "atlantic"] + + logger.info(f"{log_prefix} Начало парсинга данных") + logger.info(f"{log_prefix} Спутники: {', '.join(target_sats)}") + logger.info(f"{log_prefix} Регионы: {', '.join(regions)}") + + if update_progress: + update_progress(0, len(target_sats), "Инициализация парсера...") + + try: + parser = LyngSatParser( + flaresolver_url="http://localhost:8191/v1", + target_sats=target_sats, + regions=regions + ) + + logger.info(f"{log_prefix} Получение данных со спутников...") + if update_progress: + update_progress(0, len(target_sats), "Получение данных со спутников...") + + lyngsat_data = parser.get_satellites_data() + stats['total_satellites'] = len(lyngsat_data) + + logger.info(f"{log_prefix} Получено данных по {stats['total_satellites']} спутникам") + + for idx, (sat_name, data) in enumerate(lyngsat_data.items(), 1): + logger.info(f"{log_prefix} Обработка спутника {idx}/{stats['total_satellites']}: {sat_name}") + + if update_progress: + update_progress(idx, stats['total_satellites'], f"Обработка {sat_name}...") + + url = data['url'] + sources = data['sources'] + stats['total_sources'] += len(sources) + + logger.info(f"{log_prefix} Найдено {len(sources)} источников для {sat_name}") + + # Находим спутник в базе + try: + sat_obj = Satellite.objects.get(name__icontains=sat_name) + logger.debug(f"{log_prefix} Спутник {sat_name} найден в базе (ID: {sat_obj.id})") + except Satellite.DoesNotExist: + error_msg = f"Спутник '{sat_name}' не найден в базе данных" + logger.warning(f"{log_prefix} {error_msg}") + stats['errors'].append(error_msg) + continue + except Satellite.MultipleObjectsReturned: + error_msg = f"Найдено несколько спутников с именем '{sat_name}'" + logger.warning(f"{log_prefix} {error_msg}") + stats['errors'].append(error_msg) + continue + + for source_idx, source in enumerate(sources, 1): + try: + # Парсим частоту + try: + freq = float(source['freq']) + except (ValueError, TypeError): + freq = -1.0 + error_msg = f"Некорректная частота для {sat_name}: {source.get('freq')}" + logger.debug(f"{log_prefix} {error_msg}") + stats['errors'].append(error_msg) + + last_update = source['last_update'] + fec = source['metadata'].get('fec') + modulation_name = source['metadata'].get('modulation') + standard_name = source['metadata'].get('standard') + symbol_velocity = source['metadata'].get('symbol_rate') + polarization_name = source['pol'] + channel_info = source['provider_name'] + + # Создаем или получаем связанные объекты + pol_obj, _ = Polarization.objects.get_or_create( + name=polarization_name if polarization_name else "-" + ) + + mod_obj, _ = Modulation.objects.get_or_create( + name=modulation_name if modulation_name else "-" + ) + + standard_obj, _ = Standard.objects.get_or_create( + name=standard_name if standard_name else "-" + ) + + # Создаем или обновляем запись Lyngsat + lyng_obj, created = LyngSat.objects.update_or_create( + id_satellite=sat_obj, + frequency=freq, + polarization=pol_obj, + defaults={ + "modulation": mod_obj, + "standard": standard_obj, + "sym_velocity": symbol_velocity if symbol_velocity else 0, + "channel_info": channel_info[:20] if channel_info else "", + "last_update": last_update, + "fec": fec[:30] if fec else "", + "url": url + } + ) + + if created: + stats['created'] += 1 + logger.debug(f"{log_prefix} Создана запись для {sat_name} {freq} МГц") + else: + stats['updated'] += 1 + logger.debug(f"{log_prefix} Обновлена запись для {sat_name} {freq} МГц") + + # Логируем прогресс каждые 10 источников + if source_idx % 10 == 0: + logger.info(f"{log_prefix} Обработано {source_idx}/{len(sources)} источников для {sat_name}") + + except Exception as e: + error_msg = f"Ошибка при обработке источника {sat_name}: {str(e)}" + logger.error(f"{log_prefix} {error_msg}", exc_info=True) + stats['errors'].append(error_msg) + continue + + logger.info(f"{log_prefix} Завершена обработка {sat_name}: создано {stats['created']}, обновлено {stats['updated']}") + + except Exception as e: + error_msg = f"Критическая ошибка: {str(e)}" + logger.error(f"{log_prefix} {error_msg}", exc_info=True) + stats['errors'].append(error_msg) + + logger.info(f"{log_prefix} Обработка завершена. Итого: создано {stats['created']}, обновлено {stats['updated']}, ошибок {len(stats['errors'])}") + + if update_progress: + update_progress(stats['total_satellites'], stats['total_satellites'], "Завершено") + + return stats + + +def link_lyngsat_to_sources(): + pass \ No newline at end of file diff --git a/dbapp/lyngsatapp/views.py b/dbapp/lyngsatapp/views.py index 91ea44a..c60c790 100644 --- a/dbapp/lyngsatapp/views.py +++ b/dbapp/lyngsatapp/views.py @@ -1,3 +1,3 @@ -from django.shortcuts import render - -# Create your views here. +from django.shortcuts import render + +# Create your views here. diff --git a/dbapp/mainapp/admin.py b/dbapp/mainapp/admin.py index 0917b3d..752e43c 100644 --- a/dbapp/mainapp/admin.py +++ b/dbapp/mainapp/admin.py @@ -1,842 +1,842 @@ -# Django imports -from django import forms -from django.contrib import admin -from django.contrib.auth.admin import UserAdmin as BaseUserAdmin -from django.contrib.auth.models import Group, User -from django.shortcuts import redirect -from django.urls import reverse -from django.utils import timezone - -# Third-party imports -from import_export.admin import ImportExportActionModelAdmin -from leaflet.admin import LeafletGeoAdmin -from more_admin_filters import ( - MultiSelectDropdownFilter, - MultiSelectRelatedDropdownFilter, -) -from rangefilter.filters import ( - DateRangeQuickSelectListFilterBuilder, - NumericRangeFilterBuilder, -) - -from .models import ( - Polarization, - Modulation, - Standard, - SigmaParMark, - SigmaParameter, - SourceType, - Parameter, - Satellite, - Mirror, - Geo, - ObjItem, - CustomUser -) -from .filters import ( - GeoKupDistanceFilter, - GeoValidDistanceFilter, - UniqueToggleFilter, - HasSigmaParameterFilter -) - - -admin.site.site_title = "Геолокация" -admin.site.site_header = "Geolocation" -admin.site.index_title = "Geo" - -# Unregister default User and Group since we're customizing them -admin.site.unregister(User) -admin.site.unregister(Group) - - -# ============================================================================ -# Base Admin Classes -# ============================================================================ - -class BaseAdmin(admin.ModelAdmin): - """ - Базовый класс для всех admin моделей. - - Предоставляет общую функциональность: - - Кнопки сохранения сверху и снизу - - Настройка количества элементов на странице - - Автоматическое заполнение полей created_by и updated_by - """ - save_on_top = True - list_per_page = 50 - - def save_model(self, request, obj, form, change): - """ - Автоматически заполняет поля created_by и updated_by при сохранении. - - Args: - request: HTTP запрос - obj: Сохраняемый объект модели - form: Форма с данными - change: True если это редактирование, False если создание - """ - if not change: - # При создании нового объекта устанавливаем created_by - if hasattr(obj, 'created_by') and not obj.created_by_id: - obj.created_by = getattr(request.user, 'customuser', None) - - # При любом сохранении обновляем updated_by - if hasattr(obj, 'updated_by'): - obj.updated_by = getattr(request.user, 'customuser', None) - - super().save_model(request, obj, form, change) - - -class CustomUserInline(admin.StackedInline): - model = CustomUser - can_delete = False - verbose_name_plural = 'Дополнительная информация пользователя' - - -class LocationForm(forms.ModelForm): - latitude_geo = forms.FloatField(required=False, label="Широта") - longitude_geo = forms.FloatField(required=False, label="Долгота") - latitude_kupsat = forms.FloatField(required=False, label="Широта") - longitude_kupsat = forms.FloatField(required=False, label="Долгота") - latitude_valid = forms.FloatField(required=False, label="Широта") - longitude_valid = forms.FloatField(required=False, label="Долгота") - - class Meta: - model = Geo - fields = '__all__' - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if self.instance and self.instance.coords: - self.fields['latitude_geo'].initial = self.instance.coords[1] - self.fields['longitude_geo'].initial = self.instance.coords[0] - if self.instance and self.instance.coords_kupsat: - self.fields['latitude_kupsat'].initial = self.instance.coords_kupsat[1] - self.fields['longitude_kupsat'].initial = self.instance.coords_kupsat[0] - if self.instance and self.instance.coords_valid: - self.fields['latitude_valid'].initial = self.instance.coords_valid[1] - self.fields['longitude_valid'].initial = self.instance.coords_valid[0] - - def save(self, commit=True): - instance = super().save(commit=False) - from django.contrib.gis.geos import Point - lat = self.cleaned_data.get('latitude_geo') - lon = self.cleaned_data.get('longitude_geo') - if lat is not None and lon is not None: - instance.coords = Point(lon, lat, srid=4326) - - lat = self.cleaned_data.get('latitude_kupsat') - lon = self.cleaned_data.get('longitude_kupsat') - if lat is not None and lon is not None: - instance.coords_kupsat = Point(lon, lat, srid=4326) - - lat = self.cleaned_data.get('latitude_valid') - lon = self.cleaned_data.get('longitude_valid') - if lat is not None and lon is not None: - instance.coords_valid = Point(lon, lat, srid=4326) - - if commit: - instance.save() - return instance - - -class GeoInline(admin.StackedInline): - model = Geo - extra = 0 - verbose_name = "Гео" - verbose_name_plural = "Гео" - form = LocationForm - readonly_fields = ("distance_coords_kup", "distance_coords_valid", "distance_kup_valid") - prefetch_related = ("mirrors",) - autocomplete_fields = ('mirrors',) - fieldsets = ( - ("Основная информация", { - "fields": ("mirrors", "location", "distance_coords_kup", - "distance_coords_valid", "distance_kup_valid", "timestamp", "comment",) - }), - ("Координаты: геолокация", { - "fields": ("longitude_geo", "latitude_geo", "coords"), - }), - ("Координаты: Кубсат", { - "fields": ("longitude_kupsat", "latitude_kupsat", "coords_kupsat"), - }), - ("Координаты: Оперативный отдел", { - "fields": ("longitude_valid", "latitude_valid", "coords_valid"), - }), - ) - - -class UserAdmin(BaseUserAdmin): - inlines = [CustomUserInline] - -admin.site.register(User, UserAdmin) - - -# ============================================================================ -# Custom Admin Actions -# ============================================================================ - -@admin.action(description="Показать выбранные на карте") -def show_on_map(modeladmin, request, queryset): - """ - Action для отображения выбранных Geo объектов на карте. - - Оптимизирован для работы с большим количеством объектов: - использует values_list для получения только ID. - """ - selected_ids = queryset.values_list('id', flat=True) - ids_str = ','.join(str(pk) for pk in selected_ids) - return redirect(reverse('mainapp:admin_show_map') + f'?ids={ids_str}') - - -@admin.action(description="Показать выбранные объекты на карте") -def show_selected_on_map(modeladmin, request, queryset): - """ - Action для отображения выбранных ObjItem объектов на карте. - - Оптимизирован для работы с большим количеством объектов: - использует values_list для получения только ID. - """ - selected_ids = queryset.values_list('id', flat=True) - ids_str = ','.join(str(pk) for pk in selected_ids) - return redirect(reverse('mainapp:show_selected_objects_map') + f'?ids={ids_str}') - - -@admin.action(description="Экспортировать выбранные объекты в CSV") -def export_objects_to_csv(modeladmin, request, queryset): - """ - Action для экспорта выбранных ObjItem объектов в CSV формат. - - Оптимизирован с использованием select_related и prefetch_related - для минимизации количества запросов к БД. - """ - import csv - from django.http import HttpResponse - - # Оптимизируем queryset - queryset = queryset.select_related( - 'geo_obj', - 'created_by__user', - 'updated_by__user', - 'parameter_obj', - 'parameter_obj__id_satellite', - 'parameter_obj__polarization', - 'parameter_obj__modulation' - ) - - response = HttpResponse(content_type='text/csv; charset=utf-8') - response['Content-Disposition'] = 'attachment; filename="objitems_export.csv"' - response.write('\ufeff') # UTF-8 BOM для корректного отображения в Excel - - writer = csv.writer(response) - writer.writerow([ - 'Название', - 'Спутник', - 'Частота (МГц)', - 'Полоса (МГц)', - 'Поляризация', - 'Модуляция', - 'ОСШ', - 'Координаты геолокации', - 'Координаты Кубсата', - 'Координаты оперативного отдела', - 'Расстояние Гео-Куб (км)', - 'Расстояние Гео-Опер (км)', - 'Дата создания', - 'Дата обновления' - ]) - - for obj in queryset: - param = getattr(obj, 'parameter_obj', None) - geo = obj.geo_obj - - # Форматирование координат - def format_coords(coords): - if not coords: - return "-" - lon, lat = coords.coords[0], coords.coords[1] - lon_str = f"{lon}E" if lon > 0 else f"{abs(lon)}W" - lat_str = f"{lat}N" if lat > 0 else f"{abs(lat)}S" - return f"{lat_str} {lon_str}" - - writer.writerow([ - obj.name, - param.id_satellite.name if param and param.id_satellite else "-", - param.frequency if param else "-", - param.freq_range if param else "-", - param.polarization.name if param and param.polarization else "-", - param.modulation.name if param and param.modulation else "-", - param.snr if param else "-", - format_coords(geo) if geo and geo.coords else "-", - format_coords(geo) if geo and geo.coords_kupsat else "-", - format_coords(geo) if geo and geo.coords_valid else "-", - round(geo.distance_coords_kup, 3) if geo and geo.distance_coords_kup else "-", - round(geo.distance_coords_valid, 3) if geo and geo.distance_coords_valid else "-", - obj.created_at.strftime("%d.%m.%Y %H:%M:%S") if obj.created_at else "-", - obj.updated_at.strftime("%d.%m.%Y %H:%M:%S") if obj.updated_at else "-" - ]) - - return response - - -# ============================================================================ -# Inline Admin Classes -# ============================================================================ - -class ParameterInline(admin.StackedInline): - """Inline для редактирования параметра объекта.""" - model = Parameter - extra = 0 - max_num = 1 - can_delete = True - verbose_name = "ВЧ загрузка" - verbose_name_plural = "ВЧ загрузка" - fields = ( - 'id_satellite', - 'frequency', - 'freq_range', - 'polarization', - 'modulation', - 'bod_velocity', - 'snr', - 'standard' - ) - autocomplete_fields = ('id_satellite', 'polarization', 'modulation', 'standard') - - -# ============================================================================ -# Admin Classes -# ============================================================================ - -@admin.register(SigmaParMark) -class SigmaParMarkAdmin(BaseAdmin): - """Админ-панель для модели SigmaParMark.""" - list_display = ("mark", "timestamp") - search_fields = ("mark",) - ordering = ("-timestamp",) - list_filter = ( - ("timestamp", DateRangeQuickSelectListFilterBuilder()), - ) - - -@admin.register(Polarization) -class PolarizationAdmin(BaseAdmin): - """Админ-панель для модели Polarization.""" - list_display = ("name",) - search_fields = ("name",) - ordering = ("name",) - - -@admin.register(Modulation) -class ModulationAdmin(BaseAdmin): - """Админ-панель для модели Modulation.""" - list_display = ("name",) - search_fields = ("name",) - ordering = ("name",) - - -@admin.register(SourceType) -class SourceTypeAdmin(BaseAdmin): - """Админ-панель для модели SourceType.""" - list_display = ("name",) - search_fields = ("name",) - ordering = ("name",) - - -@admin.register(Standard) -class StandardAdmin(BaseAdmin): - """Админ-панель для модели Standard.""" - list_display = ("name",) - search_fields = ("name",) - ordering = ("name",) - - -class SigmaParameterInline(admin.StackedInline): - model = SigmaParameter - extra = 0 - autocomplete_fields = ['mark'] - readonly_fields = ( - "datetime_begin", - "datetime_end", - ) - def has_add_permission(self, request, obj=None): - return False - - -@admin.register(Parameter) -class ParameterAdmin(ImportExportActionModelAdmin, BaseAdmin): - """ - Админ-панель для модели Parameter. - - Оптимизирована для работы с большим количеством параметров: - - Использует select_related для оптимизации запросов - - Предоставляет фильтры по основным характеристикам - - Поддерживает импорт/экспорт данных - """ - list_display = ( - "id_satellite", - "frequency", - "freq_range", - "polarization", - "modulation", - "bod_velocity", - "snr", - "standard", - "related_objitem", - "sigma_parameter" - ) - list_display_links = ("frequency", "id_satellite") - list_select_related = ("polarization", "modulation", "standard", "id_satellite", "objitem") - - list_filter = ( - HasSigmaParameterFilter, - ("objitem", MultiSelectRelatedDropdownFilter), - ("id_satellite", MultiSelectRelatedDropdownFilter), - ("polarization__name", MultiSelectDropdownFilter), - ("modulation", MultiSelectRelatedDropdownFilter), - ("standard", MultiSelectRelatedDropdownFilter), - ("frequency", NumericRangeFilterBuilder()), - ("freq_range", NumericRangeFilterBuilder()), - ("snr", NumericRangeFilterBuilder()), - ) - - search_fields = ( - "id_satellite__name", - "frequency", - "freq_range", - "bod_velocity", - "snr", - "modulation__name", - "polarization__name", - "standard__name", - "objitem__name", - ) - - ordering = ("-frequency",) - autocomplete_fields = ("objitem",) - inlines = [SigmaParameterInline] - - def related_objitem(self, obj): - """Отображает связанный ObjItem.""" - if hasattr(obj, 'objitem') and obj.objitem: - return obj.objitem.name - return "-" - related_objitem.short_description = "Объект" - related_objitem.admin_order_field = "objitem__name" - - def sigma_parameter(self, obj): - """Отображает связанный параметр Sigma.""" - sigma_obj = obj.sigma_parameter.all() - if sigma_obj: - return f"{sigma_obj[0].frequency}: {sigma_obj[0].freq_range}" - return "-" - sigma_parameter.short_description = "ВЧ sigma" - - -@admin.register(SigmaParameter) -class SigmaParameterAdmin(ImportExportActionModelAdmin, BaseAdmin): - """ - Админ-панель для модели SigmaParameter. - - Оптимизирована для работы с параметрами Sigma: - - Использует select_related и prefetch_related для оптимизации - - Предоставляет фильтры по основным характеристикам - - Поддерживает импорт/экспорт данных - """ - list_display = ( - "id_satellite", - "frequency", - "transfer_frequency", - "freq_range", - "polarization", - "modulation", - "bod_velocity", - "snr", - "parameter", - "datetime_begin", - "datetime_end", - ) - list_display_links = ("id_satellite",) - list_select_related = ("modulation", "standard", "id_satellite", "parameter", "polarization") - - readonly_fields = ( - "datetime_begin", - "datetime_end", - "transfer_frequency" - ) - - list_filter = ( - ("id_satellite__name", MultiSelectDropdownFilter), - ("modulation__name", MultiSelectDropdownFilter), - ("standard__name", MultiSelectDropdownFilter), - ("frequency", NumericRangeFilterBuilder()), - ("freq_range", NumericRangeFilterBuilder()), - ("snr", NumericRangeFilterBuilder()), - ("datetime_begin", DateRangeQuickSelectListFilterBuilder()), - ("datetime_end", DateRangeQuickSelectListFilterBuilder()), - ) - - search_fields = ( - "id_satellite__name", - "frequency", - "freq_range", - "bod_velocity", - "snr", - "modulation__name", - "standard__name", - ) - - autocomplete_fields = ("mark",) - ordering = ("-frequency",) - - def get_queryset(self, request): - """Оптимизированный queryset с prefetch_related для mark.""" - qs = super().get_queryset(request) - return qs.prefetch_related("mark") - - -@admin.register(Satellite) -class SatelliteAdmin(BaseAdmin): - """Админ-панель для модели Satellite.""" - list_display = ("name",) - search_fields = ("name",) - ordering = ("name",) - - -@admin.register(Mirror) -class MirrorAdmin(ImportExportActionModelAdmin, BaseAdmin): - """Админ-панель для модели Mirror с поддержкой импорта/экспорта.""" - list_display = ("name",) - search_fields = ("name",) - ordering = ("name",) - - -@admin.register(Geo) -class GeoAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin, BaseAdmin): - """ - Админ-панель для модели Geo с поддержкой карты Leaflet. - - Оптимизирована для работы с геоданными: - - Использует prefetch_related для оптимизации запросов к mirrors - - Предоставляет фильтры по зеркалам, локации и дате - - Поддерживает импорт/экспорт данных - - Интегрирована с Leaflet для отображения на карте - """ - form = LocationForm - - readonly_fields = ("distance_coords_kup", "distance_coords_valid", "distance_kup_valid") - - fieldsets = ( - ("Основная информация", { - "fields": ("mirrors", "location", "distance_coords_kup", - "distance_coords_valid", "distance_kup_valid", "timestamp", "comment") - }), - ("Координаты: геолокация", { - "fields": ("longitude_geo", "latitude_geo", "coords") - }), - ("Координаты: Кубсат", { - "fields": ("longitude_kupsat", "latitude_kupsat", "coords_kupsat") - }), - ("Координаты: Оперативный отдел", { - "fields": ("longitude_valid", "latitude_valid", "coords_valid") - }), - ) - - list_display = ( - "formatted_timestamp", - "location", - "mirrors_names", - "geo_coords", - "kupsat_coords", - "valid_coords", - "is_average", - ) - list_display_links = ("formatted_timestamp",) - - list_filter = ( - ("mirrors", MultiSelectRelatedDropdownFilter), - "is_average", - ("location", MultiSelectDropdownFilter), - ("timestamp", DateRangeQuickSelectListFilterBuilder()), - ) - - search_fields = ( - "mirrors__name", - "location", - ) - - autocomplete_fields = ("mirrors",) - ordering = ("-timestamp",) - actions = [show_on_map] - - settings_overrides = { - 'DEFAULT_CENTER': (55.7558, 37.6173), - 'DEFAULT_ZOOM': 12, - } - - def get_queryset(self, request): - """Оптимизированный queryset с prefetch_related для mirrors.""" - qs = super().get_queryset(request) - return qs.prefetch_related("mirrors") - - def mirrors_names(self, obj): - """Отображает список зеркал через запятую.""" - return ", ".join(m.name for m in obj.mirrors.all()) - mirrors_names.short_description = "Зеркала" - - def formatted_timestamp(self, obj): - """Форматирует timestamp в локальное время.""" - if not obj.timestamp: - return "" - local_time = timezone.localtime(obj.timestamp) - return local_time.strftime("%d.%m.%Y %H:%M:%S") - formatted_timestamp.short_description = "Дата и время" - formatted_timestamp.admin_order_field = "timestamp" - - def geo_coords(self, obj): - """Отображает координаты геолокации в формате широта/долгота.""" - if not obj.coords: - return "-" - longitude = obj.coords.coords[0] - latitude = obj.coords.coords[1] - lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" - lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" - return f"{lat} {lon}" - geo_coords.short_description = "Координаты геолокации" - - def kupsat_coords(self, obj): - """Отображает координаты Кубсата в формате широта/долгота.""" - if obj.coords_kupsat is None: - return "-" - longitude = obj.coords_kupsat.coords[0] - latitude = obj.coords_kupsat.coords[1] - lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" - lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" - return f"{lat} {lon}" - kupsat_coords.short_description = "Координаты Кубсата" - - def valid_coords(self, obj): - """Отображает координаты оперативного отдела в формате широта/долгота.""" - if obj.coords_valid is None: - return "-" - longitude = obj.coords_valid.coords[0] - latitude = obj.coords_valid.coords[1] - lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" - lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" - return f"{lat} {lon}" - valid_coords.short_description = "Координаты оперативного отдела" - - - - -@admin.register(ObjItem) -class ObjItemAdmin(BaseAdmin): - """ - Админ-панель для модели ObjItem. - - Оптимизирована для работы с большим количеством объектов: - - Использует select_related и prefetch_related для оптимизации запросов - - Предоставляет фильтры по основным параметрам - - Поддерживает поиск по имени, координатам и частоте - - Включает кастомные actions для отображения на карте - """ - list_display = ( - "name", - "sat_name", - "freq", - "freq_range", - "pol", - "bod_velocity", - "modulation", - "snr", - "geo_coords", - "kupsat_coords", - "valid_coords", - "distance_geo_kup", - "distance_geo_valid", - "distance_kup_valid", - "created_at", - "updated_at", - ) - list_display_links = ("name",) - list_select_related = ( - "geo_obj", - "created_by__user", - "updated_by__user", - "parameter_obj", - "parameter_obj__id_satellite", - "parameter_obj__polarization", - "parameter_obj__modulation", - "parameter_obj__standard" - ) - - list_filter = ( - UniqueToggleFilter, - ("parameter_obj__id_satellite", MultiSelectRelatedDropdownFilter), - ("parameter_obj__frequency", NumericRangeFilterBuilder()), - ("parameter_obj__freq_range", NumericRangeFilterBuilder()), - ("parameter_obj__snr", NumericRangeFilterBuilder()), - ("parameter_obj__modulation", MultiSelectRelatedDropdownFilter), - ("parameter_obj__polarization", MultiSelectRelatedDropdownFilter), - GeoKupDistanceFilter, - GeoValidDistanceFilter, - ("created_at", DateRangeQuickSelectListFilterBuilder()), - ("updated_at", DateRangeQuickSelectListFilterBuilder()), - ) - - search_fields = ( - "name", - "geo_obj__location", - "parameter_obj__frequency", - "parameter_obj__id_satellite__name", - ) - - ordering = ("-updated_at",) - inlines = [GeoInline, ParameterInline] - actions = [show_selected_on_map, export_objects_to_csv] - readonly_fields = ("created_at", "created_by", "updated_at", "updated_by") - - fieldsets = ( - ("Основная информация", { - "fields": ("name",) - }), - ("Метаданные", { - "fields": ("created_at", "created_by", "updated_at", "updated_by"), - "classes": ("collapse",) - }), - ) - - def get_queryset(self, request): - """ - Оптимизированный queryset с использованием select_related. - - Загружает связанные объекты одним запросом для улучшения производительности. - """ - qs = super().get_queryset(request) - return qs.select_related( - "geo_obj", - "created_by__user", - "updated_by__user", - "parameter_obj", - "parameter_obj__id_satellite", - "parameter_obj__polarization", - "parameter_obj__modulation", - "parameter_obj__standard" - ) - - def sat_name(self, obj): - """Отображает название спутника из связанного параметра.""" - if hasattr(obj, 'parameter_obj') and obj.parameter_obj: - if obj.parameter_obj.id_satellite: - return obj.parameter_obj.id_satellite.name - return "-" - sat_name.short_description = "Спутник" - sat_name.admin_order_field = "parameter_obj__id_satellite__name" - - def freq(self, obj): - """Отображает частоту из связанного параметра.""" - if hasattr(obj, 'parameter_obj') and obj.parameter_obj: - return obj.parameter_obj.frequency - return "-" - freq.short_description = "Частота, МГц" - freq.admin_order_field = "parameter_obj__frequency" - - def distance_geo_kup(self, obj): - """Отображает расстояние между геолокацией и Кубсатом.""" - geo = obj.geo_obj - if not geo or geo.distance_coords_kup is None: - return "-" - return round(geo.distance_coords_kup, 3) - distance_geo_kup.short_description = "Гео-куб, км" - - def distance_geo_valid(self, obj): - """Отображает расстояние между геолокацией и оперативным отделом.""" - geo = obj.geo_obj - if not geo or geo.distance_coords_valid is None: - return "-" - return round(geo.distance_coords_valid, 3) - distance_geo_valid.short_description = "Гео-опер, км" - - def distance_kup_valid(self, obj): - """Отображает расстояние между Кубсатом и оперативным отделом.""" - geo = obj.geo_obj - if not geo or geo.distance_kup_valid is None: - return "-" - return round(geo.distance_kup_valid, 3) - distance_kup_valid.short_description = "Куб-опер, км" - - def pol(self, obj): - """Отображает поляризацию из связанного параметра.""" - if hasattr(obj, 'parameter_obj') and obj.parameter_obj: - if obj.parameter_obj.polarization: - return obj.parameter_obj.polarization.name - return "-" - pol.short_description = "Поляризация" - - def freq_range(self, obj): - """Отображает полосу частот из связанного параметра.""" - if hasattr(obj, 'parameter_obj') and obj.parameter_obj: - return obj.parameter_obj.freq_range - return "-" - freq_range.short_description = "Полоса, МГц" - freq_range.admin_order_field = "parameter_obj__freq_range" - - def bod_velocity(self, obj): - """Отображает символьную скорость из связанного параметра.""" - if hasattr(obj, 'parameter_obj') and obj.parameter_obj: - return obj.parameter_obj.bod_velocity - return "-" - bod_velocity.short_description = "Сим. v, БОД" - - def modulation(self, obj): - """Отображает модуляцию из связанного параметра.""" - if hasattr(obj, 'parameter_obj') and obj.parameter_obj: - if obj.parameter_obj.modulation: - return obj.parameter_obj.modulation.name - return "-" - modulation.short_description = "Модуляция" - - def snr(self, obj): - """Отображает отношение сигнал/шум из связанного параметра.""" - if hasattr(obj, 'parameter_obj') and obj.parameter_obj: - return obj.parameter_obj.snr - return "-" - snr.short_description = "ОСШ" - - def geo_coords(self, obj): - """Отображает координаты геолокации в формате широта/долгота.""" - geo = obj.geo_obj - if not geo or not geo.coords: - return "-" - longitude = geo.coords.coords[0] - latitude = geo.coords.coords[1] - lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" - lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" - return f"{lat} {lon}" - geo_coords.short_description = "Координаты геолокации" - geo_coords.admin_order_field = "geo_obj__coords" - - def kupsat_coords(self, obj): - """Отображает координаты Кубсата в формате широта/долгота.""" - geo = obj.geo_obj - if not geo or not geo.coords_kupsat: - return "-" - longitude = geo.coords_kupsat.coords[0] - latitude = geo.coords_kupsat.coords[1] - lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" - lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" - return f"{lat} {lon}" - kupsat_coords.short_description = "Координаты Кубсата" - - def valid_coords(self, obj): - """Отображает координаты оперативного отдела в формате широта/долгота.""" - geo = obj.geo_obj - if not geo or not geo.coords_valid: - return "-" - longitude = geo.coords_valid.coords[0] - latitude = geo.coords_valid.coords[1] - lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" - lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" - return f"{lat} {lon}" - valid_coords.short_description = "Координаты оперативного отдела" +# Django imports +from django import forms +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from django.contrib.auth.models import Group, User +from django.shortcuts import redirect +from django.urls import reverse +from django.utils import timezone + +# Third-party imports +from import_export.admin import ImportExportActionModelAdmin +from leaflet.admin import LeafletGeoAdmin +from more_admin_filters import ( + MultiSelectDropdownFilter, + MultiSelectRelatedDropdownFilter, +) +from rangefilter.filters import ( + DateRangeQuickSelectListFilterBuilder, + NumericRangeFilterBuilder, +) + +from .models import ( + Polarization, + Modulation, + Standard, + SigmaParMark, + SigmaParameter, + SourceType, + Parameter, + Satellite, + Mirror, + Geo, + ObjItem, + CustomUser +) +from .filters import ( + GeoKupDistanceFilter, + GeoValidDistanceFilter, + UniqueToggleFilter, + HasSigmaParameterFilter +) + + +admin.site.site_title = "Геолокация" +admin.site.site_header = "Geolocation" +admin.site.index_title = "Geo" + +# Unregister default User and Group since we're customizing them +admin.site.unregister(User) +admin.site.unregister(Group) + + +# ============================================================================ +# Base Admin Classes +# ============================================================================ + +class BaseAdmin(admin.ModelAdmin): + """ + Базовый класс для всех admin моделей. + + Предоставляет общую функциональность: + - Кнопки сохранения сверху и снизу + - Настройка количества элементов на странице + - Автоматическое заполнение полей created_by и updated_by + """ + save_on_top = True + list_per_page = 50 + + def save_model(self, request, obj, form, change): + """ + Автоматически заполняет поля created_by и updated_by при сохранении. + + Args: + request: HTTP запрос + obj: Сохраняемый объект модели + form: Форма с данными + change: True если это редактирование, False если создание + """ + if not change: + # При создании нового объекта устанавливаем created_by + if hasattr(obj, 'created_by') and not obj.created_by_id: + obj.created_by = getattr(request.user, 'customuser', None) + + # При любом сохранении обновляем updated_by + if hasattr(obj, 'updated_by'): + obj.updated_by = getattr(request.user, 'customuser', None) + + super().save_model(request, obj, form, change) + + +class CustomUserInline(admin.StackedInline): + model = CustomUser + can_delete = False + verbose_name_plural = 'Дополнительная информация пользователя' + + +class LocationForm(forms.ModelForm): + latitude_geo = forms.FloatField(required=False, label="Широта") + longitude_geo = forms.FloatField(required=False, label="Долгота") + latitude_kupsat = forms.FloatField(required=False, label="Широта") + longitude_kupsat = forms.FloatField(required=False, label="Долгота") + latitude_valid = forms.FloatField(required=False, label="Широта") + longitude_valid = forms.FloatField(required=False, label="Долгота") + + class Meta: + model = Geo + fields = '__all__' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance and self.instance.coords: + self.fields['latitude_geo'].initial = self.instance.coords[1] + self.fields['longitude_geo'].initial = self.instance.coords[0] + if self.instance and self.instance.coords_kupsat: + self.fields['latitude_kupsat'].initial = self.instance.coords_kupsat[1] + self.fields['longitude_kupsat'].initial = self.instance.coords_kupsat[0] + if self.instance and self.instance.coords_valid: + self.fields['latitude_valid'].initial = self.instance.coords_valid[1] + self.fields['longitude_valid'].initial = self.instance.coords_valid[0] + + def save(self, commit=True): + instance = super().save(commit=False) + from django.contrib.gis.geos import Point + lat = self.cleaned_data.get('latitude_geo') + lon = self.cleaned_data.get('longitude_geo') + if lat is not None and lon is not None: + instance.coords = Point(lon, lat, srid=4326) + + lat = self.cleaned_data.get('latitude_kupsat') + lon = self.cleaned_data.get('longitude_kupsat') + if lat is not None and lon is not None: + instance.coords_kupsat = Point(lon, lat, srid=4326) + + lat = self.cleaned_data.get('latitude_valid') + lon = self.cleaned_data.get('longitude_valid') + if lat is not None and lon is not None: + instance.coords_valid = Point(lon, lat, srid=4326) + + if commit: + instance.save() + return instance + + +class GeoInline(admin.StackedInline): + model = Geo + extra = 0 + verbose_name = "Гео" + verbose_name_plural = "Гео" + form = LocationForm + readonly_fields = ("distance_coords_kup", "distance_coords_valid", "distance_kup_valid") + prefetch_related = ("mirrors",) + autocomplete_fields = ('mirrors',) + fieldsets = ( + ("Основная информация", { + "fields": ("mirrors", "location", "distance_coords_kup", + "distance_coords_valid", "distance_kup_valid", "timestamp", "comment",) + }), + ("Координаты: геолокация", { + "fields": ("longitude_geo", "latitude_geo", "coords"), + }), + ("Координаты: Кубсат", { + "fields": ("longitude_kupsat", "latitude_kupsat", "coords_kupsat"), + }), + ("Координаты: Оперативный отдел", { + "fields": ("longitude_valid", "latitude_valid", "coords_valid"), + }), + ) + + +class UserAdmin(BaseUserAdmin): + inlines = [CustomUserInline] + +admin.site.register(User, UserAdmin) + + +# ============================================================================ +# Custom Admin Actions +# ============================================================================ + +@admin.action(description="Показать выбранные на карте") +def show_on_map(modeladmin, request, queryset): + """ + Action для отображения выбранных Geo объектов на карте. + + Оптимизирован для работы с большим количеством объектов: + использует values_list для получения только ID. + """ + selected_ids = queryset.values_list('id', flat=True) + ids_str = ','.join(str(pk) for pk in selected_ids) + return redirect(reverse('mainapp:admin_show_map') + f'?ids={ids_str}') + + +@admin.action(description="Показать выбранные объекты на карте") +def show_selected_on_map(modeladmin, request, queryset): + """ + Action для отображения выбранных ObjItem объектов на карте. + + Оптимизирован для работы с большим количеством объектов: + использует values_list для получения только ID. + """ + selected_ids = queryset.values_list('id', flat=True) + ids_str = ','.join(str(pk) for pk in selected_ids) + return redirect(reverse('mainapp:show_selected_objects_map') + f'?ids={ids_str}') + + +@admin.action(description="Экспортировать выбранные объекты в CSV") +def export_objects_to_csv(modeladmin, request, queryset): + """ + Action для экспорта выбранных ObjItem объектов в CSV формат. + + Оптимизирован с использованием select_related и prefetch_related + для минимизации количества запросов к БД. + """ + import csv + from django.http import HttpResponse + + # Оптимизируем queryset + queryset = queryset.select_related( + 'geo_obj', + 'created_by__user', + 'updated_by__user', + 'parameter_obj', + 'parameter_obj__id_satellite', + 'parameter_obj__polarization', + 'parameter_obj__modulation' + ) + + response = HttpResponse(content_type='text/csv; charset=utf-8') + response['Content-Disposition'] = 'attachment; filename="objitems_export.csv"' + response.write('\ufeff') # UTF-8 BOM для корректного отображения в Excel + + writer = csv.writer(response) + writer.writerow([ + 'Название', + 'Спутник', + 'Частота (МГц)', + 'Полоса (МГц)', + 'Поляризация', + 'Модуляция', + 'ОСШ', + 'Координаты геолокации', + 'Координаты Кубсата', + 'Координаты оперативного отдела', + 'Расстояние Гео-Куб (км)', + 'Расстояние Гео-Опер (км)', + 'Дата создания', + 'Дата обновления' + ]) + + for obj in queryset: + param = getattr(obj, 'parameter_obj', None) + geo = obj.geo_obj + + # Форматирование координат + def format_coords(coords): + if not coords: + return "-" + lon, lat = coords.coords[0], coords.coords[1] + lon_str = f"{lon}E" if lon > 0 else f"{abs(lon)}W" + lat_str = f"{lat}N" if lat > 0 else f"{abs(lat)}S" + return f"{lat_str} {lon_str}" + + writer.writerow([ + obj.name, + param.id_satellite.name if param and param.id_satellite else "-", + param.frequency if param else "-", + param.freq_range if param else "-", + param.polarization.name if param and param.polarization else "-", + param.modulation.name if param and param.modulation else "-", + param.snr if param else "-", + format_coords(geo) if geo and geo.coords else "-", + format_coords(geo) if geo and geo.coords_kupsat else "-", + format_coords(geo) if geo and geo.coords_valid else "-", + round(geo.distance_coords_kup, 3) if geo and geo.distance_coords_kup else "-", + round(geo.distance_coords_valid, 3) if geo and geo.distance_coords_valid else "-", + obj.created_at.strftime("%d.%m.%Y %H:%M:%S") if obj.created_at else "-", + obj.updated_at.strftime("%d.%m.%Y %H:%M:%S") if obj.updated_at else "-" + ]) + + return response + + +# ============================================================================ +# Inline Admin Classes +# ============================================================================ + +class ParameterInline(admin.StackedInline): + """Inline для редактирования параметра объекта.""" + model = Parameter + extra = 0 + max_num = 1 + can_delete = True + verbose_name = "ВЧ загрузка" + verbose_name_plural = "ВЧ загрузка" + fields = ( + 'id_satellite', + 'frequency', + 'freq_range', + 'polarization', + 'modulation', + 'bod_velocity', + 'snr', + 'standard' + ) + autocomplete_fields = ('id_satellite', 'polarization', 'modulation', 'standard') + + +# ============================================================================ +# Admin Classes +# ============================================================================ + +@admin.register(SigmaParMark) +class SigmaParMarkAdmin(BaseAdmin): + """Админ-панель для модели SigmaParMark.""" + list_display = ("mark", "timestamp") + search_fields = ("mark",) + ordering = ("-timestamp",) + list_filter = ( + ("timestamp", DateRangeQuickSelectListFilterBuilder()), + ) + + +@admin.register(Polarization) +class PolarizationAdmin(BaseAdmin): + """Админ-панель для модели Polarization.""" + list_display = ("name",) + search_fields = ("name",) + ordering = ("name",) + + +@admin.register(Modulation) +class ModulationAdmin(BaseAdmin): + """Админ-панель для модели Modulation.""" + list_display = ("name",) + search_fields = ("name",) + ordering = ("name",) + + +@admin.register(SourceType) +class SourceTypeAdmin(BaseAdmin): + """Админ-панель для модели SourceType.""" + list_display = ("name",) + search_fields = ("name",) + ordering = ("name",) + + +@admin.register(Standard) +class StandardAdmin(BaseAdmin): + """Админ-панель для модели Standard.""" + list_display = ("name",) + search_fields = ("name",) + ordering = ("name",) + + +class SigmaParameterInline(admin.StackedInline): + model = SigmaParameter + extra = 0 + autocomplete_fields = ['mark'] + readonly_fields = ( + "datetime_begin", + "datetime_end", + ) + def has_add_permission(self, request, obj=None): + return False + + +@admin.register(Parameter) +class ParameterAdmin(ImportExportActionModelAdmin, BaseAdmin): + """ + Админ-панель для модели Parameter. + + Оптимизирована для работы с большим количеством параметров: + - Использует select_related для оптимизации запросов + - Предоставляет фильтры по основным характеристикам + - Поддерживает импорт/экспорт данных + """ + list_display = ( + "id_satellite", + "frequency", + "freq_range", + "polarization", + "modulation", + "bod_velocity", + "snr", + "standard", + "related_objitem", + "sigma_parameter" + ) + list_display_links = ("frequency", "id_satellite") + list_select_related = ("polarization", "modulation", "standard", "id_satellite", "objitem") + + list_filter = ( + HasSigmaParameterFilter, + ("objitem", MultiSelectRelatedDropdownFilter), + ("id_satellite", MultiSelectRelatedDropdownFilter), + ("polarization__name", MultiSelectDropdownFilter), + ("modulation", MultiSelectRelatedDropdownFilter), + ("standard", MultiSelectRelatedDropdownFilter), + ("frequency", NumericRangeFilterBuilder()), + ("freq_range", NumericRangeFilterBuilder()), + ("snr", NumericRangeFilterBuilder()), + ) + + search_fields = ( + "id_satellite__name", + "frequency", + "freq_range", + "bod_velocity", + "snr", + "modulation__name", + "polarization__name", + "standard__name", + "objitem__name", + ) + + ordering = ("-frequency",) + autocomplete_fields = ("objitem",) + inlines = [SigmaParameterInline] + + def related_objitem(self, obj): + """Отображает связанный ObjItem.""" + if hasattr(obj, 'objitem') and obj.objitem: + return obj.objitem.name + return "-" + related_objitem.short_description = "Объект" + related_objitem.admin_order_field = "objitem__name" + + def sigma_parameter(self, obj): + """Отображает связанный параметр Sigma.""" + sigma_obj = obj.sigma_parameter.all() + if sigma_obj: + return f"{sigma_obj[0].frequency}: {sigma_obj[0].freq_range}" + return "-" + sigma_parameter.short_description = "ВЧ sigma" + + +@admin.register(SigmaParameter) +class SigmaParameterAdmin(ImportExportActionModelAdmin, BaseAdmin): + """ + Админ-панель для модели SigmaParameter. + + Оптимизирована для работы с параметрами Sigma: + - Использует select_related и prefetch_related для оптимизации + - Предоставляет фильтры по основным характеристикам + - Поддерживает импорт/экспорт данных + """ + list_display = ( + "id_satellite", + "frequency", + "transfer_frequency", + "freq_range", + "polarization", + "modulation", + "bod_velocity", + "snr", + "parameter", + "datetime_begin", + "datetime_end", + ) + list_display_links = ("id_satellite",) + list_select_related = ("modulation", "standard", "id_satellite", "parameter", "polarization") + + readonly_fields = ( + "datetime_begin", + "datetime_end", + "transfer_frequency" + ) + + list_filter = ( + ("id_satellite__name", MultiSelectDropdownFilter), + ("modulation__name", MultiSelectDropdownFilter), + ("standard__name", MultiSelectDropdownFilter), + ("frequency", NumericRangeFilterBuilder()), + ("freq_range", NumericRangeFilterBuilder()), + ("snr", NumericRangeFilterBuilder()), + ("datetime_begin", DateRangeQuickSelectListFilterBuilder()), + ("datetime_end", DateRangeQuickSelectListFilterBuilder()), + ) + + search_fields = ( + "id_satellite__name", + "frequency", + "freq_range", + "bod_velocity", + "snr", + "modulation__name", + "standard__name", + ) + + autocomplete_fields = ("mark",) + ordering = ("-frequency",) + + def get_queryset(self, request): + """Оптимизированный queryset с prefetch_related для mark.""" + qs = super().get_queryset(request) + return qs.prefetch_related("mark") + + +@admin.register(Satellite) +class SatelliteAdmin(BaseAdmin): + """Админ-панель для модели Satellite.""" + list_display = ("name",) + search_fields = ("name",) + ordering = ("name",) + + +@admin.register(Mirror) +class MirrorAdmin(ImportExportActionModelAdmin, BaseAdmin): + """Админ-панель для модели Mirror с поддержкой импорта/экспорта.""" + list_display = ("name",) + search_fields = ("name",) + ordering = ("name",) + + +@admin.register(Geo) +class GeoAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin, BaseAdmin): + """ + Админ-панель для модели Geo с поддержкой карты Leaflet. + + Оптимизирована для работы с геоданными: + - Использует prefetch_related для оптимизации запросов к mirrors + - Предоставляет фильтры по зеркалам, локации и дате + - Поддерживает импорт/экспорт данных + - Интегрирована с Leaflet для отображения на карте + """ + form = LocationForm + + readonly_fields = ("distance_coords_kup", "distance_coords_valid", "distance_kup_valid") + + fieldsets = ( + ("Основная информация", { + "fields": ("mirrors", "location", "distance_coords_kup", + "distance_coords_valid", "distance_kup_valid", "timestamp", "comment") + }), + ("Координаты: геолокация", { + "fields": ("longitude_geo", "latitude_geo", "coords") + }), + ("Координаты: Кубсат", { + "fields": ("longitude_kupsat", "latitude_kupsat", "coords_kupsat") + }), + ("Координаты: Оперативный отдел", { + "fields": ("longitude_valid", "latitude_valid", "coords_valid") + }), + ) + + list_display = ( + "formatted_timestamp", + "location", + "mirrors_names", + "geo_coords", + "kupsat_coords", + "valid_coords", + "is_average", + ) + list_display_links = ("formatted_timestamp",) + + list_filter = ( + ("mirrors", MultiSelectRelatedDropdownFilter), + "is_average", + ("location", MultiSelectDropdownFilter), + ("timestamp", DateRangeQuickSelectListFilterBuilder()), + ) + + search_fields = ( + "mirrors__name", + "location", + ) + + autocomplete_fields = ("mirrors",) + ordering = ("-timestamp",) + actions = [show_on_map] + + settings_overrides = { + 'DEFAULT_CENTER': (55.7558, 37.6173), + 'DEFAULT_ZOOM': 12, + } + + def get_queryset(self, request): + """Оптимизированный queryset с prefetch_related для mirrors.""" + qs = super().get_queryset(request) + return qs.prefetch_related("mirrors") + + def mirrors_names(self, obj): + """Отображает список зеркал через запятую.""" + return ", ".join(m.name for m in obj.mirrors.all()) + mirrors_names.short_description = "Зеркала" + + def formatted_timestamp(self, obj): + """Форматирует timestamp в локальное время.""" + if not obj.timestamp: + return "" + local_time = timezone.localtime(obj.timestamp) + return local_time.strftime("%d.%m.%Y %H:%M:%S") + formatted_timestamp.short_description = "Дата и время" + formatted_timestamp.admin_order_field = "timestamp" + + def geo_coords(self, obj): + """Отображает координаты геолокации в формате широта/долгота.""" + if not obj.coords: + return "-" + longitude = obj.coords.coords[0] + latitude = obj.coords.coords[1] + lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" + lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" + return f"{lat} {lon}" + geo_coords.short_description = "Координаты геолокации" + + def kupsat_coords(self, obj): + """Отображает координаты Кубсата в формате широта/долгота.""" + if obj.coords_kupsat is None: + return "-" + longitude = obj.coords_kupsat.coords[0] + latitude = obj.coords_kupsat.coords[1] + lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" + lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" + return f"{lat} {lon}" + kupsat_coords.short_description = "Координаты Кубсата" + + def valid_coords(self, obj): + """Отображает координаты оперативного отдела в формате широта/долгота.""" + if obj.coords_valid is None: + return "-" + longitude = obj.coords_valid.coords[0] + latitude = obj.coords_valid.coords[1] + lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" + lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" + return f"{lat} {lon}" + valid_coords.short_description = "Координаты оперативного отдела" + + + + +@admin.register(ObjItem) +class ObjItemAdmin(BaseAdmin): + """ + Админ-панель для модели ObjItem. + + Оптимизирована для работы с большим количеством объектов: + - Использует select_related и prefetch_related для оптимизации запросов + - Предоставляет фильтры по основным параметрам + - Поддерживает поиск по имени, координатам и частоте + - Включает кастомные actions для отображения на карте + """ + list_display = ( + "name", + "sat_name", + "freq", + "freq_range", + "pol", + "bod_velocity", + "modulation", + "snr", + "geo_coords", + "kupsat_coords", + "valid_coords", + "distance_geo_kup", + "distance_geo_valid", + "distance_kup_valid", + "created_at", + "updated_at", + ) + list_display_links = ("name",) + list_select_related = ( + "geo_obj", + "created_by__user", + "updated_by__user", + "parameter_obj", + "parameter_obj__id_satellite", + "parameter_obj__polarization", + "parameter_obj__modulation", + "parameter_obj__standard" + ) + + list_filter = ( + UniqueToggleFilter, + ("parameter_obj__id_satellite", MultiSelectRelatedDropdownFilter), + ("parameter_obj__frequency", NumericRangeFilterBuilder()), + ("parameter_obj__freq_range", NumericRangeFilterBuilder()), + ("parameter_obj__snr", NumericRangeFilterBuilder()), + ("parameter_obj__modulation", MultiSelectRelatedDropdownFilter), + ("parameter_obj__polarization", MultiSelectRelatedDropdownFilter), + GeoKupDistanceFilter, + GeoValidDistanceFilter, + ("created_at", DateRangeQuickSelectListFilterBuilder()), + ("updated_at", DateRangeQuickSelectListFilterBuilder()), + ) + + search_fields = ( + "name", + "geo_obj__location", + "parameter_obj__frequency", + "parameter_obj__id_satellite__name", + ) + + ordering = ("-updated_at",) + inlines = [GeoInline, ParameterInline] + actions = [show_selected_on_map, export_objects_to_csv] + readonly_fields = ("created_at", "created_by", "updated_at", "updated_by") + + fieldsets = ( + ("Основная информация", { + "fields": ("name",) + }), + ("Метаданные", { + "fields": ("created_at", "created_by", "updated_at", "updated_by"), + "classes": ("collapse",) + }), + ) + + def get_queryset(self, request): + """ + Оптимизированный queryset с использованием select_related. + + Загружает связанные объекты одним запросом для улучшения производительности. + """ + qs = super().get_queryset(request) + return qs.select_related( + "geo_obj", + "created_by__user", + "updated_by__user", + "parameter_obj", + "parameter_obj__id_satellite", + "parameter_obj__polarization", + "parameter_obj__modulation", + "parameter_obj__standard" + ) + + def sat_name(self, obj): + """Отображает название спутника из связанного параметра.""" + if hasattr(obj, 'parameter_obj') and obj.parameter_obj: + if obj.parameter_obj.id_satellite: + return obj.parameter_obj.id_satellite.name + return "-" + sat_name.short_description = "Спутник" + sat_name.admin_order_field = "parameter_obj__id_satellite__name" + + def freq(self, obj): + """Отображает частоту из связанного параметра.""" + if hasattr(obj, 'parameter_obj') and obj.parameter_obj: + return obj.parameter_obj.frequency + return "-" + freq.short_description = "Частота, МГц" + freq.admin_order_field = "parameter_obj__frequency" + + def distance_geo_kup(self, obj): + """Отображает расстояние между геолокацией и Кубсатом.""" + geo = obj.geo_obj + if not geo or geo.distance_coords_kup is None: + return "-" + return round(geo.distance_coords_kup, 3) + distance_geo_kup.short_description = "Гео-куб, км" + + def distance_geo_valid(self, obj): + """Отображает расстояние между геолокацией и оперативным отделом.""" + geo = obj.geo_obj + if not geo or geo.distance_coords_valid is None: + return "-" + return round(geo.distance_coords_valid, 3) + distance_geo_valid.short_description = "Гео-опер, км" + + def distance_kup_valid(self, obj): + """Отображает расстояние между Кубсатом и оперативным отделом.""" + geo = obj.geo_obj + if not geo or geo.distance_kup_valid is None: + return "-" + return round(geo.distance_kup_valid, 3) + distance_kup_valid.short_description = "Куб-опер, км" + + def pol(self, obj): + """Отображает поляризацию из связанного параметра.""" + if hasattr(obj, 'parameter_obj') and obj.parameter_obj: + if obj.parameter_obj.polarization: + return obj.parameter_obj.polarization.name + return "-" + pol.short_description = "Поляризация" + + def freq_range(self, obj): + """Отображает полосу частот из связанного параметра.""" + if hasattr(obj, 'parameter_obj') and obj.parameter_obj: + return obj.parameter_obj.freq_range + return "-" + freq_range.short_description = "Полоса, МГц" + freq_range.admin_order_field = "parameter_obj__freq_range" + + def bod_velocity(self, obj): + """Отображает символьную скорость из связанного параметра.""" + if hasattr(obj, 'parameter_obj') and obj.parameter_obj: + return obj.parameter_obj.bod_velocity + return "-" + bod_velocity.short_description = "Сим. v, БОД" + + def modulation(self, obj): + """Отображает модуляцию из связанного параметра.""" + if hasattr(obj, 'parameter_obj') and obj.parameter_obj: + if obj.parameter_obj.modulation: + return obj.parameter_obj.modulation.name + return "-" + modulation.short_description = "Модуляция" + + def snr(self, obj): + """Отображает отношение сигнал/шум из связанного параметра.""" + if hasattr(obj, 'parameter_obj') and obj.parameter_obj: + return obj.parameter_obj.snr + return "-" + snr.short_description = "ОСШ" + + def geo_coords(self, obj): + """Отображает координаты геолокации в формате широта/долгота.""" + geo = obj.geo_obj + if not geo or not geo.coords: + return "-" + longitude = geo.coords.coords[0] + latitude = geo.coords.coords[1] + lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" + lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" + return f"{lat} {lon}" + geo_coords.short_description = "Координаты геолокации" + geo_coords.admin_order_field = "geo_obj__coords" + + def kupsat_coords(self, obj): + """Отображает координаты Кубсата в формате широта/долгота.""" + geo = obj.geo_obj + if not geo or not geo.coords_kupsat: + return "-" + longitude = geo.coords_kupsat.coords[0] + latitude = geo.coords_kupsat.coords[1] + lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" + lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" + return f"{lat} {lon}" + kupsat_coords.short_description = "Координаты Кубсата" + + def valid_coords(self, obj): + """Отображает координаты оперативного отдела в формате широта/долгота.""" + geo = obj.geo_obj + if not geo or not geo.coords_valid: + return "-" + longitude = geo.coords_valid.coords[0] + latitude = geo.coords_valid.coords[1] + lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" + lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" + return f"{lat} {lon}" + valid_coords.short_description = "Координаты оперативного отдела" diff --git a/dbapp/mainapp/clusters.py b/dbapp/mainapp/clusters.py index 1805649..b2a2f48 100644 --- a/dbapp/mainapp/clusters.py +++ b/dbapp/mainapp/clusters.py @@ -1,34 +1,34 @@ -# Third-party imports -import matplotlib.pyplot as plt -import numpy as np -from sklearn.cluster import DBSCAN, HDBSCAN, KMeans - -# Local imports -from .models import ObjItem - -def get_clusters(coords: list[tuple[float, float]]): - coords = np.radians(coords) - lat, lon = coords[:, 0], coords[:, 1] - db = DBSCAN(eps=0.06, min_samples=5, algorithm='ball_tree', metric='haversine') - # db = HDBSCAN() - cluster_labels = db.fit_predict(coords) - plt.figure(figsize=(10, 8)) - unique_labels = set(cluster_labels) - colors = plt.cm.tab10(np.linspace(0, 1, len(unique_labels))) - - for label, color in zip(unique_labels, colors): - if label == -1: - color = 'k' - label_name = 'Шум' - else: - label_name = f'Кластер {label}' - - mask = cluster_labels == label - plt.scatter(lon[mask], lat[mask], c=[color], label=label_name, s=30) - - plt.xlabel('Долгота') - plt.ylabel('Широта') - plt.title('Кластеризация геоданных с DBSCAN (метрика Хаверсина)') - plt.legend() - plt.grid(True) - plt.show() +# Third-party imports +import matplotlib.pyplot as plt +import numpy as np +from sklearn.cluster import DBSCAN, HDBSCAN, KMeans + +# Local imports +from .models import ObjItem + +def get_clusters(coords: list[tuple[float, float]]): + coords = np.radians(coords) + lat, lon = coords[:, 0], coords[:, 1] + db = DBSCAN(eps=0.06, min_samples=5, algorithm='ball_tree', metric='haversine') + # db = HDBSCAN() + cluster_labels = db.fit_predict(coords) + plt.figure(figsize=(10, 8)) + unique_labels = set(cluster_labels) + colors = plt.cm.tab10(np.linspace(0, 1, len(unique_labels))) + + for label, color in zip(unique_labels, colors): + if label == -1: + color = 'k' + label_name = 'Шум' + else: + label_name = f'Кластер {label}' + + mask = cluster_labels == label + plt.scatter(lon[mask], lat[mask], c=[color], label=label_name, s=30) + + plt.xlabel('Долгота') + plt.ylabel('Широта') + plt.title('Кластеризация геоданных с DBSCAN (метрика Хаверсина)') + plt.legend() + plt.grid(True) + plt.show() diff --git a/dbapp/mainapp/filters.py b/dbapp/mainapp/filters.py index 2ecf9aa..1a22f94 100644 --- a/dbapp/mainapp/filters.py +++ b/dbapp/mainapp/filters.py @@ -1,76 +1,76 @@ -# Django imports -from django.contrib.admin import SimpleListFilter - -# Local imports -from .models import ObjItem - -class GeoKupDistanceFilter(SimpleListFilter): - title = 'Расстояние между гео и кубсатом' - parameter_name = 'distance_geo_kup' - - def lookups(self, request, model_admin): - return ( - ('small', 'Меньше 100 км'), - ('medium', '100-500 км'), - ('large', 'Больше 500 км'), - ) - - def queryset(self, request, queryset): - if self.value() == 'small': - return queryset.filter(distance_coords_kup__lt=100) - if self.value() == 'medium': - return queryset.filter(distance_coords_kup__gte=100, distance_coords_kup__lte=500) - if self.value() == 'large': - return queryset.filter(distance_coords_kup__gt=500) - - -class GeoValidDistanceFilter(SimpleListFilter): - title = 'Расстояние между гео и оперативным отделом' - parameter_name = 'distance_geo_valid' - - def lookups(self, request, model_admin): - return ( - ('small', 'Меньше 100 км'), - ('medium', '100-500 км'), - ('large', 'Больше 500 км'), - ) - - def queryset(self, request, queryset): - if self.value() == 'small': - return queryset.filter(distance_coords_valid__lt=100) - if self.value() == 'medium': - return queryset.filter(distance_coords_valid__gte=100, distance_coords_valid__lte=500) - if self.value() == 'large': - return queryset.filter(distance_coords_valid__gt=500) - -class UniqueToggleFilter(SimpleListFilter): - title = 'Уникальность по имени' - parameter_name = 'name' - - def lookups(self, request, model_admin): - return ( - ('unique', 'Только уникальные'), - ('all', 'Все'), - ) - - def queryset(self, request, queryset): - if self.value() == 'unique': - return queryset.order_by('name').distinct('name') - return queryset - -class HasSigmaParameterFilter(SimpleListFilter): - title = 'ВЧ sigma' - parameter_name = 'has_sigma' - - def lookups(self, request, model_admin): - return ( - ('yes', 'Заполнено'), - ('no', 'Пусто'), - ) - - def queryset(self, request, queryset): - if self.value() == 'yes': - return queryset.filter(sigma_parameter__isnull=False) - if self.value() == 'no': - return queryset.filter(sigma_parameter__isnull=True) +# Django imports +from django.contrib.admin import SimpleListFilter + +# Local imports +from .models import ObjItem + +class GeoKupDistanceFilter(SimpleListFilter): + title = 'Расстояние между гео и кубсатом' + parameter_name = 'distance_geo_kup' + + def lookups(self, request, model_admin): + return ( + ('small', 'Меньше 100 км'), + ('medium', '100-500 км'), + ('large', 'Больше 500 км'), + ) + + def queryset(self, request, queryset): + if self.value() == 'small': + return queryset.filter(distance_coords_kup__lt=100) + if self.value() == 'medium': + return queryset.filter(distance_coords_kup__gte=100, distance_coords_kup__lte=500) + if self.value() == 'large': + return queryset.filter(distance_coords_kup__gt=500) + + +class GeoValidDistanceFilter(SimpleListFilter): + title = 'Расстояние между гео и оперативным отделом' + parameter_name = 'distance_geo_valid' + + def lookups(self, request, model_admin): + return ( + ('small', 'Меньше 100 км'), + ('medium', '100-500 км'), + ('large', 'Больше 500 км'), + ) + + def queryset(self, request, queryset): + if self.value() == 'small': + return queryset.filter(distance_coords_valid__lt=100) + if self.value() == 'medium': + return queryset.filter(distance_coords_valid__gte=100, distance_coords_valid__lte=500) + if self.value() == 'large': + return queryset.filter(distance_coords_valid__gt=500) + +class UniqueToggleFilter(SimpleListFilter): + title = 'Уникальность по имени' + parameter_name = 'name' + + def lookups(self, request, model_admin): + return ( + ('unique', 'Только уникальные'), + ('all', 'Все'), + ) + + def queryset(self, request, queryset): + if self.value() == 'unique': + return queryset.order_by('name').distinct('name') + return queryset + +class HasSigmaParameterFilter(SimpleListFilter): + title = 'ВЧ sigma' + parameter_name = 'has_sigma' + + def lookups(self, request, model_admin): + return ( + ('yes', 'Заполнено'), + ('no', 'Пусто'), + ) + + def queryset(self, request, queryset): + if self.value() == 'yes': + return queryset.filter(sigma_parameter__isnull=False) + if self.value() == 'no': + return queryset.filter(sigma_parameter__isnull=True) return queryset \ No newline at end of file diff --git a/dbapp/mainapp/forms.py b/dbapp/mainapp/forms.py index 48eae25..2b0930c 100644 --- a/dbapp/mainapp/forms.py +++ b/dbapp/mainapp/forms.py @@ -1,301 +1,301 @@ -# Django imports -from django import forms - -# Local imports -from .models import Geo, Modulation, ObjItem, Parameter, Polarization, Satellite, Standard - -class UploadFileForm(forms.Form): - file = forms.FileField( - label="Выберите файл", - widget=forms.FileInput(attrs={ - 'class': 'form-file-input' - }) - ) - - -class LoadExcelData(forms.Form): - file = forms.FileField( - label="Выберите Excel файл", - widget=forms.FileInput(attrs={ - 'class': 'form-control', - 'accept': '.xlsx,.xls' - }) - ) - sat_choice = forms.ModelChoiceField( - queryset=Satellite.objects.all(), - label="Выберите спутник", - widget=forms.Select(attrs={ - 'class': 'form-select' - }) - ) - number_input = forms.IntegerField( - label="Введите число объектов", - min_value=0, - widget=forms.NumberInput(attrs={ - 'class': 'form-control' - }) - ) - -class LoadCsvData(forms.Form): - file = forms.FileField( - label="Выберите CSV файл", - widget=forms.FileInput(attrs={ - 'class': 'form-control', - 'accept': '.csv' - }) - ) - -class UploadVchLoad(UploadFileForm): - sat_choice = forms.ModelChoiceField( - queryset=Satellite.objects.all(), - label="Выберите спутник", - widget=forms.Select(attrs={ - 'class': 'form-select' - }) - ) - - -class VchLinkForm(forms.Form): - sat_choice = forms.ModelChoiceField( - queryset=Satellite.objects.all(), - label="Выберите спутник", - widget=forms.Select(attrs={ - 'class': 'form-select' - }) - ) - # ku_range = forms.ChoiceField( - # choices=[(9750.0, '9750'), (10750.0, '10750')], - # # coerce=lambda x: x == 'True', - # widget=forms.Select(attrs={'class': 'form-select'}), - # label='Выбор диапазона' - # ) - value1 = forms.FloatField( - label="Первое число", - widget=forms.NumberInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Введите первое число' - }) - ) - value2 = forms.FloatField( - label="Второе число", - widget=forms.NumberInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Введите второе число' - }) - ) - - -class NewEventForm(forms.Form): - # sat_choice = forms.ModelChoiceField( - # queryset=Satellite.objects.all(), - # label="Выберите спутник", - # widget=forms.Select(attrs={ - # 'class': 'form-select' - # }) - # ) - # pol_choice = forms.ModelChoiceField( - # queryset=Polarization.objects.all(), - # label="Выберите поляризацию", - # widget=forms.Select(attrs={ - # 'class': 'form-select' - # }) - # ) - file = forms.FileField( - label="Выберите файл", - widget=forms.FileInput(attrs={ - 'class': 'form-control', - 'accept': '.xlsx,.xls' - }) - ) - - -class FillLyngsatDataForm(forms.Form): - """Форма для заполнения данных из Lyngsat""" - - REGION_CHOICES = [ - ('europe', 'Европа'), - ('asia', 'Азия'), - ('america', 'Америка'), - ('atlantic', 'Атлантика'), - ] - - satellites = forms.ModelMultipleChoiceField( - queryset=Satellite.objects.all().order_by('name'), - label="Выберите спутники", - widget=forms.SelectMultiple(attrs={ - 'class': 'form-select', - 'size': '10' - }), - required=True, - help_text="Удерживайте Ctrl (Cmd на Mac) для выбора нескольких спутников" - ) - - regions = forms.MultipleChoiceField( - choices=REGION_CHOICES, - label="Выберите регионы", - widget=forms.SelectMultiple(attrs={ - 'class': 'form-select', - 'size': '4' - }), - required=True, - initial=['europe', 'asia', 'america', 'atlantic'], - help_text="Удерживайте Ctrl (Cmd на Mac) для выбора нескольких регионов" - ) -class ParameterForm(forms.ModelForm): - """ - Форма для создания и редактирования параметров ВЧ загрузки. - - Работает с одним экземпляром Parameter, связанным с ObjItem через OneToOne связь. - """ - - class Meta: - model = Parameter - fields = [ - 'id_satellite', 'frequency', 'freq_range', 'polarization', - 'bod_velocity', 'modulation', 'snr', 'standard' - ] - widgets = { - 'id_satellite': forms.Select(attrs={ - 'class': 'form-select', - 'required': True - }), - 'frequency': forms.NumberInput(attrs={ - 'class': 'form-control', - 'step': '0.000001', - 'min': '0', - 'max': '50000', - 'placeholder': 'Введите частоту в МГц' - }), - 'freq_range': forms.NumberInput(attrs={ - 'class': 'form-control', - 'step': '0.000001', - 'min': '0', - 'max': '1000', - 'placeholder': 'Введите полосу частот в МГц' - }), - 'bod_velocity': forms.NumberInput(attrs={ - 'class': 'form-control', - 'step': '0.001', - 'min': '0', - 'placeholder': 'Введите символьную скорость в БОД' - }), - 'snr': forms.NumberInput(attrs={ - 'class': 'form-control', - 'step': '0.001', - 'min': '-50', - 'max': '100', - 'placeholder': 'Введите ОСШ в дБ' - }), - 'polarization': forms.Select(attrs={'class': 'form-select'}), - 'modulation': forms.Select(attrs={'class': 'form-select'}), - 'standard': forms.Select(attrs={'class': 'form-select'}), - } - labels = { - 'id_satellite': 'Спутник', - 'frequency': 'Частота (МГц)', - 'freq_range': 'Полоса частот (МГц)', - 'polarization': 'Поляризация', - 'bod_velocity': 'Символьная скорость (БОД)', - 'modulation': 'Модуляция', - 'snr': 'ОСШ (дБ)', - 'standard': 'Стандарт', - } - help_texts = { - 'frequency': 'Частота в диапазоне от 0 до 50000 МГц', - 'freq_range': 'Полоса частот в диапазоне от 0 до 1000 МГц', - 'bod_velocity': 'Символьная скорость должна быть положительной', - 'snr': 'Отношение сигнал/шум в диапазоне от -50 до 100 дБ', - } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Динамически загружаем choices для select полей - self.fields['id_satellite'].queryset = Satellite.objects.all().order_by('name') - self.fields['polarization'].queryset = Polarization.objects.all().order_by('name') - self.fields['modulation'].queryset = Modulation.objects.all().order_by('name') - self.fields['standard'].queryset = Standard.objects.all().order_by('name') - - # Делаем спутник обязательным полем - self.fields['id_satellite'].required = True - - def clean(self): - """ - Дополнительная валидация формы. - - Проверяет соотношение между частотой, полосой частот и символьной скоростью. - """ - cleaned_data = super().clean() - frequency = cleaned_data.get('frequency') - freq_range = cleaned_data.get('freq_range') - bod_velocity = cleaned_data.get('bod_velocity') - - # Проверка что частота больше полосы частот - if frequency and freq_range: - if freq_range > frequency: - self.add_error('freq_range', 'Полоса частот не может быть больше частоты') - - # Проверка что символьная скорость соответствует полосе частот - if bod_velocity and freq_range: - if bod_velocity > freq_range * 1000000: # Конвертация МГц в Гц - self.add_error('bod_velocity', 'Символьная скорость не может превышать полосу частот') - - return cleaned_data - -class GeoForm(forms.ModelForm): - class Meta: - model = Geo - fields = ['location', 'comment', 'is_average'] - widgets = { - 'location': forms.TextInput(attrs={'class': 'form-control'}), - 'comment': forms.TextInput(attrs={'class': 'form-control'}), - 'is_average': forms.CheckboxInput(attrs={'class': 'form-check-input'}), - } - -class ObjItemForm(forms.ModelForm): - """ - Форма для создания и редактирования объектов (источников сигнала). - - Работает с моделью ObjItem. Параметры ВЧ загрузки обрабатываются отдельно - через ParameterForm с использованием OneToOne связи. - """ - - class Meta: - model = ObjItem - fields = ['name'] - widgets = { - 'name': forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Введите название объекта', - 'maxlength': '100' - }), - } - labels = { - 'name': 'Название объекта', - } - help_texts = { - 'name': 'Уникальное название объекта/источника сигнала', - } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - # Делаем поле name необязательным, так как оно может быть пустым - self.fields['name'].required = False - - def clean_name(self): - """ - Валидация поля name. - - Проверяет что название не состоит только из пробелов. - """ - name = self.cleaned_data.get('name') - - if name: - # Удаляем лишние пробелы - name = name.strip() - - # Проверяем что после удаления пробелов что-то осталось - if not name: - raise forms.ValidationError('Название не может состоять только из пробелов') - +# Django imports +from django import forms + +# Local imports +from .models import Geo, Modulation, ObjItem, Parameter, Polarization, Satellite, Standard + +class UploadFileForm(forms.Form): + file = forms.FileField( + label="Выберите файл", + widget=forms.FileInput(attrs={ + 'class': 'form-file-input' + }) + ) + + +class LoadExcelData(forms.Form): + file = forms.FileField( + label="Выберите Excel файл", + widget=forms.FileInput(attrs={ + 'class': 'form-control', + 'accept': '.xlsx,.xls' + }) + ) + sat_choice = forms.ModelChoiceField( + queryset=Satellite.objects.all(), + label="Выберите спутник", + widget=forms.Select(attrs={ + 'class': 'form-select' + }) + ) + number_input = forms.IntegerField( + label="Введите число объектов", + min_value=0, + widget=forms.NumberInput(attrs={ + 'class': 'form-control' + }) + ) + +class LoadCsvData(forms.Form): + file = forms.FileField( + label="Выберите CSV файл", + widget=forms.FileInput(attrs={ + 'class': 'form-control', + 'accept': '.csv' + }) + ) + +class UploadVchLoad(UploadFileForm): + sat_choice = forms.ModelChoiceField( + queryset=Satellite.objects.all(), + label="Выберите спутник", + widget=forms.Select(attrs={ + 'class': 'form-select' + }) + ) + + +class VchLinkForm(forms.Form): + sat_choice = forms.ModelChoiceField( + queryset=Satellite.objects.all(), + label="Выберите спутник", + widget=forms.Select(attrs={ + 'class': 'form-select' + }) + ) + # ku_range = forms.ChoiceField( + # choices=[(9750.0, '9750'), (10750.0, '10750')], + # # coerce=lambda x: x == 'True', + # widget=forms.Select(attrs={'class': 'form-select'}), + # label='Выбор диапазона' + # ) + value1 = forms.FloatField( + label="Первое число", + widget=forms.NumberInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Введите первое число' + }) + ) + value2 = forms.FloatField( + label="Второе число", + widget=forms.NumberInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Введите второе число' + }) + ) + + +class NewEventForm(forms.Form): + # sat_choice = forms.ModelChoiceField( + # queryset=Satellite.objects.all(), + # label="Выберите спутник", + # widget=forms.Select(attrs={ + # 'class': 'form-select' + # }) + # ) + # pol_choice = forms.ModelChoiceField( + # queryset=Polarization.objects.all(), + # label="Выберите поляризацию", + # widget=forms.Select(attrs={ + # 'class': 'form-select' + # }) + # ) + file = forms.FileField( + label="Выберите файл", + widget=forms.FileInput(attrs={ + 'class': 'form-control', + 'accept': '.xlsx,.xls' + }) + ) + + +class FillLyngsatDataForm(forms.Form): + """Форма для заполнения данных из Lyngsat""" + + REGION_CHOICES = [ + ('europe', 'Европа'), + ('asia', 'Азия'), + ('america', 'Америка'), + ('atlantic', 'Атлантика'), + ] + + satellites = forms.ModelMultipleChoiceField( + queryset=Satellite.objects.all().order_by('name'), + label="Выберите спутники", + widget=forms.SelectMultiple(attrs={ + 'class': 'form-select', + 'size': '10' + }), + required=True, + help_text="Удерживайте Ctrl (Cmd на Mac) для выбора нескольких спутников" + ) + + regions = forms.MultipleChoiceField( + choices=REGION_CHOICES, + label="Выберите регионы", + widget=forms.SelectMultiple(attrs={ + 'class': 'form-select', + 'size': '4' + }), + required=True, + initial=['europe', 'asia', 'america', 'atlantic'], + help_text="Удерживайте Ctrl (Cmd на Mac) для выбора нескольких регионов" + ) +class ParameterForm(forms.ModelForm): + """ + Форма для создания и редактирования параметров ВЧ загрузки. + + Работает с одним экземпляром Parameter, связанным с ObjItem через OneToOne связь. + """ + + class Meta: + model = Parameter + fields = [ + 'id_satellite', 'frequency', 'freq_range', 'polarization', + 'bod_velocity', 'modulation', 'snr', 'standard' + ] + widgets = { + 'id_satellite': forms.Select(attrs={ + 'class': 'form-select', + 'required': True + }), + 'frequency': forms.NumberInput(attrs={ + 'class': 'form-control', + 'step': '0.000001', + 'min': '0', + 'max': '50000', + 'placeholder': 'Введите частоту в МГц' + }), + 'freq_range': forms.NumberInput(attrs={ + 'class': 'form-control', + 'step': '0.000001', + 'min': '0', + 'max': '1000', + 'placeholder': 'Введите полосу частот в МГц' + }), + 'bod_velocity': forms.NumberInput(attrs={ + 'class': 'form-control', + 'step': '0.001', + 'min': '0', + 'placeholder': 'Введите символьную скорость в БОД' + }), + 'snr': forms.NumberInput(attrs={ + 'class': 'form-control', + 'step': '0.001', + 'min': '-50', + 'max': '100', + 'placeholder': 'Введите ОСШ в дБ' + }), + 'polarization': forms.Select(attrs={'class': 'form-select'}), + 'modulation': forms.Select(attrs={'class': 'form-select'}), + 'standard': forms.Select(attrs={'class': 'form-select'}), + } + labels = { + 'id_satellite': 'Спутник', + 'frequency': 'Частота (МГц)', + 'freq_range': 'Полоса частот (МГц)', + 'polarization': 'Поляризация', + 'bod_velocity': 'Символьная скорость (БОД)', + 'modulation': 'Модуляция', + 'snr': 'ОСШ (дБ)', + 'standard': 'Стандарт', + } + help_texts = { + 'frequency': 'Частота в диапазоне от 0 до 50000 МГц', + 'freq_range': 'Полоса частот в диапазоне от 0 до 1000 МГц', + 'bod_velocity': 'Символьная скорость должна быть положительной', + 'snr': 'Отношение сигнал/шум в диапазоне от -50 до 100 дБ', + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Динамически загружаем choices для select полей + self.fields['id_satellite'].queryset = Satellite.objects.all().order_by('name') + self.fields['polarization'].queryset = Polarization.objects.all().order_by('name') + self.fields['modulation'].queryset = Modulation.objects.all().order_by('name') + self.fields['standard'].queryset = Standard.objects.all().order_by('name') + + # Делаем спутник обязательным полем + self.fields['id_satellite'].required = True + + def clean(self): + """ + Дополнительная валидация формы. + + Проверяет соотношение между частотой, полосой частот и символьной скоростью. + """ + cleaned_data = super().clean() + frequency = cleaned_data.get('frequency') + freq_range = cleaned_data.get('freq_range') + bod_velocity = cleaned_data.get('bod_velocity') + + # Проверка что частота больше полосы частот + if frequency and freq_range: + if freq_range > frequency: + self.add_error('freq_range', 'Полоса частот не может быть больше частоты') + + # Проверка что символьная скорость соответствует полосе частот + if bod_velocity and freq_range: + if bod_velocity > freq_range * 1000000: # Конвертация МГц в Гц + self.add_error('bod_velocity', 'Символьная скорость не может превышать полосу частот') + + return cleaned_data + +class GeoForm(forms.ModelForm): + class Meta: + model = Geo + fields = ['location', 'comment', 'is_average'] + widgets = { + 'location': forms.TextInput(attrs={'class': 'form-control'}), + 'comment': forms.TextInput(attrs={'class': 'form-control'}), + 'is_average': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + } + +class ObjItemForm(forms.ModelForm): + """ + Форма для создания и редактирования объектов (источников сигнала). + + Работает с моделью ObjItem. Параметры ВЧ загрузки обрабатываются отдельно + через ParameterForm с использованием OneToOne связи. + """ + + class Meta: + model = ObjItem + fields = ['name'] + widgets = { + 'name': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Введите название объекта', + 'maxlength': '100' + }), + } + labels = { + 'name': 'Название объекта', + } + help_texts = { + 'name': 'Уникальное название объекта/источника сигнала', + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Делаем поле name необязательным, так как оно может быть пустым + self.fields['name'].required = False + + def clean_name(self): + """ + Валидация поля name. + + Проверяет что название не состоит только из пробелов. + """ + name = self.cleaned_data.get('name') + + if name: + # Удаляем лишние пробелы + name = name.strip() + + # Проверяем что после удаления пробелов что-то осталось + if not name: + raise forms.ValidationError('Название не может состоять только из пробелов') + return name \ No newline at end of file diff --git a/dbapp/mainapp/management/commands/test_celery.py b/dbapp/mainapp/management/commands/test_celery.py new file mode 100644 index 0000000..5a865dc --- /dev/null +++ b/dbapp/mainapp/management/commands/test_celery.py @@ -0,0 +1,24 @@ +from django.core.management.base import BaseCommand +from mainapp.tasks import test_celery_connection, add_numbers + + +class Command(BaseCommand): + help = 'Test Celery functionality' + + def handle(self, *args, **options): + self.stdout.write('Testing Celery connection...') + + # Test simple task + result = test_celery_connection.delay("Hello from test command!") + self.stdout.write(f'Task ID: {result.id}') + + # Wait for result + task_result = result.get(timeout=10) + self.stdout.write(self.style.SUCCESS(f'Task result: {task_result}')) + + # Test math task + math_result = add_numbers.delay(10, 20) + sum_result = math_result.get(timeout=10) + self.stdout.write(self.style.SUCCESS(f'10 + 20 = {sum_result}')) + + self.stdout.write(self.style.SUCCESS('All tests passed!')) \ No newline at end of file diff --git a/dbapp/mainapp/migrations/0001_initial.py b/dbapp/mainapp/migrations/0001_initial.py index 66f8653..579b6de 100644 --- a/dbapp/mainapp/migrations/0001_initial.py +++ b/dbapp/mainapp/migrations/0001_initial.py @@ -1,204 +1,204 @@ -# Generated by Django 5.2.7 on 2025-10-31 13:36 - -import django.contrib.gis.db.models.fields -import django.contrib.gis.db.models.functions -import django.db.models.deletion -import django.db.models.expressions -import mainapp.models -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='Mirror', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=30, unique=True, verbose_name='Имя зеркала')), - ], - options={ - 'verbose_name': 'Зеркало', - 'verbose_name_plural': 'Зеркала', - }, - ), - migrations.CreateModel( - name='Modulation', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(db_index=True, max_length=20, unique=True, verbose_name='Модуляция')), - ], - options={ - 'verbose_name': 'Модуляция', - 'verbose_name_plural': 'Модуляции', - }, - ), - migrations.CreateModel( - name='Polarization', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=20, unique=True, verbose_name='Поляризация')), - ], - options={ - 'verbose_name': 'Поляризация', - 'verbose_name_plural': 'Поляризация', - }, - ), - migrations.CreateModel( - name='Satellite', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(db_index=True, max_length=100, unique=True, verbose_name='Имя спутника')), - ('norad', models.IntegerField(blank=True, null=True, verbose_name='NORAD ID')), - ], - options={ - 'verbose_name': 'Спутник', - 'verbose_name_plural': 'Спутники', - }, - ), - migrations.CreateModel( - name='SigmaParMark', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('mark', models.BooleanField(blank=True, null=True, verbose_name='Наличие сигнала')), - ('timestamp', models.DateTimeField(blank=True, null=True, verbose_name='Время')), - ], - options={ - 'verbose_name': 'Отметка', - 'verbose_name_plural': 'Отметки', - }, - ), - migrations.CreateModel( - name='Standard', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=20, unique=True, verbose_name='Стандарт')), - ], - options={ - 'verbose_name': 'Стандарт', - 'verbose_name_plural': 'Стандарты', - }, - ), - migrations.CreateModel( - name='CustomUser', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('role', models.CharField(choices=[('admin', 'Администратор'), ('moderator', 'Модератор'), ('user', 'Пользователь')], default='user', max_length=20, verbose_name='Роль пользователя')), - ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - options={ - 'verbose_name': 'Пользователь', - 'verbose_name_plural': 'Пользователи', - }, - ), - migrations.CreateModel( - name='ObjItem', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(blank=True, db_index=True, max_length=100, null=True, verbose_name='Имя объекта')), - ('id_user_add', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objitems', to='mainapp.customuser', verbose_name='Пользователь')), - ], - options={ - 'verbose_name': 'Объект', - 'verbose_name_plural': 'Объекты', - }, - ), - migrations.CreateModel( - name='Parameter', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('frequency', models.FloatField(blank=True, db_index=True, default=0, null=True, verbose_name='Частота, МГц')), - ('freq_range', models.FloatField(blank=True, default=0, null=True, verbose_name='Полоса частот, МГц')), - ('bod_velocity', models.FloatField(blank=True, default=0, null=True, verbose_name='Символьная скорость, БОД')), - ('snr', models.FloatField(blank=True, default=0, null=True, verbose_name='ОСШ')), - ('id_user_add', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='parameter_added', to='mainapp.customuser', verbose_name='Пользователь')), - ('modulation', models.ForeignKey(blank=True, default=mainapp.models.get_default_modulation, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='modulations', to='mainapp.modulation', verbose_name='Модуляция')), - ('objitems', models.ManyToManyField(blank=True, related_name='parameters_obj', to='mainapp.objitem', verbose_name='Источники')), - ('polarization', models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='polarizations', to='mainapp.polarization', verbose_name='Поляризация')), - ('id_satellite', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='parameters', to='mainapp.satellite', verbose_name='Спутник')), - ('standard', models.ForeignKey(blank=True, default=mainapp.models.get_default_standard, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='standards', to='mainapp.standard', verbose_name='Стандарт')), - ], - options={ - 'verbose_name': 'ВЧ загрузка', - 'verbose_name_plural': 'ВЧ загрузки', - }, - ), - migrations.CreateModel( - name='SourceType', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=50, unique=True, verbose_name='Тип источника')), - ('objitem', models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_type_obj', to='mainapp.objitem', verbose_name='Гео')), - ], - options={ - 'verbose_name': 'Тип источника', - 'verbose_name_plural': 'Типы источников', - }, - ), - migrations.CreateModel( - name='SigmaParameter', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('transfer', models.FloatField(choices=[(-1.0, '-'), (9750.0, '9750 МГц'), (10750.0, '10750 МГц')], default=-1.0, verbose_name='Перенос по частоте')), - ('status', models.CharField(blank=True, max_length=20, null=True, verbose_name='Статус')), - ('frequency', models.FloatField(blank=True, db_index=True, default=0, null=True, verbose_name='Частота, МГц')), - ('transfer_frequency', models.GeneratedField(db_persist=True, expression=models.ExpressionWrapper(django.db.models.expressions.CombinedExpression(models.F('frequency'), '+', models.F('transfer')), output_field=models.FloatField()), null=True, output_field=models.FloatField(), verbose_name='Частота в Ku, МГц')), - ('freq_range', models.FloatField(blank=True, default=0, null=True, verbose_name='Полоса частот, МГц')), - ('power', models.FloatField(blank=True, default=0, null=True, verbose_name='Мощность, дБм')), - ('bod_velocity', models.FloatField(blank=True, default=0, null=True, verbose_name='Символьная скорость, БОД')), - ('snr', models.FloatField(blank=True, default=0, null=True, verbose_name='ОСШ, Дб')), - ('packets', models.BooleanField(blank=True, null=True, verbose_name='Пакетность')), - ('datetime_begin', models.DateTimeField(blank=True, null=True, verbose_name='Время начала измерения')), - ('datetime_end', models.DateTimeField(blank=True, null=True, verbose_name='Время окончания измерения')), - ('id_satellite', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sigmapar_sat', to='mainapp.satellite', verbose_name='Спутник')), - ('modulation', models.ForeignKey(blank=True, default=mainapp.models.get_default_modulation, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='modulations_sigma', to='mainapp.modulation', verbose_name='Модуляция')), - ('parameter', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sigma_parameter', to='mainapp.parameter', verbose_name='ВЧ')), - ('polarization', models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='polarizations_sigma', to='mainapp.polarization', verbose_name='Поляризация')), - ('mark', models.ManyToManyField(blank=True, to='mainapp.sigmaparmark', verbose_name='Отметка')), - ('standard', models.ForeignKey(blank=True, default=mainapp.models.get_default_standard, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='standards_sigma', to='mainapp.standard', verbose_name='Стандарт')), - ], - options={ - 'verbose_name': 'ВЧ sigma', - 'verbose_name_plural': 'ВЧ sigma', - }, - ), - migrations.CreateModel( - name='Geo', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('timestamp', models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Время')), - ('coords', django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326, verbose_name='Координата геолокации')), - ('location', models.CharField(blank=True, max_length=255, null=True, verbose_name='Метоположение')), - ('comment', models.CharField(blank=True, max_length=255, verbose_name='Комментарий')), - ('coords_kupsat', django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326, verbose_name='Координаты Кубсата')), - ('coords_valid', django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326, verbose_name='Координаты оперативников')), - ('is_average', models.BooleanField(blank=True, null=True, verbose_name='Усреднённое')), - ('distance_coords_kup', models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.contrib.gis.db.models.functions.Distance('coords', 'coords_kupsat'), '/', models.Value(1000)), null=True, output_field=models.FloatField(), verbose_name='Расстояние между купсатом и гео, км')), - ('distance_coords_valid', models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.contrib.gis.db.models.functions.Distance('coords', 'coords_valid'), '/', models.Value(1000)), null=True, output_field=models.FloatField(), verbose_name='Расстояние между гео и оперативным отделом, км')), - ('distance_kup_valid', models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.contrib.gis.db.models.functions.Distance('coords_valid', 'coords_kupsat'), '/', models.Value(1000)), null=True, output_field=models.FloatField(), verbose_name='Расстояние между купсатом и оперативным отделом, км')), - ('id_user_add', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='geos_added', to='mainapp.customuser', verbose_name='Пользователь')), - ('mirrors', models.ManyToManyField(related_name='geo_mirrors', to='mainapp.mirror', verbose_name='Зеркала')), - ('objitem', models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='geo_obj', to='mainapp.objitem', verbose_name='Гео')), - ], - options={ - 'verbose_name': 'Гео', - 'verbose_name_plural': 'Гео', - 'constraints': [models.UniqueConstraint(fields=('timestamp', 'coords'), name='unique_geo_combination')], - }, - ), - migrations.AddIndex( - model_name='parameter', - index=models.Index(fields=['id_satellite', 'frequency'], name='mainapp_par_id_sate_cbfab2_idx'), - ), - migrations.AddIndex( - model_name='parameter', - index=models.Index(fields=['frequency', 'polarization'], name='mainapp_par_frequen_75a049_idx'), - ), - ] +# Generated by Django 5.2.7 on 2025-10-31 13:36 + +import django.contrib.gis.db.models.fields +import django.contrib.gis.db.models.functions +import django.db.models.deletion +import django.db.models.expressions +import mainapp.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Mirror', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=30, unique=True, verbose_name='Имя зеркала')), + ], + options={ + 'verbose_name': 'Зеркало', + 'verbose_name_plural': 'Зеркала', + }, + ), + migrations.CreateModel( + name='Modulation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(db_index=True, max_length=20, unique=True, verbose_name='Модуляция')), + ], + options={ + 'verbose_name': 'Модуляция', + 'verbose_name_plural': 'Модуляции', + }, + ), + migrations.CreateModel( + name='Polarization', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=20, unique=True, verbose_name='Поляризация')), + ], + options={ + 'verbose_name': 'Поляризация', + 'verbose_name_plural': 'Поляризация', + }, + ), + migrations.CreateModel( + name='Satellite', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(db_index=True, max_length=100, unique=True, verbose_name='Имя спутника')), + ('norad', models.IntegerField(blank=True, null=True, verbose_name='NORAD ID')), + ], + options={ + 'verbose_name': 'Спутник', + 'verbose_name_plural': 'Спутники', + }, + ), + migrations.CreateModel( + name='SigmaParMark', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('mark', models.BooleanField(blank=True, null=True, verbose_name='Наличие сигнала')), + ('timestamp', models.DateTimeField(blank=True, null=True, verbose_name='Время')), + ], + options={ + 'verbose_name': 'Отметка', + 'verbose_name_plural': 'Отметки', + }, + ), + migrations.CreateModel( + name='Standard', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=20, unique=True, verbose_name='Стандарт')), + ], + options={ + 'verbose_name': 'Стандарт', + 'verbose_name_plural': 'Стандарты', + }, + ), + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(choices=[('admin', 'Администратор'), ('moderator', 'Модератор'), ('user', 'Пользователь')], default='user', max_length=20, verbose_name='Роль пользователя')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Пользователь', + 'verbose_name_plural': 'Пользователи', + }, + ), + migrations.CreateModel( + name='ObjItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(blank=True, db_index=True, max_length=100, null=True, verbose_name='Имя объекта')), + ('id_user_add', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objitems', to='mainapp.customuser', verbose_name='Пользователь')), + ], + options={ + 'verbose_name': 'Объект', + 'verbose_name_plural': 'Объекты', + }, + ), + migrations.CreateModel( + name='Parameter', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('frequency', models.FloatField(blank=True, db_index=True, default=0, null=True, verbose_name='Частота, МГц')), + ('freq_range', models.FloatField(blank=True, default=0, null=True, verbose_name='Полоса частот, МГц')), + ('bod_velocity', models.FloatField(blank=True, default=0, null=True, verbose_name='Символьная скорость, БОД')), + ('snr', models.FloatField(blank=True, default=0, null=True, verbose_name='ОСШ')), + ('id_user_add', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='parameter_added', to='mainapp.customuser', verbose_name='Пользователь')), + ('modulation', models.ForeignKey(blank=True, default=mainapp.models.get_default_modulation, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='modulations', to='mainapp.modulation', verbose_name='Модуляция')), + ('objitems', models.ManyToManyField(blank=True, related_name='parameters_obj', to='mainapp.objitem', verbose_name='Источники')), + ('polarization', models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='polarizations', to='mainapp.polarization', verbose_name='Поляризация')), + ('id_satellite', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='parameters', to='mainapp.satellite', verbose_name='Спутник')), + ('standard', models.ForeignKey(blank=True, default=mainapp.models.get_default_standard, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='standards', to='mainapp.standard', verbose_name='Стандарт')), + ], + options={ + 'verbose_name': 'ВЧ загрузка', + 'verbose_name_plural': 'ВЧ загрузки', + }, + ), + migrations.CreateModel( + name='SourceType', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, unique=True, verbose_name='Тип источника')), + ('objitem', models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_type_obj', to='mainapp.objitem', verbose_name='Гео')), + ], + options={ + 'verbose_name': 'Тип источника', + 'verbose_name_plural': 'Типы источников', + }, + ), + migrations.CreateModel( + name='SigmaParameter', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('transfer', models.FloatField(choices=[(-1.0, '-'), (9750.0, '9750 МГц'), (10750.0, '10750 МГц')], default=-1.0, verbose_name='Перенос по частоте')), + ('status', models.CharField(blank=True, max_length=20, null=True, verbose_name='Статус')), + ('frequency', models.FloatField(blank=True, db_index=True, default=0, null=True, verbose_name='Частота, МГц')), + ('transfer_frequency', models.GeneratedField(db_persist=True, expression=models.ExpressionWrapper(django.db.models.expressions.CombinedExpression(models.F('frequency'), '+', models.F('transfer')), output_field=models.FloatField()), null=True, output_field=models.FloatField(), verbose_name='Частота в Ku, МГц')), + ('freq_range', models.FloatField(blank=True, default=0, null=True, verbose_name='Полоса частот, МГц')), + ('power', models.FloatField(blank=True, default=0, null=True, verbose_name='Мощность, дБм')), + ('bod_velocity', models.FloatField(blank=True, default=0, null=True, verbose_name='Символьная скорость, БОД')), + ('snr', models.FloatField(blank=True, default=0, null=True, verbose_name='ОСШ, Дб')), + ('packets', models.BooleanField(blank=True, null=True, verbose_name='Пакетность')), + ('datetime_begin', models.DateTimeField(blank=True, null=True, verbose_name='Время начала измерения')), + ('datetime_end', models.DateTimeField(blank=True, null=True, verbose_name='Время окончания измерения')), + ('id_satellite', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sigmapar_sat', to='mainapp.satellite', verbose_name='Спутник')), + ('modulation', models.ForeignKey(blank=True, default=mainapp.models.get_default_modulation, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='modulations_sigma', to='mainapp.modulation', verbose_name='Модуляция')), + ('parameter', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sigma_parameter', to='mainapp.parameter', verbose_name='ВЧ')), + ('polarization', models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='polarizations_sigma', to='mainapp.polarization', verbose_name='Поляризация')), + ('mark', models.ManyToManyField(blank=True, to='mainapp.sigmaparmark', verbose_name='Отметка')), + ('standard', models.ForeignKey(blank=True, default=mainapp.models.get_default_standard, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='standards_sigma', to='mainapp.standard', verbose_name='Стандарт')), + ], + options={ + 'verbose_name': 'ВЧ sigma', + 'verbose_name_plural': 'ВЧ sigma', + }, + ), + migrations.CreateModel( + name='Geo', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('timestamp', models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Время')), + ('coords', django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326, verbose_name='Координата геолокации')), + ('location', models.CharField(blank=True, max_length=255, null=True, verbose_name='Метоположение')), + ('comment', models.CharField(blank=True, max_length=255, verbose_name='Комментарий')), + ('coords_kupsat', django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326, verbose_name='Координаты Кубсата')), + ('coords_valid', django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326, verbose_name='Координаты оперативников')), + ('is_average', models.BooleanField(blank=True, null=True, verbose_name='Усреднённое')), + ('distance_coords_kup', models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.contrib.gis.db.models.functions.Distance('coords', 'coords_kupsat'), '/', models.Value(1000)), null=True, output_field=models.FloatField(), verbose_name='Расстояние между купсатом и гео, км')), + ('distance_coords_valid', models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.contrib.gis.db.models.functions.Distance('coords', 'coords_valid'), '/', models.Value(1000)), null=True, output_field=models.FloatField(), verbose_name='Расстояние между гео и оперативным отделом, км')), + ('distance_kup_valid', models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.contrib.gis.db.models.functions.Distance('coords_valid', 'coords_kupsat'), '/', models.Value(1000)), null=True, output_field=models.FloatField(), verbose_name='Расстояние между купсатом и оперативным отделом, км')), + ('id_user_add', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='geos_added', to='mainapp.customuser', verbose_name='Пользователь')), + ('mirrors', models.ManyToManyField(related_name='geo_mirrors', to='mainapp.mirror', verbose_name='Зеркала')), + ('objitem', models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='geo_obj', to='mainapp.objitem', verbose_name='Гео')), + ], + options={ + 'verbose_name': 'Гео', + 'verbose_name_plural': 'Гео', + 'constraints': [models.UniqueConstraint(fields=('timestamp', 'coords'), name='unique_geo_combination')], + }, + ), + migrations.AddIndex( + model_name='parameter', + index=models.Index(fields=['id_satellite', 'frequency'], name='mainapp_par_id_sate_cbfab2_idx'), + ), + migrations.AddIndex( + model_name='parameter', + index=models.Index(fields=['frequency', 'polarization'], name='mainapp_par_frequen_75a049_idx'), + ), + ] diff --git a/dbapp/mainapp/migrations/0002_objitem_created_at_objitem_created_by_and_more.py b/dbapp/mainapp/migrations/0002_objitem_created_at_objitem_created_by_and_more.py index 8669b50..218c9df 100644 --- a/dbapp/mainapp/migrations/0002_objitem_created_at_objitem_created_by_and_more.py +++ b/dbapp/mainapp/migrations/0002_objitem_created_at_objitem_created_by_and_more.py @@ -1,35 +1,35 @@ -# Generated by Django 5.2.7 on 2025-10-31 13:56 - -import django.db.models.deletion -import django.utils.timezone -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('mainapp', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='objitem', - name='created_at', - field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='Дата создания'), - ), - migrations.AddField( - model_name='objitem', - name='created_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objitems_created', to='mainapp.customuser', verbose_name='Создан пользователем'), - ), - migrations.AddField( - model_name='objitem', - name='updated_at', - field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='Дата последнего изменения'), - ), - migrations.AddField( - model_name='objitem', - name='updated_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objitems_updated', to='mainapp.customuser', verbose_name='Изменен пользователем'), - ), - ] +# Generated by Django 5.2.7 on 2025-10-31 13:56 + +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mainapp', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='objitem', + name='created_at', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='Дата создания'), + ), + migrations.AddField( + model_name='objitem', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objitems_created', to='mainapp.customuser', verbose_name='Создан пользователем'), + ), + migrations.AddField( + model_name='objitem', + name='updated_at', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='Дата последнего изменения'), + ), + migrations.AddField( + model_name='objitem', + name='updated_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objitems_updated', to='mainapp.customuser', verbose_name='Изменен пользователем'), + ), + ] diff --git a/dbapp/mainapp/migrations/0003_alter_objitem_created_at_alter_objitem_updated_at.py b/dbapp/mainapp/migrations/0003_alter_objitem_created_at_alter_objitem_updated_at.py index 2503260..3302785 100644 --- a/dbapp/mainapp/migrations/0003_alter_objitem_created_at_alter_objitem_updated_at.py +++ b/dbapp/mainapp/migrations/0003_alter_objitem_created_at_alter_objitem_updated_at.py @@ -1,23 +1,23 @@ -# Generated by Django 5.2.7 on 2025-10-31 14:02 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('mainapp', '0002_objitem_created_at_objitem_created_by_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='objitem', - name='created_at', - field=models.DateTimeField(auto_now_add=True, verbose_name='Дата создания'), - ), - migrations.AlterField( - model_name='objitem', - name='updated_at', - field=models.DateTimeField(auto_now=True, verbose_name='Дата последнего изменения'), - ), - ] +# Generated by Django 5.2.7 on 2025-10-31 14:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mainapp', '0002_objitem_created_at_objitem_created_by_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='objitem', + name='created_at', + field=models.DateTimeField(auto_now_add=True, verbose_name='Дата создания'), + ), + migrations.AlterField( + model_name='objitem', + name='updated_at', + field=models.DateTimeField(auto_now=True, verbose_name='Дата последнего изменения'), + ), + ] diff --git a/dbapp/mainapp/migrations/0004_remove_geo_id_user_add_remove_objitem_id_user_add_and_more.py b/dbapp/mainapp/migrations/0004_remove_geo_id_user_add_remove_objitem_id_user_add_and_more.py index c4bdb1c..cc6cc39 100644 --- a/dbapp/mainapp/migrations/0004_remove_geo_id_user_add_remove_objitem_id_user_add_and_more.py +++ b/dbapp/mainapp/migrations/0004_remove_geo_id_user_add_remove_objitem_id_user_add_and_more.py @@ -1,25 +1,25 @@ -# Generated by Django 5.2.7 on 2025-11-01 07:38 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('mainapp', '0003_alter_objitem_created_at_alter_objitem_updated_at'), - ] - - operations = [ - migrations.RemoveField( - model_name='geo', - name='id_user_add', - ), - migrations.RemoveField( - model_name='objitem', - name='id_user_add', - ), - migrations.RemoveField( - model_name='parameter', - name='id_user_add', - ), - ] +# Generated by Django 5.2.7 on 2025-11-01 07:38 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('mainapp', '0003_alter_objitem_created_at_alter_objitem_updated_at'), + ] + + operations = [ + migrations.RemoveField( + model_name='geo', + name='id_user_add', + ), + migrations.RemoveField( + model_name='objitem', + name='id_user_add', + ), + migrations.RemoveField( + model_name='parameter', + name='id_user_add', + ), + ] diff --git a/dbapp/mainapp/migrations/0005_alter_geo_objitem.py b/dbapp/mainapp/migrations/0005_alter_geo_objitem.py index 301b5d1..db28ddf 100644 --- a/dbapp/mainapp/migrations/0005_alter_geo_objitem.py +++ b/dbapp/mainapp/migrations/0005_alter_geo_objitem.py @@ -1,19 +1,19 @@ -# Generated by Django 5.2.7 on 2025-11-07 19:35 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('mainapp', '0004_remove_geo_id_user_add_remove_objitem_id_user_add_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='geo', - name='objitem', - field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='geo_obj', to='mainapp.objitem', verbose_name='Гео'), - ), - ] +# Generated by Django 5.2.7 on 2025-11-07 19:35 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mainapp', '0004_remove_geo_id_user_add_remove_objitem_id_user_add_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='geo', + name='objitem', + field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='geo_obj', to='mainapp.objitem', verbose_name='Гео'), + ), + ] diff --git a/dbapp/mainapp/migrations/0006_alter_customuser_options_alter_geo_options_and_more.py b/dbapp/mainapp/migrations/0006_alter_customuser_options_alter_geo_options_and_more.py index 4bc8fd4..61a0af0 100644 --- a/dbapp/mainapp/migrations/0006_alter_customuser_options_alter_geo_options_and_more.py +++ b/dbapp/mainapp/migrations/0006_alter_customuser_options_alter_geo_options_and_more.py @@ -1,290 +1,290 @@ -# Generated by Django 5.2.7 on 2025-11-07 20:58 - -import django.contrib.gis.db.models.fields -import django.contrib.gis.db.models.functions -import django.core.validators -import django.db.models.deletion -import django.db.models.expressions -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('mainapp', '0005_alter_geo_objitem'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AlterModelOptions( - name='customuser', - options={'ordering': ['user__username'], 'verbose_name': 'Пользователь', 'verbose_name_plural': 'Пользователи'}, - ), - migrations.AlterModelOptions( - name='geo', - options={'ordering': ['-timestamp'], 'verbose_name': 'Гео', 'verbose_name_plural': 'Гео'}, - ), - migrations.AlterModelOptions( - name='mirror', - options={'ordering': ['name'], 'verbose_name': 'Зеркало', 'verbose_name_plural': 'Зеркала'}, - ), - migrations.AlterModelOptions( - name='modulation', - options={'ordering': ['name'], 'verbose_name': 'Модуляция', 'verbose_name_plural': 'Модуляции'}, - ), - migrations.AlterModelOptions( - name='objitem', - options={'ordering': ['-updated_at'], 'verbose_name': 'Объект', 'verbose_name_plural': 'Объекты'}, - ), - migrations.AlterModelOptions( - name='polarization', - options={'ordering': ['name'], 'verbose_name': 'Поляризация', 'verbose_name_plural': 'Поляризация'}, - ), - migrations.AlterModelOptions( - name='satellite', - options={'ordering': ['name'], 'verbose_name': 'Спутник', 'verbose_name_plural': 'Спутники'}, - ), - migrations.AlterModelOptions( - name='sigmaparmark', - options={'ordering': ['-timestamp'], 'verbose_name': 'Отметка', 'verbose_name_plural': 'Отметки'}, - ), - migrations.AlterModelOptions( - name='sourcetype', - options={'ordering': ['name'], 'verbose_name': 'Тип источника', 'verbose_name_plural': 'Типы источников'}, - ), - migrations.AlterModelOptions( - name='standard', - options={'ordering': ['name'], 'verbose_name': 'Стандарт', 'verbose_name_plural': 'Стандарты'}, - ), - migrations.AlterField( - model_name='customuser', - name='role', - field=models.CharField(choices=[('admin', 'Администратор'), ('moderator', 'Модератор'), ('user', 'Пользователь')], db_index=True, default='user', help_text='Роль пользователя в системе', max_length=20, verbose_name='Роль пользователя'), - ), - migrations.AlterField( - model_name='customuser', - name='user', - field=models.OneToOneField(help_text='Связанный пользователь Django', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Пользователь'), - ), - migrations.AlterField( - model_name='geo', - name='comment', - field=models.CharField(blank=True, help_text='Дополнительные комментарии', max_length=255, verbose_name='Комментарий'), - ), - migrations.AlterField( - model_name='geo', - name='coords', - field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Основные координаты геолокации (WGS84)', null=True, srid=4326, verbose_name='Координата геолокации'), - ), - migrations.AlterField( - model_name='geo', - name='coords_kupsat', - field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты, полученные от кубсата (WGS84)', null=True, srid=4326, verbose_name='Координаты Кубсата'), - ), - migrations.AlterField( - model_name='geo', - name='coords_valid', - field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты, предоставленные оперативным отделом (WGS84)', null=True, srid=4326, verbose_name='Координаты оперативников'), - ), - migrations.AlterField( - model_name='geo', - name='distance_coords_kup', - field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.contrib.gis.db.models.functions.Distance('coords', 'coords_kupsat'), '/', models.Value(1000)), null=True, output_field=models.FloatField(), verbose_name='Расстояние между кубсатом и гео, км'), - ), - migrations.AlterField( - model_name='geo', - name='distance_kup_valid', - field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.contrib.gis.db.models.functions.Distance('coords_valid', 'coords_kupsat'), '/', models.Value(1000)), null=True, output_field=models.FloatField(), verbose_name='Расстояние между кубсатом и оперативным отделом, км'), - ), - migrations.AlterField( - model_name='geo', - name='is_average', - field=models.BooleanField(blank=True, help_text='Является ли координата усредненной', null=True, verbose_name='Усреднённое'), - ), - migrations.AlterField( - model_name='geo', - name='location', - field=models.CharField(blank=True, help_text='Текстовое описание местоположения', max_length=255, null=True, verbose_name='Местоположение'), - ), - migrations.AlterField( - model_name='geo', - name='mirrors', - field=models.ManyToManyField(blank=True, help_text='Зеркала антенн, использованные для приема', related_name='geo_mirrors', to='mainapp.mirror', verbose_name='Зеркала'), - ), - migrations.AlterField( - model_name='geo', - name='objitem', - field=models.OneToOneField(help_text='Связанный объект', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='geo_obj', to='mainapp.objitem', verbose_name='Объект'), - ), - migrations.AlterField( - model_name='geo', - name='timestamp', - field=models.DateTimeField(blank=True, db_index=True, help_text='Время фиксации геолокации', null=True, verbose_name='Время'), - ), - migrations.AlterField( - model_name='mirror', - name='name', - field=models.CharField(db_index=True, help_text='Уникальное название зеркала антенны', max_length=30, unique=True, verbose_name='Имя зеркала'), - ), - migrations.AlterField( - model_name='modulation', - name='name', - field=models.CharField(db_index=True, help_text='Тип модуляции сигнала (QPSK, 8PSK, 16APSK и т.д.)', max_length=20, unique=True, verbose_name='Модуляция'), - ), - migrations.AlterField( - model_name='objitem', - name='created_at', - field=models.DateTimeField(auto_now_add=True, help_text='Дата и время создания записи', verbose_name='Дата создания'), - ), - migrations.AlterField( - model_name='objitem', - name='created_by', - field=models.ForeignKey(blank=True, help_text='Пользователь, создавший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objitems_created', to='mainapp.customuser', verbose_name='Создан пользователем'), - ), - migrations.AlterField( - model_name='objitem', - name='name', - field=models.CharField(blank=True, db_index=True, help_text='Название объекта/источника сигнала', max_length=100, null=True, verbose_name='Имя объекта'), - ), - migrations.AlterField( - model_name='objitem', - name='updated_at', - field=models.DateTimeField(auto_now=True, help_text='Дата и время последнего изменения', verbose_name='Дата последнего изменения'), - ), - migrations.AlterField( - model_name='objitem', - name='updated_by', - field=models.ForeignKey(blank=True, help_text='Пользователь, последним изменивший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objitems_updated', to='mainapp.customuser', verbose_name='Изменен пользователем'), - ), - migrations.AlterField( - model_name='parameter', - name='bod_velocity', - field=models.FloatField(blank=True, default=0, help_text='Символьная скорость должна быть положительной', null=True, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Символьная скорость, БОД'), - ), - migrations.AlterField( - model_name='parameter', - name='freq_range', - field=models.FloatField(blank=True, default=0, help_text='Полоса частот в диапазоне от 0 до 1000 МГц', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1000)], verbose_name='Полоса частот, МГц'), - ), - migrations.AlterField( - model_name='parameter', - name='frequency', - field=models.FloatField(blank=True, db_index=True, default=0, help_text='Частота в диапазоне от 0 до 50000 МГц', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(50000)], verbose_name='Частота, МГц'), - ), - migrations.AlterField( - model_name='parameter', - name='snr', - field=models.FloatField(blank=True, default=0, help_text='Отношение сигнал/шум в диапазоне от -50 до 100 дБ', null=True, validators=[django.core.validators.MinValueValidator(-50), django.core.validators.MaxValueValidator(100)], verbose_name='ОСШ'), - ), - migrations.AlterField( - model_name='polarization', - name='name', - field=models.CharField(db_index=True, help_text='Тип поляризации (H - горизонтальная, V - вертикальная, L - левая круговая, R - правая круговая)', max_length=20, unique=True, verbose_name='Поляризация'), - ), - migrations.AlterField( - model_name='satellite', - name='name', - field=models.CharField(db_index=True, help_text='Название спутника', max_length=100, unique=True, verbose_name='Имя спутника'), - ), - migrations.AlterField( - model_name='satellite', - name='norad', - field=models.IntegerField(blank=True, help_text='Идентификатор NORAD для отслеживания спутника', null=True, verbose_name='NORAD ID'), - ), - migrations.AlterField( - model_name='sigmaparameter', - name='bod_velocity', - field=models.FloatField(blank=True, default=0, help_text='Символьная скорость должна быть положительной', null=True, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Символьная скорость, БОД'), - ), - migrations.AlterField( - model_name='sigmaparameter', - name='datetime_begin', - field=models.DateTimeField(blank=True, help_text='Дата и время начала измерения', null=True, verbose_name='Время начала измерения'), - ), - migrations.AlterField( - model_name='sigmaparameter', - name='datetime_end', - field=models.DateTimeField(blank=True, help_text='Дата и время окончания измерения', null=True, verbose_name='Время окончания измерения'), - ), - migrations.AlterField( - model_name='sigmaparameter', - name='freq_range', - field=models.FloatField(blank=True, default=0, help_text='Полоса частот в диапазоне от 0 до 1000 МГц', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1000)], verbose_name='Полоса частот, МГц'), - ), - migrations.AlterField( - model_name='sigmaparameter', - name='frequency', - field=models.FloatField(blank=True, db_index=True, default=0, help_text='Частота в диапазоне от 0 до 50000 МГц', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(50000)], verbose_name='Частота, МГц'), - ), - migrations.AlterField( - model_name='sigmaparameter', - name='packets', - field=models.BooleanField(blank=True, help_text='Наличие пакетной передачи', null=True, verbose_name='Пакетность'), - ), - migrations.AlterField( - model_name='sigmaparameter', - name='power', - field=models.FloatField(blank=True, default=0, help_text='Мощность сигнала в диапазоне от -100 до 100 дБм', null=True, validators=[django.core.validators.MinValueValidator(-100), django.core.validators.MaxValueValidator(100)], verbose_name='Мощность, дБм'), - ), - migrations.AlterField( - model_name='sigmaparameter', - name='snr', - field=models.FloatField(blank=True, default=0, help_text='Отношение сигнал/шум в диапазоне от -50 до 100 дБ', null=True, validators=[django.core.validators.MinValueValidator(-50), django.core.validators.MaxValueValidator(100)], verbose_name='ОСШ, Дб'), - ), - migrations.AlterField( - model_name='sigmaparameter', - name='status', - field=models.CharField(blank=True, help_text='Статус измерения', max_length=20, null=True, verbose_name='Статус'), - ), - migrations.AlterField( - model_name='sigmaparameter', - name='transfer', - field=models.FloatField(choices=[(-1.0, '-'), (9750.0, '9750 МГц'), (10750.0, '10750 МГц')], default=-1.0, help_text='Выберите перенос по частоте', verbose_name='Перенос по частоте'), - ), - migrations.AlterField( - model_name='sigmaparmark', - name='mark', - field=models.BooleanField(blank=True, help_text='True - сигнал обнаружен, False - сигнал отсутствует', null=True, verbose_name='Наличие сигнала'), - ), - migrations.AlterField( - model_name='sigmaparmark', - name='timestamp', - field=models.DateTimeField(blank=True, db_index=True, help_text='Время фиксации отметки', null=True, verbose_name='Время'), - ), - migrations.AlterField( - model_name='sourcetype', - name='name', - field=models.CharField(db_index=True, help_text='Тип источника сигнала', max_length=50, unique=True, verbose_name='Тип источника'), - ), - migrations.AlterField( - model_name='sourcetype', - name='objitem', - field=models.OneToOneField(help_text='Связанный объект', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_type_obj', to='mainapp.objitem', verbose_name='Объект'), - ), - migrations.AlterField( - model_name='standard', - name='name', - field=models.CharField(db_index=True, help_text='Стандарт передачи данных (DVB-S, DVB-S2, DVB-S2X и т.д.)', max_length=20, unique=True, verbose_name='Стандарт'), - ), - migrations.AddIndex( - model_name='geo', - index=models.Index(fields=['-timestamp'], name='mainapp_geo_timesta_58a605_idx'), - ), - migrations.AddIndex( - model_name='geo', - index=models.Index(fields=['location'], name='mainapp_geo_locatio_b855c9_idx'), - ), - migrations.AddIndex( - model_name='objitem', - index=models.Index(fields=['name'], name='mainapp_obj_name_e4f1e1_idx'), - ), - migrations.AddIndex( - model_name='objitem', - index=models.Index(fields=['-updated_at'], name='mainapp_obj_updated_f46b0e_idx'), - ), - migrations.AddIndex( - model_name='objitem', - index=models.Index(fields=['-created_at'], name='mainapp_obj_created_cba553_idx'), - ), - ] +# Generated by Django 5.2.7 on 2025-11-07 20:58 + +import django.contrib.gis.db.models.fields +import django.contrib.gis.db.models.functions +import django.core.validators +import django.db.models.deletion +import django.db.models.expressions +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mainapp', '0005_alter_geo_objitem'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterModelOptions( + name='customuser', + options={'ordering': ['user__username'], 'verbose_name': 'Пользователь', 'verbose_name_plural': 'Пользователи'}, + ), + migrations.AlterModelOptions( + name='geo', + options={'ordering': ['-timestamp'], 'verbose_name': 'Гео', 'verbose_name_plural': 'Гео'}, + ), + migrations.AlterModelOptions( + name='mirror', + options={'ordering': ['name'], 'verbose_name': 'Зеркало', 'verbose_name_plural': 'Зеркала'}, + ), + migrations.AlterModelOptions( + name='modulation', + options={'ordering': ['name'], 'verbose_name': 'Модуляция', 'verbose_name_plural': 'Модуляции'}, + ), + migrations.AlterModelOptions( + name='objitem', + options={'ordering': ['-updated_at'], 'verbose_name': 'Объект', 'verbose_name_plural': 'Объекты'}, + ), + migrations.AlterModelOptions( + name='polarization', + options={'ordering': ['name'], 'verbose_name': 'Поляризация', 'verbose_name_plural': 'Поляризация'}, + ), + migrations.AlterModelOptions( + name='satellite', + options={'ordering': ['name'], 'verbose_name': 'Спутник', 'verbose_name_plural': 'Спутники'}, + ), + migrations.AlterModelOptions( + name='sigmaparmark', + options={'ordering': ['-timestamp'], 'verbose_name': 'Отметка', 'verbose_name_plural': 'Отметки'}, + ), + migrations.AlterModelOptions( + name='sourcetype', + options={'ordering': ['name'], 'verbose_name': 'Тип источника', 'verbose_name_plural': 'Типы источников'}, + ), + migrations.AlterModelOptions( + name='standard', + options={'ordering': ['name'], 'verbose_name': 'Стандарт', 'verbose_name_plural': 'Стандарты'}, + ), + migrations.AlterField( + model_name='customuser', + name='role', + field=models.CharField(choices=[('admin', 'Администратор'), ('moderator', 'Модератор'), ('user', 'Пользователь')], db_index=True, default='user', help_text='Роль пользователя в системе', max_length=20, verbose_name='Роль пользователя'), + ), + migrations.AlterField( + model_name='customuser', + name='user', + field=models.OneToOneField(help_text='Связанный пользователь Django', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Пользователь'), + ), + migrations.AlterField( + model_name='geo', + name='comment', + field=models.CharField(blank=True, help_text='Дополнительные комментарии', max_length=255, verbose_name='Комментарий'), + ), + migrations.AlterField( + model_name='geo', + name='coords', + field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Основные координаты геолокации (WGS84)', null=True, srid=4326, verbose_name='Координата геолокации'), + ), + migrations.AlterField( + model_name='geo', + name='coords_kupsat', + field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты, полученные от кубсата (WGS84)', null=True, srid=4326, verbose_name='Координаты Кубсата'), + ), + migrations.AlterField( + model_name='geo', + name='coords_valid', + field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты, предоставленные оперативным отделом (WGS84)', null=True, srid=4326, verbose_name='Координаты оперативников'), + ), + migrations.AlterField( + model_name='geo', + name='distance_coords_kup', + field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.contrib.gis.db.models.functions.Distance('coords', 'coords_kupsat'), '/', models.Value(1000)), null=True, output_field=models.FloatField(), verbose_name='Расстояние между кубсатом и гео, км'), + ), + migrations.AlterField( + model_name='geo', + name='distance_kup_valid', + field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.contrib.gis.db.models.functions.Distance('coords_valid', 'coords_kupsat'), '/', models.Value(1000)), null=True, output_field=models.FloatField(), verbose_name='Расстояние между кубсатом и оперативным отделом, км'), + ), + migrations.AlterField( + model_name='geo', + name='is_average', + field=models.BooleanField(blank=True, help_text='Является ли координата усредненной', null=True, verbose_name='Усреднённое'), + ), + migrations.AlterField( + model_name='geo', + name='location', + field=models.CharField(blank=True, help_text='Текстовое описание местоположения', max_length=255, null=True, verbose_name='Местоположение'), + ), + migrations.AlterField( + model_name='geo', + name='mirrors', + field=models.ManyToManyField(blank=True, help_text='Зеркала антенн, использованные для приема', related_name='geo_mirrors', to='mainapp.mirror', verbose_name='Зеркала'), + ), + migrations.AlterField( + model_name='geo', + name='objitem', + field=models.OneToOneField(help_text='Связанный объект', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='geo_obj', to='mainapp.objitem', verbose_name='Объект'), + ), + migrations.AlterField( + model_name='geo', + name='timestamp', + field=models.DateTimeField(blank=True, db_index=True, help_text='Время фиксации геолокации', null=True, verbose_name='Время'), + ), + migrations.AlterField( + model_name='mirror', + name='name', + field=models.CharField(db_index=True, help_text='Уникальное название зеркала антенны', max_length=30, unique=True, verbose_name='Имя зеркала'), + ), + migrations.AlterField( + model_name='modulation', + name='name', + field=models.CharField(db_index=True, help_text='Тип модуляции сигнала (QPSK, 8PSK, 16APSK и т.д.)', max_length=20, unique=True, verbose_name='Модуляция'), + ), + migrations.AlterField( + model_name='objitem', + name='created_at', + field=models.DateTimeField(auto_now_add=True, help_text='Дата и время создания записи', verbose_name='Дата создания'), + ), + migrations.AlterField( + model_name='objitem', + name='created_by', + field=models.ForeignKey(blank=True, help_text='Пользователь, создавший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objitems_created', to='mainapp.customuser', verbose_name='Создан пользователем'), + ), + migrations.AlterField( + model_name='objitem', + name='name', + field=models.CharField(blank=True, db_index=True, help_text='Название объекта/источника сигнала', max_length=100, null=True, verbose_name='Имя объекта'), + ), + migrations.AlterField( + model_name='objitem', + name='updated_at', + field=models.DateTimeField(auto_now=True, help_text='Дата и время последнего изменения', verbose_name='Дата последнего изменения'), + ), + migrations.AlterField( + model_name='objitem', + name='updated_by', + field=models.ForeignKey(blank=True, help_text='Пользователь, последним изменивший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objitems_updated', to='mainapp.customuser', verbose_name='Изменен пользователем'), + ), + migrations.AlterField( + model_name='parameter', + name='bod_velocity', + field=models.FloatField(blank=True, default=0, help_text='Символьная скорость должна быть положительной', null=True, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Символьная скорость, БОД'), + ), + migrations.AlterField( + model_name='parameter', + name='freq_range', + field=models.FloatField(blank=True, default=0, help_text='Полоса частот в диапазоне от 0 до 1000 МГц', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1000)], verbose_name='Полоса частот, МГц'), + ), + migrations.AlterField( + model_name='parameter', + name='frequency', + field=models.FloatField(blank=True, db_index=True, default=0, help_text='Частота в диапазоне от 0 до 50000 МГц', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(50000)], verbose_name='Частота, МГц'), + ), + migrations.AlterField( + model_name='parameter', + name='snr', + field=models.FloatField(blank=True, default=0, help_text='Отношение сигнал/шум в диапазоне от -50 до 100 дБ', null=True, validators=[django.core.validators.MinValueValidator(-50), django.core.validators.MaxValueValidator(100)], verbose_name='ОСШ'), + ), + migrations.AlterField( + model_name='polarization', + name='name', + field=models.CharField(db_index=True, help_text='Тип поляризации (H - горизонтальная, V - вертикальная, L - левая круговая, R - правая круговая)', max_length=20, unique=True, verbose_name='Поляризация'), + ), + migrations.AlterField( + model_name='satellite', + name='name', + field=models.CharField(db_index=True, help_text='Название спутника', max_length=100, unique=True, verbose_name='Имя спутника'), + ), + migrations.AlterField( + model_name='satellite', + name='norad', + field=models.IntegerField(blank=True, help_text='Идентификатор NORAD для отслеживания спутника', null=True, verbose_name='NORAD ID'), + ), + migrations.AlterField( + model_name='sigmaparameter', + name='bod_velocity', + field=models.FloatField(blank=True, default=0, help_text='Символьная скорость должна быть положительной', null=True, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Символьная скорость, БОД'), + ), + migrations.AlterField( + model_name='sigmaparameter', + name='datetime_begin', + field=models.DateTimeField(blank=True, help_text='Дата и время начала измерения', null=True, verbose_name='Время начала измерения'), + ), + migrations.AlterField( + model_name='sigmaparameter', + name='datetime_end', + field=models.DateTimeField(blank=True, help_text='Дата и время окончания измерения', null=True, verbose_name='Время окончания измерения'), + ), + migrations.AlterField( + model_name='sigmaparameter', + name='freq_range', + field=models.FloatField(blank=True, default=0, help_text='Полоса частот в диапазоне от 0 до 1000 МГц', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1000)], verbose_name='Полоса частот, МГц'), + ), + migrations.AlterField( + model_name='sigmaparameter', + name='frequency', + field=models.FloatField(blank=True, db_index=True, default=0, help_text='Частота в диапазоне от 0 до 50000 МГц', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(50000)], verbose_name='Частота, МГц'), + ), + migrations.AlterField( + model_name='sigmaparameter', + name='packets', + field=models.BooleanField(blank=True, help_text='Наличие пакетной передачи', null=True, verbose_name='Пакетность'), + ), + migrations.AlterField( + model_name='sigmaparameter', + name='power', + field=models.FloatField(blank=True, default=0, help_text='Мощность сигнала в диапазоне от -100 до 100 дБм', null=True, validators=[django.core.validators.MinValueValidator(-100), django.core.validators.MaxValueValidator(100)], verbose_name='Мощность, дБм'), + ), + migrations.AlterField( + model_name='sigmaparameter', + name='snr', + field=models.FloatField(blank=True, default=0, help_text='Отношение сигнал/шум в диапазоне от -50 до 100 дБ', null=True, validators=[django.core.validators.MinValueValidator(-50), django.core.validators.MaxValueValidator(100)], verbose_name='ОСШ, Дб'), + ), + migrations.AlterField( + model_name='sigmaparameter', + name='status', + field=models.CharField(blank=True, help_text='Статус измерения', max_length=20, null=True, verbose_name='Статус'), + ), + migrations.AlterField( + model_name='sigmaparameter', + name='transfer', + field=models.FloatField(choices=[(-1.0, '-'), (9750.0, '9750 МГц'), (10750.0, '10750 МГц')], default=-1.0, help_text='Выберите перенос по частоте', verbose_name='Перенос по частоте'), + ), + migrations.AlterField( + model_name='sigmaparmark', + name='mark', + field=models.BooleanField(blank=True, help_text='True - сигнал обнаружен, False - сигнал отсутствует', null=True, verbose_name='Наличие сигнала'), + ), + migrations.AlterField( + model_name='sigmaparmark', + name='timestamp', + field=models.DateTimeField(blank=True, db_index=True, help_text='Время фиксации отметки', null=True, verbose_name='Время'), + ), + migrations.AlterField( + model_name='sourcetype', + name='name', + field=models.CharField(db_index=True, help_text='Тип источника сигнала', max_length=50, unique=True, verbose_name='Тип источника'), + ), + migrations.AlterField( + model_name='sourcetype', + name='objitem', + field=models.OneToOneField(help_text='Связанный объект', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_type_obj', to='mainapp.objitem', verbose_name='Объект'), + ), + migrations.AlterField( + model_name='standard', + name='name', + field=models.CharField(db_index=True, help_text='Стандарт передачи данных (DVB-S, DVB-S2, DVB-S2X и т.д.)', max_length=20, unique=True, verbose_name='Стандарт'), + ), + migrations.AddIndex( + model_name='geo', + index=models.Index(fields=['-timestamp'], name='mainapp_geo_timesta_58a605_idx'), + ), + migrations.AddIndex( + model_name='geo', + index=models.Index(fields=['location'], name='mainapp_geo_locatio_b855c9_idx'), + ), + migrations.AddIndex( + model_name='objitem', + index=models.Index(fields=['name'], name='mainapp_obj_name_e4f1e1_idx'), + ), + migrations.AddIndex( + model_name='objitem', + index=models.Index(fields=['-updated_at'], name='mainapp_obj_updated_f46b0e_idx'), + ), + migrations.AddIndex( + model_name='objitem', + index=models.Index(fields=['-created_at'], name='mainapp_obj_created_cba553_idx'), + ), + ] diff --git a/dbapp/mainapp/migrations/0007_remove_parameter_objitems_parameter_objitem.py b/dbapp/mainapp/migrations/0007_remove_parameter_objitems_parameter_objitem.py index 39a2ced..73f959f 100644 --- a/dbapp/mainapp/migrations/0007_remove_parameter_objitems_parameter_objitem.py +++ b/dbapp/mainapp/migrations/0007_remove_parameter_objitems_parameter_objitem.py @@ -1,23 +1,23 @@ -# Generated by Django 5.2.7 on 2025-11-10 18:39 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('mainapp', '0006_alter_customuser_options_alter_geo_options_and_more'), - ] - - operations = [ - migrations.RemoveField( - model_name='parameter', - name='objitems', - ), - migrations.AddField( - model_name='parameter', - name='objitem', - field=models.OneToOneField(blank=True, help_text='Связанный объект', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='parameter_obj', to='mainapp.objitem', verbose_name='Объект'), - ), - ] +# Generated by Django 5.2.7 on 2025-11-10 18:39 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mainapp', '0006_alter_customuser_options_alter_geo_options_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='parameter', + name='objitems', + ), + migrations.AddField( + model_name='parameter', + name='objitem', + field=models.OneToOneField(blank=True, help_text='Связанный объект', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='parameter_obj', to='mainapp.objitem', verbose_name='Объект'), + ), + ] diff --git a/dbapp/mainapp/migrations/0008_remove_sourcetype_objitem_objitem_source_type_id_and_more.py b/dbapp/mainapp/migrations/0008_remove_sourcetype_objitem_objitem_source_type_id_and_more.py new file mode 100644 index 0000000..861348f --- /dev/null +++ b/dbapp/mainapp/migrations/0008_remove_sourcetype_objitem_objitem_source_type_id_and_more.py @@ -0,0 +1,63 @@ +# Generated by Django 5.2.7 on 2025-11-11 13:59 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mainapp', '0007_remove_parameter_objitems_parameter_objitem'), + ] + + operations = [ + migrations.RemoveField( + model_name='sourcetype', + name='objitem', + ), + migrations.AddField( + model_name='objitem', + name='source_type_id', + field=models.ForeignKey(blank=True, help_text='Тип источника сигнала', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objitems_sourcetype', to='mainapp.sourcetype', verbose_name='Тип источника'), + ), + migrations.AlterField( + model_name='parameter', + name='bod_velocity', + field=models.FloatField(blank=True, default=0, help_text='Символьная скорость должна быть положительной', null=True, verbose_name='Символьная скорость, БОД'), + ), + migrations.AlterField( + model_name='parameter', + name='freq_range', + field=models.FloatField(blank=True, default=0, help_text='Полоса частот сигнала', null=True, verbose_name='Полоса частот, МГц'), + ), + migrations.AlterField( + model_name='parameter', + name='frequency', + field=models.FloatField(blank=True, db_index=True, default=0, help_text='Центральная частота сигнала', null=True, verbose_name='Частота, МГц'), + ), + migrations.AlterField( + model_name='parameter', + name='snr', + field=models.FloatField(blank=True, default=0, help_text='Отношение сигнал/шум', null=True, verbose_name='ОСШ'), + ), + migrations.AlterField( + model_name='sigmaparameter', + name='bod_velocity', + field=models.FloatField(blank=True, default=0, help_text='Символьная скорость должна быть положительной', null=True, verbose_name='Символьная скорость, БОД'), + ), + migrations.AlterField( + model_name='sigmaparameter', + name='freq_range', + field=models.FloatField(blank=True, default=0, help_text='Полоса частот', null=True, verbose_name='Полоса частот, МГц'), + ), + migrations.AlterField( + model_name='sigmaparameter', + name='frequency', + field=models.FloatField(blank=True, db_index=True, default=0, help_text='Центральная частота сигнала', null=True, verbose_name='Частота, МГц'), + ), + migrations.AlterField( + model_name='sigmaparameter', + name='power', + field=models.FloatField(blank=True, default=0, help_text='Мощность сигнала', null=True, verbose_name='Мощность, дБм'), + ), + ] diff --git a/dbapp/mainapp/mixins.py b/dbapp/mainapp/mixins.py index e696b58..c81150f 100644 --- a/dbapp/mainapp/mixins.py +++ b/dbapp/mainapp/mixins.py @@ -1,229 +1,229 @@ -""" -Переиспользуемые миксины для представлений mainapp. - -Этот модуль содержит миксины для стандартизации общей логики в представлениях, -включая проверку прав доступа, обработку координат и сообщений. -""" - -# Standard library imports -from datetime import datetime -from typing import Optional, Tuple - -# Django imports -from django.contrib import messages -from django.contrib.auth.mixins import UserPassesTestMixin -from django.contrib.gis.geos import Point - - -class RoleRequiredMixin(UserPassesTestMixin): - """ - Mixin для проверки роли пользователя. - - Проверяет, что пользователь имеет одну из требуемых ролей для доступа к представлению. - - Attributes: - required_roles (list): Список допустимых ролей для доступа. - По умолчанию ['admin', 'moderator']. - - Example: - class MyView(RoleRequiredMixin, View): - required_roles = ['admin', 'moderator'] - - def get(self, request): - # Только пользователи с ролью admin или moderator могут получить доступ - return render(request, 'template.html') - """ - - required_roles = ["admin", "moderator"] - - def test_func(self) -> bool: - """ - Проверяет, имеет ли пользователь требуемую роль. - - Returns: - bool: True если пользователь имеет одну из требуемых ролей, иначе False. - """ - if not self.request.user.is_authenticated: - return False - - if not hasattr(self.request.user, "customuser"): - return False - - return self.request.user.customuser.role in self.required_roles - - -class CoordinateProcessingMixin: - """ - Mixin для обработки координат из POST данных форм. - - Предоставляет методы для извлечения и обработки координат различных типов - (геолокация, кубсат, оперативники) из POST запроса и применения их к объекту Geo. - - Example: - class MyFormView(CoordinateProcessingMixin, FormView): - def form_valid(self, form): - geo_instance = Geo() - self.process_coordinates(geo_instance) - geo_instance.save() - return super().form_valid(form) - """ - - def process_coordinates(self, geo_instance, prefix: str = "geo") -> None: - """ - Обрабатывает координаты из POST данных и применяет их к объекту Geo. - - Извлекает координаты геолокации, кубсата и оперативников из POST запроса - и устанавливает соответствующие поля объекта Geo. - - Args: - geo_instance: Экземпляр модели Geo для обновления координат. - prefix (str): Префикс для полей формы (по умолчанию 'geo'). - - Note: - Метод ожидает следующие поля в request.POST: - - geo_longitude, geo_latitude: координаты геолокации - - kupsat_longitude, kupsat_latitude: координаты кубсата - - valid_longitude, valid_latitude: координаты оперативников - """ - # Обрабатываем координаты геолокации - geo_coords = self._extract_coordinates("geo") - if geo_coords: - geo_instance.coords = Point(geo_coords[0], geo_coords[1], srid=4326) - - # Обрабатываем координаты Кубсата - kupsat_coords = self._extract_coordinates("kupsat") - if kupsat_coords: - geo_instance.coords_kupsat = Point( - kupsat_coords[0], kupsat_coords[1], srid=4326 - ) - - # Обрабатываем координаты оперативников - valid_coords = self._extract_coordinates("valid") - if valid_coords: - geo_instance.coords_valid = Point( - valid_coords[0], valid_coords[1], srid=4326 - ) - - def _extract_coordinates(self, coord_type: str) -> Optional[Tuple[float, float]]: - """ - Извлекает координаты указанного типа из POST данных. - - Args: - coord_type (str): Тип координат ('geo', 'kupsat', 'valid'). - - Returns: - Optional[Tuple[float, float]]: Кортеж (longitude, latitude) или None, - если координаты не найдены или невалидны. - """ - longitude_key = f"{coord_type}_longitude" - latitude_key = f"{coord_type}_latitude" - - longitude = self.request.POST.get(longitude_key) - latitude = self.request.POST.get(latitude_key) - - if longitude and latitude: - try: - return (float(longitude), float(latitude)) - except (ValueError, TypeError): - return None - return None - - def process_timestamp(self, geo_instance) -> None: - """ - Обрабатывает дату и время из POST данных и применяет к объекту Geo. - - Args: - geo_instance: Экземпляр модели Geo для обновления timestamp. - - Note: - Метод ожидает следующие поля в request.POST: - - timestamp_date: дата в формате YYYY-MM-DD - - timestamp_time: время в формате HH:MM - """ - timestamp_date = self.request.POST.get("timestamp_date") - timestamp_time = self.request.POST.get("timestamp_time") - - if timestamp_date and timestamp_time: - try: - naive_datetime = datetime.strptime( - f"{timestamp_date} {timestamp_time}", "%Y-%m-%d %H:%M" - ) - geo_instance.timestamp = naive_datetime - except ValueError: - # Если формат даты/времени неверный, пропускаем - pass - - -class FormMessageMixin: - """ - Mixin для стандартизации сообщений об успехе и ошибках в формах. - - Автоматически добавляет сообщения пользователю при успешной или неуспешной - обработке формы. - - Attributes: - success_message (str): Сообщение при успешной обработке формы. - error_message (str): Сообщение при ошибке обработки формы. - - Example: - class MyFormView(FormMessageMixin, FormView): - success_message = "Данные успешно сохранены!" - error_message = "Ошибка при сохранении данных" - - def form_valid(self, form): - # Автоматически добавит success_message - return super().form_valid(form) - """ - - success_message = "Операция выполнена успешно" - error_message = "Произошла ошибка при обработке формы" - - def form_valid(self, form): - """ - Обрабатывает валидную форму и добавляет сообщение об успехе. - - Args: - form: Валидная форма Django. - - Returns: - HttpResponse: Результат обработки родительского метода form_valid. - """ - if self.success_message: - messages.success(self.request, self.success_message) - return super().form_valid(form) - - def form_invalid(self, form): - """ - Обрабатывает невалидную форму и добавляет сообщение об ошибке. - - Args: - form: Невалидная форма Django. - - Returns: - HttpResponse: Результат обработки родительского метода form_invalid. - """ - if self.error_message: - messages.error(self.request, self.error_message) - return super().form_invalid(form) - - def get_success_message(self) -> str: - """ - Возвращает сообщение об успехе. - - Может быть переопределен в подклассах для динамического формирования сообщения. - - Returns: - str: Сообщение об успехе. - """ - return self.success_message - - def get_error_message(self) -> str: - """ - Возвращает сообщение об ошибке. - - Может быть переопределен в подклассах для динамического формирования сообщения. - - Returns: - str: Сообщение об ошибке. - """ - return self.error_message +""" +Переиспользуемые миксины для представлений mainapp. + +Этот модуль содержит миксины для стандартизации общей логики в представлениях, +включая проверку прав доступа, обработку координат и сообщений. +""" + +# Standard library imports +from datetime import datetime +from typing import Optional, Tuple + +# Django imports +from django.contrib import messages +from django.contrib.auth.mixins import UserPassesTestMixin +from django.contrib.gis.geos import Point + + +class RoleRequiredMixin(UserPassesTestMixin): + """ + Mixin для проверки роли пользователя. + + Проверяет, что пользователь имеет одну из требуемых ролей для доступа к представлению. + + Attributes: + required_roles (list): Список допустимых ролей для доступа. + По умолчанию ['admin', 'moderator']. + + Example: + class MyView(RoleRequiredMixin, View): + required_roles = ['admin', 'moderator'] + + def get(self, request): + # Только пользователи с ролью admin или moderator могут получить доступ + return render(request, 'template.html') + """ + + required_roles = ["admin", "moderator"] + + def test_func(self) -> bool: + """ + Проверяет, имеет ли пользователь требуемую роль. + + Returns: + bool: True если пользователь имеет одну из требуемых ролей, иначе False. + """ + if not self.request.user.is_authenticated: + return False + + if not hasattr(self.request.user, "customuser"): + return False + + return self.request.user.customuser.role in self.required_roles + + +class CoordinateProcessingMixin: + """ + Mixin для обработки координат из POST данных форм. + + Предоставляет методы для извлечения и обработки координат различных типов + (геолокация, кубсат, оперативники) из POST запроса и применения их к объекту Geo. + + Example: + class MyFormView(CoordinateProcessingMixin, FormView): + def form_valid(self, form): + geo_instance = Geo() + self.process_coordinates(geo_instance) + geo_instance.save() + return super().form_valid(form) + """ + + def process_coordinates(self, geo_instance, prefix: str = "geo") -> None: + """ + Обрабатывает координаты из POST данных и применяет их к объекту Geo. + + Извлекает координаты геолокации, кубсата и оперативников из POST запроса + и устанавливает соответствующие поля объекта Geo. + + Args: + geo_instance: Экземпляр модели Geo для обновления координат. + prefix (str): Префикс для полей формы (по умолчанию 'geo'). + + Note: + Метод ожидает следующие поля в request.POST: + - geo_longitude, geo_latitude: координаты геолокации + - kupsat_longitude, kupsat_latitude: координаты кубсата + - valid_longitude, valid_latitude: координаты оперативников + """ + # Обрабатываем координаты геолокации + geo_coords = self._extract_coordinates("geo") + if geo_coords: + geo_instance.coords = Point(geo_coords[0], geo_coords[1], srid=4326) + + # Обрабатываем координаты Кубсата + kupsat_coords = self._extract_coordinates("kupsat") + if kupsat_coords: + geo_instance.coords_kupsat = Point( + kupsat_coords[0], kupsat_coords[1], srid=4326 + ) + + # Обрабатываем координаты оперативников + valid_coords = self._extract_coordinates("valid") + if valid_coords: + geo_instance.coords_valid = Point( + valid_coords[0], valid_coords[1], srid=4326 + ) + + def _extract_coordinates(self, coord_type: str) -> Optional[Tuple[float, float]]: + """ + Извлекает координаты указанного типа из POST данных. + + Args: + coord_type (str): Тип координат ('geo', 'kupsat', 'valid'). + + Returns: + Optional[Tuple[float, float]]: Кортеж (longitude, latitude) или None, + если координаты не найдены или невалидны. + """ + longitude_key = f"{coord_type}_longitude" + latitude_key = f"{coord_type}_latitude" + + longitude = self.request.POST.get(longitude_key) + latitude = self.request.POST.get(latitude_key) + + if longitude and latitude: + try: + return (float(longitude), float(latitude)) + except (ValueError, TypeError): + return None + return None + + def process_timestamp(self, geo_instance) -> None: + """ + Обрабатывает дату и время из POST данных и применяет к объекту Geo. + + Args: + geo_instance: Экземпляр модели Geo для обновления timestamp. + + Note: + Метод ожидает следующие поля в request.POST: + - timestamp_date: дата в формате YYYY-MM-DD + - timestamp_time: время в формате HH:MM + """ + timestamp_date = self.request.POST.get("timestamp_date") + timestamp_time = self.request.POST.get("timestamp_time") + + if timestamp_date and timestamp_time: + try: + naive_datetime = datetime.strptime( + f"{timestamp_date} {timestamp_time}", "%Y-%m-%d %H:%M" + ) + geo_instance.timestamp = naive_datetime + except ValueError: + # Если формат даты/времени неверный, пропускаем + pass + + +class FormMessageMixin: + """ + Mixin для стандартизации сообщений об успехе и ошибках в формах. + + Автоматически добавляет сообщения пользователю при успешной или неуспешной + обработке формы. + + Attributes: + success_message (str): Сообщение при успешной обработке формы. + error_message (str): Сообщение при ошибке обработки формы. + + Example: + class MyFormView(FormMessageMixin, FormView): + success_message = "Данные успешно сохранены!" + error_message = "Ошибка при сохранении данных" + + def form_valid(self, form): + # Автоматически добавит success_message + return super().form_valid(form) + """ + + success_message = "Операция выполнена успешно" + error_message = "Произошла ошибка при обработке формы" + + def form_valid(self, form): + """ + Обрабатывает валидную форму и добавляет сообщение об успехе. + + Args: + form: Валидная форма Django. + + Returns: + HttpResponse: Результат обработки родительского метода form_valid. + """ + if self.success_message: + messages.success(self.request, self.success_message) + return super().form_valid(form) + + def form_invalid(self, form): + """ + Обрабатывает невалидную форму и добавляет сообщение об ошибке. + + Args: + form: Невалидная форма Django. + + Returns: + HttpResponse: Результат обработки родительского метода form_invalid. + """ + if self.error_message: + messages.error(self.request, self.error_message) + return super().form_invalid(form) + + def get_success_message(self) -> str: + """ + Возвращает сообщение об успехе. + + Может быть переопределен в подклассах для динамического формирования сообщения. + + Returns: + str: Сообщение об успехе. + """ + return self.success_message + + def get_error_message(self) -> str: + """ + Возвращает сообщение об ошибке. + + Может быть переопределен в подклассах для динамического формирования сообщения. + + Returns: + str: Сообщение об ошибке. + """ + return self.error_message diff --git a/dbapp/mainapp/models.py b/dbapp/mainapp/models.py index 0072da3..c3b8732 100644 --- a/dbapp/mainapp/models.py +++ b/dbapp/mainapp/models.py @@ -1,786 +1,782 @@ -# Django imports -from django.contrib.auth.models import User -from django.contrib.gis.db import models as gis -from django.contrib.gis.db.models import functions -from django.core.exceptions import ValidationError -from django.core.validators import MaxValueValidator, MinValueValidator -from django.db import models -from django.db.models import ExpressionWrapper, F -from django.utils import timezone - - -def get_default_polarization(): - obj, created = Polarization.objects.get_or_create(name="-") - return obj.id - - -def get_default_modulation(): - obj, created = Modulation.objects.get_or_create(name="-") - return obj.id - - -def get_default_standard(): - obj, created = Standard.objects.get_or_create(name="-") - return obj.id - - -class CustomUser(models.Model): - """ - Расширенная модель пользователя с ролями. - - Добавляет систему ролей к стандартной модели User Django. - """ - - ROLE_CHOICES = [ - ("admin", "Администратор"), - ("moderator", "Модератор"), - ("user", "Пользователь"), - ] - - # Связи - user = models.OneToOneField( - User, - on_delete=models.CASCADE, - verbose_name="Пользователь", - help_text="Связанный пользователь Django", - ) - - # Основные поля - role = models.CharField( - max_length=20, - choices=ROLE_CHOICES, - default="user", - verbose_name="Роль пользователя", - db_index=True, - help_text="Роль пользователя в системе", - ) - - def __str__(self): - return ( - f"{self.user.first_name} {self.user.last_name}" - if self.user.first_name and self.user.last_name - else self.user.username - ) - - class Meta: - verbose_name = "Пользователь" - verbose_name_plural = "Пользователи" - ordering = ["user__username"] - - -class SigmaParMark(models.Model): - """ - Модель отметки о наличии сигнала. - - Используется для фиксации моментов времени когда сигнал был обнаружен или потерян. - """ - - # Основные поля - mark = models.BooleanField( - null=True, - blank=True, - verbose_name="Наличие сигнала", - help_text="True - сигнал обнаружен, False - сигнал отсутствует", - ) - timestamp = models.DateTimeField( - null=True, - blank=True, - verbose_name="Время", - db_index=True, - help_text="Время фиксации отметки", - ) - - def __str__(self): - if self.timestamp: - timestamp = self.timestamp.strftime("%d.%m.%Y %H:%M") - return f"+ {timestamp}" if self.mark else f"- {timestamp}" - return "Отметка без времени" - - class Meta: - verbose_name = "Отметка" - verbose_name_plural = "Отметки" - ordering = ["-timestamp"] - - -class Mirror(models.Model): - """ - Модель зеркала антенны. - - Представляет физическое зеркало антенны для приема спутникового сигнала. - """ - - # Основные поля - name = models.CharField( - max_length=30, - unique=True, - verbose_name="Имя зеркала", - db_index=True, - help_text="Уникальное название зеркала антенны", - ) - - def __str__(self): - return self.name - - class Meta: - verbose_name = "Зеркало" - verbose_name_plural = "Зеркала" - ordering = ["name"] - - -class Polarization(models.Model): - """ - Модель поляризации сигнала. - - Определяет тип поляризации спутникового сигнала (H, V, L, R и т.д.). - """ - - # Основные поля - name = models.CharField( - max_length=20, - unique=True, - verbose_name="Поляризация", - db_index=True, - help_text="Тип поляризации (H - горизонтальная, V - вертикальная, L - левая круговая, R - правая круговая)", - ) - - def __str__(self): - return self.name - - class Meta: - verbose_name = "Поляризация" - verbose_name_plural = "Поляризация" - ordering = ["name"] - - -class Modulation(models.Model): - """ - Модель типа модуляции сигнала. - - Определяет схему модуляции (QPSK, 8PSK, 16APSK и т.д.). - """ - - # Основные поля - name = models.CharField( - max_length=20, - unique=True, - verbose_name="Модуляция", - db_index=True, - help_text="Тип модуляции сигнала (QPSK, 8PSK, 16APSK и т.д.)", - ) - - def __str__(self): - return self.name - - class Meta: - verbose_name = "Модуляция" - verbose_name_plural = "Модуляции" - ordering = ["name"] - - -class Standard(models.Model): - """ - Модель стандарта передачи данных. - - Определяет стандарт передачи (DVB-S, DVB-S2, DVB-S2X и т.д.). - """ - - # Основные поля - name = models.CharField( - max_length=20, - unique=True, - verbose_name="Стандарт", - db_index=True, - help_text="Стандарт передачи данных (DVB-S, DVB-S2, DVB-S2X и т.д.)", - ) - - def __str__(self): - return self.name - - class Meta: - verbose_name = "Стандарт" - verbose_name_plural = "Стандарты" - ordering = ["name"] - - -class Satellite(models.Model): - """ - Модель спутника. - - Представляет спутник связи с его основными характеристиками. - """ - - # Основные поля - name = models.CharField( - max_length=100, - unique=True, - verbose_name="Имя спутника", - db_index=True, - help_text="Название спутника", - ) - norad = models.IntegerField( - blank=True, - null=True, - verbose_name="NORAD ID", - help_text="Идентификатор NORAD для отслеживания спутника", - ) - - def __str__(self): - return self.name - - class Meta: - verbose_name = "Спутник" - verbose_name_plural = "Спутники" - ordering = ["name"] - - -class ObjItemQuerySet(models.QuerySet): - """Custom QuerySet для модели ObjItem с оптимизированными запросами""" - - def with_related(self): - """Оптимизирует запросы, загружая связанные объекты""" - return self.select_related( - "geo_obj", - "updated_by__user", - "created_by__user", - "source_type_obj", - "parameter_obj", - "parameter_obj__id_satellite", - "parameter_obj__polarization", - "parameter_obj__modulation", - "parameter_obj__standard", - ) - - def recent(self, days=30): - """Возвращает объекты, созданные за последние N дней""" - from datetime import timedelta - - cutoff_date = timezone.now() - timedelta(days=days) - return self.filter(created_at__gte=cutoff_date) - - def by_user(self, user): - """Возвращает объекты, созданные указанным пользователем""" - return self.filter(created_by=user) - - -class ObjItemManager(models.Manager): - """Custom Manager для модели ObjItem""" - - def get_queryset(self): - return ObjItemQuerySet(self.model, using=self._db) - - def with_related(self): - """Возвращает queryset с предзагруженными связанными объектами""" - return self.get_queryset().with_related() - - def recent(self, days=30): - """Возвращает недавно созданные объекты""" - return self.get_queryset().recent(days) - - def by_user(self, user): - """Возвращает объекты пользователя""" - return self.get_queryset().by_user(user) - - -class ObjItem(models.Model): - """ - Модель объекта (источника сигнала). - - Центральная модель, объединяющая информацию о ВЧ параметрах, геолокации и типе источника. - """ - - # Основные поля - name = models.CharField( - null=True, - blank=True, - max_length=100, - verbose_name="Имя объекта", - db_index=True, - help_text="Название объекта/источника сигнала", - ) - - # Метаданные - created_at = models.DateTimeField( - auto_now_add=True, - verbose_name="Дата создания", - help_text="Дата и время создания записи", - ) - created_by = models.ForeignKey( - CustomUser, - on_delete=models.SET_NULL, - related_name="objitems_created", - null=True, - blank=True, - verbose_name="Создан пользователем", - help_text="Пользователь, создавший запись", - ) - updated_at = models.DateTimeField( - auto_now=True, - verbose_name="Дата последнего изменения", - help_text="Дата и время последнего изменения", - ) - updated_by = models.ForeignKey( - CustomUser, - on_delete=models.SET_NULL, - related_name="objitems_updated", - null=True, - blank=True, - verbose_name="Изменен пользователем", - help_text="Пользователь, последним изменивший запись", - ) - - # Custom manager - objects = ObjItemManager() - - def __str__(self): - return f"Объект {self.name}" if self.name else f"Объект #{self.pk}" - - class Meta: - verbose_name = "Объект" - verbose_name_plural = "Объекты" - ordering = ["-updated_at"] - indexes = [ - models.Index(fields=["name"]), - models.Index(fields=["-updated_at"]), - models.Index(fields=["-created_at"]), - ] - - -class SourceType(models.Model): - """ - Модель типа источника сигнала. - - Классифицирует источники по типам (наземный, морской, воздушный и т.д.). - """ - - # Основные поля - name = models.CharField( - max_length=50, - unique=True, - verbose_name="Тип источника", - db_index=True, - help_text="Тип источника сигнала", - ) - - # Связи - objitem = models.OneToOneField( - ObjItem, - on_delete=models.SET_NULL, - verbose_name="Объект", - related_name="source_type_obj", - null=True, - help_text="Связанный объект", - ) - - def __str__(self): - return self.name - - class Meta: - verbose_name = "Тип источника" - verbose_name_plural = "Типы источников" - ordering = ["name"] - - -class Parameter(models.Model): - id_satellite = models.ForeignKey( - Satellite, - on_delete=models.PROTECT, - related_name="parameters", - verbose_name="Спутник", - null=True, - ) - polarization = models.ForeignKey( - Polarization, - default=get_default_polarization, - on_delete=models.SET_DEFAULT, - related_name="polarizations", - null=True, - blank=True, - verbose_name="Поляризация", - ) - frequency = models.FloatField( - default=0, - null=True, - blank=True, - verbose_name="Частота, МГц", - db_index=True, - validators=[MinValueValidator(0), MaxValueValidator(50000)], - help_text="Частота в диапазоне от 0 до 50000 МГц", - ) - freq_range = models.FloatField( - default=0, - null=True, - blank=True, - verbose_name="Полоса частот, МГц", - validators=[MinValueValidator(0), MaxValueValidator(1000)], - help_text="Полоса частот в диапазоне от 0 до 1000 МГц", - ) - bod_velocity = models.FloatField( - default=0, - null=True, - blank=True, - verbose_name="Символьная скорость, БОД", - validators=[MinValueValidator(0)], - help_text="Символьная скорость должна быть положительной", - ) - modulation = models.ForeignKey( - Modulation, - default=get_default_modulation, - on_delete=models.SET_DEFAULT, - related_name="modulations", - null=True, - blank=True, - verbose_name="Модуляция", - ) - snr = models.FloatField( - default=0, - null=True, - blank=True, - verbose_name="ОСШ", - validators=[MinValueValidator(-50), MaxValueValidator(100)], - help_text="Отношение сигнал/шум в диапазоне от -50 до 100 дБ", - ) - standard = models.ForeignKey( - Standard, - default=get_default_standard, - on_delete=models.SET_DEFAULT, - related_name="standards", - null=True, - blank=True, - verbose_name="Стандарт", - ) - # id_user_add = models.ForeignKey(CustomUser, on_delete=models.SET_NULL, related_name="parameter_added", verbose_name="Пользователь", null=True, blank=True) - objitem = models.OneToOneField( - ObjItem, - on_delete=models.CASCADE, - related_name="parameter_obj", - verbose_name="Объект", - null=True, - blank=True, - help_text="Связанный объект" - ) - # id_sigma_parameter = models.ManyToManyField(SigmaParameter, on_delete=models.SET_NULL, related_name="sigma_parameter", verbose_name="ВЧ с sigma", null=True, blank=True) - # id_sigma_parameter = models.ManyToManyField(SigmaParameter, verbose_name="ВЧ с sigma", null=True, blank=True) - - def clean(self): - """Валидация на уровне модели""" - super().clean() - - # Проверка что частота больше полосы частот - if self.frequency and self.freq_range: - if self.freq_range > self.frequency: - raise ValidationError( - {"freq_range": "Полоса частот не может быть больше частоты"} - ) - - # Проверка что символьная скорость соответствует полосе частот - if self.bod_velocity and self.freq_range: - if self.bod_velocity > self.freq_range * 1000000: # Конвертация МГц в Гц - raise ValidationError( - { - "bod_velocity": "Символьная скорость не может превышать полосу частот" - } - ) - - def __str__(self): - polarization_name = self.polarization.name if self.polarization else "-" - modulation_name = self.modulation.name if self.modulation else "-" - return f"Источник-{self.frequency}:{self.freq_range} МГц:{polarization_name}:{modulation_name}" - - class Meta: - verbose_name = "ВЧ загрузка" - verbose_name_plural = "ВЧ загрузки" - indexes = [ - models.Index(fields=["id_satellite", "frequency"]), - models.Index(fields=["frequency", "polarization"]), - ] - # constraints = [ - # models.UniqueConstraint( - # fields=[ - # 'polarization', 'frequency', 'freq_range', - # 'bod_velocity', 'modulation', 'snr', 'standard' - # ], - # name='unique_parameter_combination' - # ) - # ] - - -class SigmaParameter(models.Model): - TRANSFERS = [(-1.0, "-"), (9750.0, "9750 МГц"), (10750.0, "10750 МГц")] - - id_satellite = models.ForeignKey( - Satellite, - on_delete=models.PROTECT, - related_name="sigmapar_sat", - verbose_name="Спутник", - ) - transfer = models.FloatField( - choices=TRANSFERS, - default=-1.0, - verbose_name="Перенос по частоте", - help_text="Выберите перенос по частоте", - ) - status = models.CharField( - max_length=20, - blank=True, - null=True, - verbose_name="Статус", - help_text="Статус измерения", - ) - frequency = models.FloatField( - default=0, - null=True, - blank=True, - verbose_name="Частота, МГц", - db_index=True, - validators=[MinValueValidator(0), MaxValueValidator(50000)], - help_text="Частота в диапазоне от 0 до 50000 МГц", - ) - transfer_frequency = models.GeneratedField( - expression=ExpressionWrapper( - F("frequency") + F("transfer"), output_field=models.FloatField() - ), - output_field=models.FloatField(), - db_persist=True, - null=True, - blank=True, - verbose_name="Частота в Ku, МГц", - ) - freq_range = models.FloatField( - default=0, - null=True, - blank=True, - verbose_name="Полоса частот, МГц", - validators=[MinValueValidator(0), MaxValueValidator(1000)], - help_text="Полоса частот в диапазоне от 0 до 1000 МГц", - ) - power = models.FloatField( - default=0, - null=True, - blank=True, - verbose_name="Мощность, дБм", - validators=[MinValueValidator(-100), MaxValueValidator(100)], - help_text="Мощность сигнала в диапазоне от -100 до 100 дБм", - ) - bod_velocity = models.FloatField( - default=0, - null=True, - blank=True, - verbose_name="Символьная скорость, БОД", - validators=[MinValueValidator(0)], - help_text="Символьная скорость должна быть положительной", - ) - polarization = models.ForeignKey( - Polarization, - default=get_default_polarization, - on_delete=models.SET_DEFAULT, - related_name="polarizations_sigma", - null=True, - blank=True, - verbose_name="Поляризация", - ) - modulation = models.ForeignKey( - Modulation, - default=get_default_modulation, - on_delete=models.SET_DEFAULT, - related_name="modulations_sigma", - null=True, - blank=True, - verbose_name="Модуляция", - ) - snr = models.FloatField( - default=0, - null=True, - blank=True, - verbose_name="ОСШ, Дб", - validators=[MinValueValidator(-50), MaxValueValidator(100)], - help_text="Отношение сигнал/шум в диапазоне от -50 до 100 дБ", - ) - standard = models.ForeignKey( - Standard, - default=get_default_standard, - on_delete=models.SET_DEFAULT, - related_name="standards_sigma", - null=True, - blank=True, - verbose_name="Стандарт", - ) - packets = models.BooleanField( - null=True, - blank=True, - verbose_name="Пакетность", - help_text="Наличие пакетной передачи", - ) - datetime_begin = models.DateTimeField( - null=True, - blank=True, - verbose_name="Время начала измерения", - help_text="Дата и время начала измерения", - ) - datetime_end = models.DateTimeField( - null=True, - blank=True, - verbose_name="Время окончания измерения", - help_text="Дата и время окончания измерения", - ) - mark = models.ManyToManyField(SigmaParMark, verbose_name="Отметка", blank=True) - parameter = models.ForeignKey( - Parameter, - on_delete=models.SET_NULL, - related_name="sigma_parameter", - verbose_name="ВЧ", - null=True, - blank=True, - ) - - def clean(self): - """Валидация на уровне модели""" - super().clean() - - # Проверка что время окончания больше времени начала - if self.datetime_begin and self.datetime_end: - if self.datetime_end < self.datetime_begin: - raise ValidationError( - {"datetime_end": "Время окончания должно быть позже времени начала"} - ) - - # Проверка что частота больше полосы частот - if self.frequency and self.freq_range: - if self.freq_range > self.frequency: - raise ValidationError( - {"freq_range": "Полоса частот не может быть больше частоты"} - ) - - def __str__(self): - modulation_name = self.modulation.name if self.modulation else "-" - return f"Sigma-{self.frequency}:{self.freq_range} МГц:{modulation_name}" - - class Meta: - verbose_name = "ВЧ sigma" - verbose_name_plural = "ВЧ sigma" - - -class Geo(models.Model): - """ - Модель геолокационных данных. - - Хранит информацию о местоположении источника сигнала, включая координаты, - данные от различных источников (геолокация, кубсат, оперативники) и расстояния между ними. - """ - - # Основные поля - timestamp = models.DateTimeField( - null=True, - blank=True, - verbose_name="Время", - db_index=True, - help_text="Время фиксации геолокации", - ) - location = models.CharField( - max_length=255, - null=True, - blank=True, - verbose_name="Местоположение", - help_text="Текстовое описание местоположения", - ) - comment = models.CharField( - max_length=255, - blank=True, - verbose_name="Комментарий", - help_text="Дополнительные комментарии", - ) - is_average = models.BooleanField( - null=True, - blank=True, - verbose_name="Усреднённое", - help_text="Является ли координата усредненной", - ) - - # Координаты - coords = gis.PointField( - srid=4326, - null=True, - blank=True, - verbose_name="Координата геолокации", - help_text="Основные координаты геолокации (WGS84)", - ) - coords_kupsat = gis.PointField( - srid=4326, - null=True, - blank=True, - verbose_name="Координаты Кубсата", - help_text="Координаты, полученные от кубсата (WGS84)", - ) - coords_valid = gis.PointField( - srid=4326, - null=True, - blank=True, - verbose_name="Координаты оперативников", - help_text="Координаты, предоставленные оперативным отделом (WGS84)", - ) - - # Вычисляемые поля - расстояния - distance_coords_kup = models.GeneratedField( - expression=functions.Distance("coords", "coords_kupsat") / 1000, - output_field=models.FloatField(), - db_persist=True, - null=True, - blank=True, - verbose_name="Расстояние между кубсатом и гео, км", - ) - distance_coords_valid = models.GeneratedField( - expression=functions.Distance("coords", "coords_valid") / 1000, - output_field=models.FloatField(), - db_persist=True, - null=True, - blank=True, - verbose_name="Расстояние между гео и оперативным отделом, км", - ) - distance_kup_valid = models.GeneratedField( - expression=functions.Distance("coords_valid", "coords_kupsat") / 1000, - output_field=models.FloatField(), - db_persist=True, - null=True, - blank=True, - verbose_name="Расстояние между кубсатом и оперативным отделом, км", - ) - - # Связи - mirrors = models.ManyToManyField( - Mirror, - related_name="geo_mirrors", - verbose_name="Зеркала", - blank=True, - help_text="Зеркала антенн, использованные для приема", - ) - objitem = models.OneToOneField( - ObjItem, - on_delete=models.CASCADE, - verbose_name="Объект", - related_name="geo_obj", - null=True, - help_text="Связанный объект", - ) - - def __str__(self): - if self.coords: - longitude = self.coords.coords[0] - latitude = self.coords.coords[1] - lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" - lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" - location_str = f", {self.location}" if self.location else "" - return f"{lat} {lon}{location_str}" - return f"Гео #{self.pk}" - - class Meta: - verbose_name = "Гео" - verbose_name_plural = "Гео" - ordering = ["-timestamp"] - indexes = [ - models.Index(fields=["-timestamp"]), - models.Index(fields=["location"]), - ] - constraints = [ - models.UniqueConstraint( - fields=["timestamp", "coords"], name="unique_geo_combination" - ) - ] +# Django imports +from django.contrib.auth.models import User +from django.contrib.gis.db import models as gis +from django.contrib.gis.db.models import functions +from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.db.models import ExpressionWrapper, F +from django.utils import timezone + + +def get_default_polarization(): + obj, created = Polarization.objects.get_or_create(name="-") + return obj.id + + +def get_default_modulation(): + obj, created = Modulation.objects.get_or_create(name="-") + return obj.id + + +def get_default_standard(): + obj, created = Standard.objects.get_or_create(name="-") + return obj.id + + +class CustomUser(models.Model): + """ + Расширенная модель пользователя с ролями. + + Добавляет систему ролей к стандартной модели User Django. + """ + + ROLE_CHOICES = [ + ("admin", "Администратор"), + ("moderator", "Модератор"), + ("user", "Пользователь"), + ] + + # Связи + user = models.OneToOneField( + User, + on_delete=models.CASCADE, + verbose_name="Пользователь", + help_text="Связанный пользователь Django", + ) + + # Основные поля + role = models.CharField( + max_length=20, + choices=ROLE_CHOICES, + default="user", + verbose_name="Роль пользователя", + db_index=True, + help_text="Роль пользователя в системе", + ) + + def __str__(self): + return ( + f"{self.user.first_name} {self.user.last_name}" + if self.user.first_name and self.user.last_name + else self.user.username + ) + + class Meta: + verbose_name = "Пользователь" + verbose_name_plural = "Пользователи" + ordering = ["user__username"] + + +class SigmaParMark(models.Model): + """ + Модель отметки о наличии сигнала. + + Используется для фиксации моментов времени когда сигнал был обнаружен или потерян. + """ + + # Основные поля + mark = models.BooleanField( + null=True, + blank=True, + verbose_name="Наличие сигнала", + help_text="True - сигнал обнаружен, False - сигнал отсутствует", + ) + timestamp = models.DateTimeField( + null=True, + blank=True, + verbose_name="Время", + db_index=True, + help_text="Время фиксации отметки", + ) + + def __str__(self): + if self.timestamp: + timestamp = self.timestamp.strftime("%d.%m.%Y %H:%M") + return f"+ {timestamp}" if self.mark else f"- {timestamp}" + return "Отметка без времени" + + class Meta: + verbose_name = "Отметка" + verbose_name_plural = "Отметки" + ordering = ["-timestamp"] + + +class Mirror(models.Model): + """ + Модель зеркала антенны. + + Представляет физическое зеркало антенны для приема спутникового сигнала. + """ + + # Основные поля + name = models.CharField( + max_length=30, + unique=True, + verbose_name="Имя зеркала", + db_index=True, + help_text="Уникальное название зеркала антенны", + ) + + def __str__(self): + return self.name + + class Meta: + verbose_name = "Зеркало" + verbose_name_plural = "Зеркала" + ordering = ["name"] + + +class Polarization(models.Model): + """ + Модель поляризации сигнала. + + Определяет тип поляризации спутникового сигнала (H, V, L, R и т.д.). + """ + + # Основные поля + name = models.CharField( + max_length=20, + unique=True, + verbose_name="Поляризация", + db_index=True, + help_text="Тип поляризации (H - горизонтальная, V - вертикальная, L - левая круговая, R - правая круговая)", + ) + + def __str__(self): + return self.name + + class Meta: + verbose_name = "Поляризация" + verbose_name_plural = "Поляризация" + ordering = ["name"] + + +class Modulation(models.Model): + """ + Модель типа модуляции сигнала. + + Определяет схему модуляции (QPSK, 8PSK, 16APSK и т.д.). + """ + + # Основные поля + name = models.CharField( + max_length=20, + unique=True, + verbose_name="Модуляция", + db_index=True, + help_text="Тип модуляции сигнала (QPSK, 8PSK, 16APSK и т.д.)", + ) + + def __str__(self): + return self.name + + class Meta: + verbose_name = "Модуляция" + verbose_name_plural = "Модуляции" + ordering = ["name"] + + +class Standard(models.Model): + """ + Модель стандарта передачи данных. + + Определяет стандарт передачи (DVB-S, DVB-S2, DVB-S2X и т.д.). + """ + + # Основные поля + name = models.CharField( + max_length=20, + unique=True, + verbose_name="Стандарт", + db_index=True, + help_text="Стандарт передачи данных (DVB-S, DVB-S2, DVB-S2X и т.д.)", + ) + + def __str__(self): + return self.name + + class Meta: + verbose_name = "Стандарт" + verbose_name_plural = "Стандарты" + ordering = ["name"] + + +class Satellite(models.Model): + """ + Модель спутника. + + Представляет спутник связи с его основными характеристиками. + """ + + # Основные поля + name = models.CharField( + max_length=100, + unique=True, + verbose_name="Имя спутника", + db_index=True, + help_text="Название спутника", + ) + norad = models.IntegerField( + blank=True, + null=True, + verbose_name="NORAD ID", + help_text="Идентификатор NORAD для отслеживания спутника", + ) + + def __str__(self): + return self.name + + class Meta: + verbose_name = "Спутник" + verbose_name_plural = "Спутники" + ordering = ["name"] + + +class SourceType(models.Model): + """ + Модель типа источника сигнала. + Классифицирует источники по типам (наземный, морской, воздушный и т.д.). + """ + # Основные поля + name = models.CharField( + max_length=50, + unique=True, + verbose_name="Тип источника", + db_index=True, + help_text="Тип источника сигнала", + ) + + def __str__(self): + return self.name + + class Meta: + verbose_name = "Тип источника" + verbose_name_plural = "Типы источников" + ordering = ["name"] + +class ObjItemQuerySet(models.QuerySet): + """Custom QuerySet для модели ObjItem с оптимизированными запросами""" + + def with_related(self): + """Оптимизирует запросы, загружая связанные объекты""" + return self.select_related( + "geo_obj", + "updated_by__user", + "created_by__user", + "source_type_obj", + "parameter_obj", + "parameter_obj__id_satellite", + "parameter_obj__polarization", + "parameter_obj__modulation", + "parameter_obj__standard", + ) + + def recent(self, days=30): + """Возвращает объекты, созданные за последние N дней""" + from datetime import timedelta + + cutoff_date = timezone.now() - timedelta(days=days) + return self.filter(created_at__gte=cutoff_date) + + def by_user(self, user): + """Возвращает объекты, созданные указанным пользователем""" + return self.filter(created_by=user) + + +class ObjItemManager(models.Manager): + """Custom Manager для модели ObjItem""" + + def get_queryset(self): + return ObjItemQuerySet(self.model, using=self._db) + + def with_related(self): + """Возвращает queryset с предзагруженными связанными объектами""" + return self.get_queryset().with_related() + + def recent(self, days=30): + """Возвращает недавно созданные объекты""" + return self.get_queryset().recent(days) + + def by_user(self, user): + """Возвращает объекты пользователя""" + return self.get_queryset().by_user(user) + + +class ObjItem(models.Model): + """ + Модель объекта (источника сигнала). + + Центральная модель, объединяющая информацию о ВЧ параметрах, геолокации и типе источника. + """ + + # Основные поля + name = models.CharField( + null=True, + blank=True, + max_length=100, + verbose_name="Имя объекта", + db_index=True, + help_text="Название объекта/источника сигнала", + ) + + # Метаданные + created_at = models.DateTimeField( + auto_now_add=True, + verbose_name="Дата создания", + help_text="Дата и время создания записи", + ) + created_by = models.ForeignKey( + CustomUser, + on_delete=models.SET_NULL, + related_name="objitems_created", + null=True, + blank=True, + verbose_name="Создан пользователем", + help_text="Пользователь, создавший запись", + ) + updated_at = models.DateTimeField( + auto_now=True, + verbose_name="Дата последнего изменения", + help_text="Дата и время последнего изменения", + ) + updated_by = models.ForeignKey( + CustomUser, + on_delete=models.SET_NULL, + related_name="objitems_updated", + null=True, + blank=True, + verbose_name="Изменен пользователем", + help_text="Пользователь, последним изменивший запись", + ) + source_type_id = models.ForeignKey( + SourceType, + on_delete=models.SET_NULL, + related_name="objitems_sourcetype", + null=True, + blank=True, + verbose_name="Тип источника", + help_text="Тип источника сигнала", + ) + + # Custom manager + objects = ObjItemManager() + + def __str__(self): + return f"Объект {self.name}" if self.name else f"Объект #{self.pk}" + + class Meta: + verbose_name = "Объект" + verbose_name_plural = "Объекты" + ordering = ["-updated_at"] + indexes = [ + models.Index(fields=["name"]), + models.Index(fields=["-updated_at"]), + models.Index(fields=["-created_at"]), + ] + + +class Parameter(models.Model): + id_satellite = models.ForeignKey( + Satellite, + on_delete=models.PROTECT, + related_name="parameters", + verbose_name="Спутник", + null=True, + ) + polarization = models.ForeignKey( + Polarization, + default=get_default_polarization, + on_delete=models.SET_DEFAULT, + related_name="polarizations", + null=True, + blank=True, + verbose_name="Поляризация", + ) + frequency = models.FloatField( + default=0, + null=True, + blank=True, + verbose_name="Частота, МГц", + db_index=True, + # validators=[MinValueValidator(0), MaxValueValidator(50000)], + help_text="Центральная частота сигнала", + ) + freq_range = models.FloatField( + default=0, + null=True, + blank=True, + verbose_name="Полоса частот, МГц", + # validators=[MinValueValidator(0), MaxValueValidator(1000)], + help_text="Полоса частот сигнала", + ) + bod_velocity = models.FloatField( + default=0, + null=True, + blank=True, + verbose_name="Символьная скорость, БОД", + # validators=[MinValueValidator(0)], + help_text="Символьная скорость должна быть положительной", + ) + modulation = models.ForeignKey( + Modulation, + default=get_default_modulation, + on_delete=models.SET_DEFAULT, + related_name="modulations", + null=True, + blank=True, + verbose_name="Модуляция", + ) + snr = models.FloatField( + default=0, + null=True, + blank=True, + verbose_name="ОСШ", + # validators=[MinValueValidator(-50), MaxValueValidator(100)], + help_text="Отношение сигнал/шум", + ) + standard = models.ForeignKey( + Standard, + default=get_default_standard, + on_delete=models.SET_DEFAULT, + related_name="standards", + null=True, + blank=True, + verbose_name="Стандарт", + ) + # id_user_add = models.ForeignKey(CustomUser, on_delete=models.SET_NULL, related_name="parameter_added", verbose_name="Пользователь", null=True, blank=True) + objitem = models.OneToOneField( + ObjItem, + on_delete=models.CASCADE, + related_name="parameter_obj", + verbose_name="Объект", + null=True, + blank=True, + help_text="Связанный объект" + ) + # id_sigma_parameter = models.ManyToManyField(SigmaParameter, on_delete=models.SET_NULL, related_name="sigma_parameter", verbose_name="ВЧ с sigma", null=True, blank=True) + # id_sigma_parameter = models.ManyToManyField(SigmaParameter, verbose_name="ВЧ с sigma", null=True, blank=True) + + def clean(self): + """Валидация на уровне модели""" + super().clean() + + # Проверка что частота больше полосы частот + if self.frequency and self.freq_range: + if self.freq_range > self.frequency: + raise ValidationError( + {"freq_range": "Полоса частот не может быть больше частоты"} + ) + + # Проверка что символьная скорость соответствует полосе частот + if self.bod_velocity and self.freq_range: + if self.bod_velocity > self.freq_range * 1000000: # Конвертация МГц в Гц + raise ValidationError( + { + "bod_velocity": "Символьная скорость не может превышать полосу частот" + } + ) + + def __str__(self): + polarization_name = self.polarization.name if self.polarization else "-" + modulation_name = self.modulation.name if self.modulation else "-" + return f"Источник-{self.frequency}:{self.freq_range} МГц:{polarization_name}:{modulation_name}" + + class Meta: + verbose_name = "ВЧ загрузка" + verbose_name_plural = "ВЧ загрузки" + indexes = [ + models.Index(fields=["id_satellite", "frequency"]), + models.Index(fields=["frequency", "polarization"]), + ] + # constraints = [ + # models.UniqueConstraint( + # fields=[ + # 'polarization', 'frequency', 'freq_range', + # 'bod_velocity', 'modulation', 'snr', 'standard' + # ], + # name='unique_parameter_combination' + # ) + # ] + + +class SigmaParameter(models.Model): + TRANSFERS = [(-1.0, "-"), (9750.0, "9750 МГц"), (10750.0, "10750 МГц")] + + id_satellite = models.ForeignKey( + Satellite, + on_delete=models.PROTECT, + related_name="sigmapar_sat", + verbose_name="Спутник", + ) + transfer = models.FloatField( + choices=TRANSFERS, + default=-1.0, + verbose_name="Перенос по частоте", + help_text="Выберите перенос по частоте", + ) + status = models.CharField( + max_length=20, + blank=True, + null=True, + verbose_name="Статус", + help_text="Статус измерения", + ) + frequency = models.FloatField( + default=0, + null=True, + blank=True, + verbose_name="Частота, МГц", + db_index=True, + # validators=[MinValueValidator(0), MaxValueValidator(50000)], + help_text="Центральная частота сигнала", + ) + transfer_frequency = models.GeneratedField( + expression=ExpressionWrapper( + F("frequency") + F("transfer"), output_field=models.FloatField() + ), + output_field=models.FloatField(), + db_persist=True, + null=True, + blank=True, + verbose_name="Частота в Ku, МГц", + ) + freq_range = models.FloatField( + default=0, + null=True, + blank=True, + verbose_name="Полоса частот, МГц", + # validators=[MinValueValidator(0), MaxValueValidator(1000)], + help_text="Полоса частот", + ) + power = models.FloatField( + default=0, + null=True, + blank=True, + verbose_name="Мощность, дБм", + # validators=[MinValueValidator(-100), MaxValueValidator(100)], + help_text="Мощность сигнала", + ) + bod_velocity = models.FloatField( + default=0, + null=True, + blank=True, + verbose_name="Символьная скорость, БОД", + # validators=[MinValueValidator(0)], + help_text="Символьная скорость должна быть положительной", + ) + polarization = models.ForeignKey( + Polarization, + default=get_default_polarization, + on_delete=models.SET_DEFAULT, + related_name="polarizations_sigma", + null=True, + blank=True, + verbose_name="Поляризация", + ) + modulation = models.ForeignKey( + Modulation, + default=get_default_modulation, + on_delete=models.SET_DEFAULT, + related_name="modulations_sigma", + null=True, + blank=True, + verbose_name="Модуляция", + ) + snr = models.FloatField( + default=0, + null=True, + blank=True, + verbose_name="ОСШ, Дб", + validators=[MinValueValidator(-50), MaxValueValidator(100)], + help_text="Отношение сигнал/шум в диапазоне от -50 до 100 дБ", + ) + standard = models.ForeignKey( + Standard, + default=get_default_standard, + on_delete=models.SET_DEFAULT, + related_name="standards_sigma", + null=True, + blank=True, + verbose_name="Стандарт", + ) + packets = models.BooleanField( + null=True, + blank=True, + verbose_name="Пакетность", + help_text="Наличие пакетной передачи", + ) + datetime_begin = models.DateTimeField( + null=True, + blank=True, + verbose_name="Время начала измерения", + help_text="Дата и время начала измерения", + ) + datetime_end = models.DateTimeField( + null=True, + blank=True, + verbose_name="Время окончания измерения", + help_text="Дата и время окончания измерения", + ) + mark = models.ManyToManyField(SigmaParMark, verbose_name="Отметка", blank=True) + parameter = models.ForeignKey( + Parameter, + on_delete=models.SET_NULL, + related_name="sigma_parameter", + verbose_name="ВЧ", + null=True, + blank=True, + ) + + def clean(self): + """Валидация на уровне модели""" + super().clean() + + # Проверка что время окончания больше времени начала + if self.datetime_begin and self.datetime_end: + if self.datetime_end < self.datetime_begin: + raise ValidationError( + {"datetime_end": "Время окончания должно быть позже времени начала"} + ) + + # Проверка что частота больше полосы частот + if self.frequency and self.freq_range: + if self.freq_range > self.frequency: + raise ValidationError( + {"freq_range": "Полоса частот не может быть больше частоты"} + ) + + def __str__(self): + modulation_name = self.modulation.name if self.modulation else "-" + return f"Sigma-{self.frequency}:{self.freq_range} МГц:{modulation_name}" + + class Meta: + verbose_name = "ВЧ sigma" + verbose_name_plural = "ВЧ sigma" + + +class Geo(models.Model): + """ + Модель геолокационных данных. + + Хранит информацию о местоположении источника сигнала, включая координаты, + данные от различных источников (геолокация, кубсат, оперативники) и расстояния между ними. + """ + + # Основные поля + timestamp = models.DateTimeField( + null=True, + blank=True, + verbose_name="Время", + db_index=True, + help_text="Время фиксации геолокации", + ) + location = models.CharField( + max_length=255, + null=True, + blank=True, + verbose_name="Местоположение", + help_text="Текстовое описание местоположения", + ) + comment = models.CharField( + max_length=255, + blank=True, + verbose_name="Комментарий", + help_text="Дополнительные комментарии", + ) + is_average = models.BooleanField( + null=True, + blank=True, + verbose_name="Усреднённое", + help_text="Является ли координата усредненной", + ) + + # Координаты + coords = gis.PointField( + srid=4326, + null=True, + blank=True, + verbose_name="Координата геолокации", + help_text="Основные координаты геолокации (WGS84)", + ) + coords_kupsat = gis.PointField( + srid=4326, + null=True, + blank=True, + verbose_name="Координаты Кубсата", + help_text="Координаты, полученные от кубсата (WGS84)", + ) + coords_valid = gis.PointField( + srid=4326, + null=True, + blank=True, + verbose_name="Координаты оперативников", + help_text="Координаты, предоставленные оперативным отделом (WGS84)", + ) + + # Вычисляемые поля - расстояния + distance_coords_kup = models.GeneratedField( + expression=functions.Distance("coords", "coords_kupsat") / 1000, + output_field=models.FloatField(), + db_persist=True, + null=True, + blank=True, + verbose_name="Расстояние между кубсатом и гео, км", + ) + distance_coords_valid = models.GeneratedField( + expression=functions.Distance("coords", "coords_valid") / 1000, + output_field=models.FloatField(), + db_persist=True, + null=True, + blank=True, + verbose_name="Расстояние между гео и оперативным отделом, км", + ) + distance_kup_valid = models.GeneratedField( + expression=functions.Distance("coords_valid", "coords_kupsat") / 1000, + output_field=models.FloatField(), + db_persist=True, + null=True, + blank=True, + verbose_name="Расстояние между кубсатом и оперативным отделом, км", + ) + + # Связи + mirrors = models.ManyToManyField( + Mirror, + related_name="geo_mirrors", + verbose_name="Зеркала", + blank=True, + help_text="Зеркала антенн, использованные для приема", + ) + objitem = models.OneToOneField( + ObjItem, + on_delete=models.CASCADE, + verbose_name="Объект", + related_name="geo_obj", + null=True, + help_text="Связанный объект", + ) + + def __str__(self): + if self.coords: + longitude = self.coords.coords[0] + latitude = self.coords.coords[1] + lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" + lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" + location_str = f", {self.location}" if self.location else "" + return f"{lat} {lon}{location_str}" + return f"Гео #{self.pk}" + + class Meta: + verbose_name = "Гео" + verbose_name_plural = "Гео" + ordering = ["-timestamp"] + indexes = [ + models.Index(fields=["-timestamp"]), + models.Index(fields=["location"]), + ] + constraints = [ + models.UniqueConstraint( + fields=["timestamp", "coords"], name="unique_geo_combination" + ) + ] diff --git a/dbapp/mainapp/popup_filters.py b/dbapp/mainapp/popup_filters.py index bdbdf7e..6b7e79c 100644 --- a/dbapp/mainapp/popup_filters.py +++ b/dbapp/mainapp/popup_filters.py @@ -1,76 +1,76 @@ -# Django imports -from django.contrib.admin.filters import ChoicesFieldListFilter -from django.forms import Media - - -class PopupCompatibleMultiSelectRelatedDropdownFilter(ChoicesFieldListFilter): - """ - A custom filter that maintains popup context when used in raw_id_fields modals. - """ - - def __init__(self, field, request, params, model, model_admin, field_path): - super().__init__(field, request, params, model, model_admin, field_path) - - # Check if we're in a popup context - self.is_popup = '_popup' in request.GET or 'pop' in request.GET or 'admin' not in request.path - - # Get all choices (related objects) - self.lookup_choices = field.get_choices(include_blank=False) - - def has_output(self): - return len(self.lookup_choices) > 1 - - def value(self): - return self.lookup_val - - def expected_parameters(self): - return [self.lookup_kwarg, self.lookup_kwarg_isnull] - - def choices(self, changelist): - # If in popup, preserve the popup parameters in the filter URL - popup_params = {} - if self.is_popup: - # Preserve popup parameters - if '_popup' in changelist.params: - popup_params['_popup'] = 1 - if 'pop' in changelist.params: - popup_params['pop'] = changelist.params['pop'] - if '_to_field' in changelist.params: - popup_params['_to_field'] = changelist.params['_to_field'] - - # Create the base URL with popup parameters - all_params = changelist.get_filters_params() - all_params.update(popup_params) - - # Generate the URL for the filter - url = changelist.get_query_string(all_params, [self.lookup_kwarg]) - - yield { - 'selected': self.lookup_val is None, - 'query_string': url, - 'display': 'All', - } - - # Add choices - for lookup, title in self.lookup_choices: - params = dict(all_params) - params[self.lookup_kwarg] = lookup - - # Remove the parameter if it's being set to the same value (for unselecting) - if self.lookup_val == str(lookup): - params.pop(self.lookup_kwarg, None) - - # Add popup parameters to each choice URL - choice_params = params.copy() - choice_params.update(popup_params) - - yield { - 'selected': str(lookup) == self.lookup_val, - 'query_string': changelist.get_query_string(choice_params, [self.lookup_kwarg_isnull]), - 'display': title, - } - - @property - def media(self): - # Include necessary CSS/JS for dropdown functionality if needed +# Django imports +from django.contrib.admin.filters import ChoicesFieldListFilter +from django.forms import Media + + +class PopupCompatibleMultiSelectRelatedDropdownFilter(ChoicesFieldListFilter): + """ + A custom filter that maintains popup context when used in raw_id_fields modals. + """ + + def __init__(self, field, request, params, model, model_admin, field_path): + super().__init__(field, request, params, model, model_admin, field_path) + + # Check if we're in a popup context + self.is_popup = '_popup' in request.GET or 'pop' in request.GET or 'admin' not in request.path + + # Get all choices (related objects) + self.lookup_choices = field.get_choices(include_blank=False) + + def has_output(self): + return len(self.lookup_choices) > 1 + + def value(self): + return self.lookup_val + + def expected_parameters(self): + return [self.lookup_kwarg, self.lookup_kwarg_isnull] + + def choices(self, changelist): + # If in popup, preserve the popup parameters in the filter URL + popup_params = {} + if self.is_popup: + # Preserve popup parameters + if '_popup' in changelist.params: + popup_params['_popup'] = 1 + if 'pop' in changelist.params: + popup_params['pop'] = changelist.params['pop'] + if '_to_field' in changelist.params: + popup_params['_to_field'] = changelist.params['_to_field'] + + # Create the base URL with popup parameters + all_params = changelist.get_filters_params() + all_params.update(popup_params) + + # Generate the URL for the filter + url = changelist.get_query_string(all_params, [self.lookup_kwarg]) + + yield { + 'selected': self.lookup_val is None, + 'query_string': url, + 'display': 'All', + } + + # Add choices + for lookup, title in self.lookup_choices: + params = dict(all_params) + params[self.lookup_kwarg] = lookup + + # Remove the parameter if it's being set to the same value (for unselecting) + if self.lookup_val == str(lookup): + params.pop(self.lookup_kwarg, None) + + # Add popup parameters to each choice URL + choice_params = params.copy() + choice_params.update(popup_params) + + yield { + 'selected': str(lookup) == self.lookup_val, + 'query_string': changelist.get_query_string(choice_params, [self.lookup_kwarg_isnull]), + 'display': title, + } + + @property + def media(self): + # Include necessary CSS/JS for dropdown functionality if needed return Media() \ No newline at end of file diff --git a/dbapp/mainapp/signals.py b/dbapp/mainapp/signals.py index 875fc48..b41d8b6 100644 --- a/dbapp/mainapp/signals.py +++ b/dbapp/mainapp/signals.py @@ -1,14 +1,14 @@ -# Django imports -from django.contrib.auth.models import User -from django.db.models.signals import post_save -from django.dispatch import receiver - -# Local imports -from .models import CustomUser - - -@receiver(post_save, sender=User) -def create_or_update_user_profile(sender, instance, created, **kwargs): - if created: - CustomUser.objects.create(user=instance) +# Django imports +from django.contrib.auth.models import User +from django.db.models.signals import post_save +from django.dispatch import receiver + +# Local imports +from .models import CustomUser + + +@receiver(post_save, sender=User) +def create_or_update_user_profile(sender, instance, created, **kwargs): + if created: + CustomUser.objects.create(user=instance) instance.customuser.save() \ No newline at end of file diff --git a/dbapp/mainapp/tasks.py b/dbapp/mainapp/tasks.py new file mode 100644 index 0000000..7c57222 --- /dev/null +++ b/dbapp/mainapp/tasks.py @@ -0,0 +1,65 @@ +""" +Simple test tasks for Celery functionality. +""" +import time +import logging +from celery import shared_task + +logger = logging.getLogger(__name__) + + +@shared_task(name='mainapp.test_celery_connection') +def test_celery_connection(message="Hello from Celery!"): + """ + A simple test task to verify Celery is working. + + Args: + message (str): Message to return + + Returns: + str: Confirmation message with task completion time + """ + logger.info(f"Test task started with message: {message}") + time.sleep(2) # Simulate some work + result = f"Task completed! Received message: {message}" + logger.info(f"Test task completed: {result}") + return result + + +@shared_task(name='mainapp.add_numbers') +def add_numbers(x, y): + """ + A simple addition task to test Celery functionality. + + Args: + x (int): First number + y (int): Second number + + Returns: + int: Sum of x and y + """ + logger.info(f"Adding {x} + {y}") + result = x + y + logger.info(f"Addition completed: {x} + {y} = {result}") + return result + + +@shared_task(name='mainapp.long_running_task') +def long_running_task(duration=10): + """ + A task that runs for a specified duration to test long-running tasks. + + Args: + duration (int): Duration in seconds + + Returns: + str: Completion message + """ + logger.info(f"Starting long running task for {duration} seconds") + for i in range(duration): + time.sleep(1) + logger.info(f"Long task progress: {i+1}/{duration}") + + result = f"Long running task completed after {duration} seconds" + logger.info(result) + return result diff --git a/dbapp/mainapp/templates/admin/map_custom.html b/dbapp/mainapp/templates/admin/map_custom.html index 1b03449..4225408 100644 --- a/dbapp/mainapp/templates/admin/map_custom.html +++ b/dbapp/mainapp/templates/admin/map_custom.html @@ -1,60 +1,60 @@ -{% extends "mapsapp/map2d_base.html" %} -{% load static %} -{% block title %}Вынос точек{% endblock title %} - -{% block extra_js %} - +{% extends "mapsapp/map2d_base.html" %} +{% load static %} +{% block title %}Вынос точек{% endblock title %} + +{% block extra_js %} + {% endblock extra_js %} \ No newline at end of file diff --git a/dbapp/mainapp/templates/mainapp/actions.html b/dbapp/mainapp/templates/mainapp/actions.html index 4cccf8f..d940219 100644 --- a/dbapp/mainapp/templates/mainapp/actions.html +++ b/dbapp/mainapp/templates/mainapp/actions.html @@ -1,189 +1,189 @@ -{% extends 'mainapp/base.html' %} - -{% block title %}Действия{% endblock %} - -{% block content %} -
-
-

Действия

-

Управление данными спутников

-
- - - {% include 'mainapp/components/_messages.html' %} - - -
- -
-
-
-
-
- - - - -
-

Загрузка данных из Excel

-
-

Загрузите данные из Excel-файла в базу данных. Поддерживается выбор спутника и ограничение количества записей.

- - Перейти к загрузке данных - -
-
-
- - -
-
-
-
-
- - - - -
-

Загрузка данных из CSV

-
-

Загрузите данные из CSV-файла в базу данных. Простая загрузка с возможностью указания пути к файлу.

- - Перейти к загрузке данных - -
-
-
- - -
-
-
-
-
- - - - - - - - - -
-

Добавление списка спутников

-
-

Добавьте новый список спутников в базу данных для последующего использования в загрузке данных.

- - Добавить список спутников - -
-
-
- - -
-
-
-
-
- - - - -
-

Добавление транспондеров

-
-

Добавьте список транспондеров из JSON-файла в базу данных. Требуется наличие файла transponders.json.

- - Добавить транспондеры - -
-
-
- - -
-
-
-
-
- - - - -
-

Добавление данных ВЧ загрузки

-
-

Загрузите данные ВЧ загрузки из HTML-файла с таблицами. Поддерживается выбор спутника для привязки данных.

- - Добавить данные ВЧ загрузки - -
-
-
- - -
-
-
-
-
- - - - -
-

Заполнение данных Lyngsat

-
-

Загрузите данные о транспондерах спутников с сайта Lyngsat. Выберите спутники и регионы для парсинга данных.

- - Заполнить данные Lyngsat - -
-
-
- - -
-
-
-
-
- - - -
-

Привязка ВЧ загрузки

-
-

Привязка ВЧ загрузки с sigma

- - Открыть форму - -
-
-
- - -
-
-
-
-
- - - -
-

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

-
-

Добавьте новое событие с помощью выбора спутника и загрузки файла данных.

- - Добавить событие - -
-
-
-
-
+{% extends 'mainapp/base.html' %} + +{% block title %}Действия{% endblock %} + +{% block content %} +
+
+

Действия

+

Управление данными спутников

+
+ + + {% include 'mainapp/components/_messages.html' %} + + +
+ +
+
+
+
+
+ + + + +
+

Загрузка данных из Excel

+
+

Загрузите данные из Excel-файла в базу данных. Поддерживается выбор спутника и ограничение количества записей.

+ + Перейти к загрузке данных + +
+
+
+ + +
+
+
+
+
+ + + + +
+

Загрузка данных из CSV

+
+

Загрузите данные из CSV-файла в базу данных. Простая загрузка с возможностью указания пути к файлу.

+ + Перейти к загрузке данных + +
+
+
+ + +
+
+
+
+
+ + + + + + + + + +
+

Добавление списка спутников

+
+

Добавьте новый список спутников в базу данных для последующего использования в загрузке данных.

+ + Добавить список спутников + +
+
+
+ + +
+
+
+
+
+ + + + +
+

Добавление транспондеров

+
+

Добавьте список транспондеров из JSON-файла в базу данных. Требуется наличие файла transponders.json.

+ + Добавить транспондеры + +
+
+
+ + +
+
+
+
+
+ + + + +
+

Добавление данных ВЧ загрузки

+
+

Загрузите данные ВЧ загрузки из HTML-файла с таблицами. Поддерживается выбор спутника для привязки данных.

+ + Добавить данные ВЧ загрузки + +
+
+
+ + +
+
+
+
+
+ + + + +
+

Заполнение данных Lyngsat

+
+

Загрузите данные о транспондерах спутников с сайта Lyngsat. Выберите спутники и регионы для парсинга данных.

+ + Заполнить данные Lyngsat + +
+
+
+ + +
+
+
+
+
+ + + +
+

Привязка ВЧ загрузки

+
+

Привязка ВЧ загрузки с sigma

+ + Открыть форму + +
+
+
+ + +
+
+
+
+
+ + + +
+

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

+
+

Добавьте новое событие с помощью выбора спутника и загрузки файла данных.

+ + Добавить событие + +
+
+
+
+
{% endblock %} \ No newline at end of file diff --git a/dbapp/mainapp/templates/mainapp/add_data_from_csv.html b/dbapp/mainapp/templates/mainapp/add_data_from_csv.html index 96fb5c3..fecb1e5 100644 --- a/dbapp/mainapp/templates/mainapp/add_data_from_csv.html +++ b/dbapp/mainapp/templates/mainapp/add_data_from_csv.html @@ -1,34 +1,34 @@ -{% extends 'mainapp/base.html' %} - -{% block title %}Загрузка данных из CSV{% endblock %} - -{% block content %} -
-
-
-
-
-

Загрузка данных из CSV

-
-
- {% include 'mainapp/components/_messages.html' %} - -

Загрузите CSV-файл для загрузки данных в базу.

- -
- {% csrf_token %} - - - {% include 'mainapp/components/_form_field.html' with field=form.file %} - -
- Назад - -
-
-
-
-
-
-
+{% extends 'mainapp/base.html' %} + +{% block title %}Загрузка данных из CSV{% endblock %} + +{% block content %} +
+
+
+
+
+

Загрузка данных из CSV

+
+
+ {% include 'mainapp/components/_messages.html' %} + +

Загрузите CSV-файл для загрузки данных в базу.

+ +
+ {% csrf_token %} + + + {% include 'mainapp/components/_form_field.html' with field=form.file %} + +
+ Назад + +
+
+
+
+
+
+
{% endblock %} \ No newline at end of file diff --git a/dbapp/mainapp/templates/mainapp/add_data_from_excel.html b/dbapp/mainapp/templates/mainapp/add_data_from_excel.html index 90865c6..fd90dc5 100644 --- a/dbapp/mainapp/templates/mainapp/add_data_from_excel.html +++ b/dbapp/mainapp/templates/mainapp/add_data_from_excel.html @@ -1,36 +1,36 @@ -{% extends 'mainapp/base.html' %} - -{% block title %}Загрузка данных из Excel{% endblock %} - -{% block content %} -
-
-
-
-
-

Загрузка данных из Excel

-
-
- {% include 'mainapp/components/_messages.html' %} - -

Загрузите Excel-файл и выберите спутник для загрузки данных в базу.

- -
- {% csrf_token %} - - - {% include 'mainapp/components/_form_field.html' with field=form.file %} - {% include 'mainapp/components/_form_field.html' with field=form.sat_choice %} - {% include 'mainapp/components/_form_field.html' with field=form.number_input %} - -
- Назад - -
-
-
-
-
-
-
+{% extends 'mainapp/base.html' %} + +{% block title %}Загрузка данных из Excel{% endblock %} + +{% block content %} +
+
+
+
+
+

Загрузка данных из Excel

+
+
+ {% include 'mainapp/components/_messages.html' %} + +

Загрузите Excel-файл и выберите спутник для загрузки данных в базу.

+ +
+ {% csrf_token %} + + + {% include 'mainapp/components/_form_field.html' with field=form.file %} + {% include 'mainapp/components/_form_field.html' with field=form.sat_choice %} + {% include 'mainapp/components/_form_field.html' with field=form.number_input %} + +
+ Назад + +
+
+
+
+
+
+
{% endblock %} \ No newline at end of file diff --git a/dbapp/mainapp/templates/mainapp/base.html b/dbapp/mainapp/templates/mainapp/base.html index eb61563..689324a 100644 --- a/dbapp/mainapp/templates/mainapp/base.html +++ b/dbapp/mainapp/templates/mainapp/base.html @@ -1,42 +1,42 @@ -{% load static %} - - - - - - - - {% block title %}Геолокация{% endblock %} - - - - - - - - - {% block extra_css %}{% endblock %} - - - - - {% include 'mainapp/components/_navbar.html' %} - - -
- {% include 'mainapp/components/_messages.html' %} -
- - -
- {% block content %}{% endblock %} -
- - - - - - {% block extra_js %}{% endblock %} - - +{% load static %} + + + + + + + + {% block title %}Геолокация{% endblock %} + + + + + + + + + {% block extra_css %}{% endblock %} + + + + + {% include 'mainapp/components/_navbar.html' %} + + +
+ {% include 'mainapp/components/_messages.html' %} +
+ + +
+ {% block content %}{% endblock %} +
+ + + + + + {% block extra_js %}{% endblock %} + + \ No newline at end of file diff --git a/dbapp/mainapp/templates/mainapp/components/_form_field.html b/dbapp/mainapp/templates/mainapp/components/_form_field.html index 530ffe5..cb07616 100644 --- a/dbapp/mainapp/templates/mainapp/components/_form_field.html +++ b/dbapp/mainapp/templates/mainapp/components/_form_field.html @@ -1,33 +1,33 @@ -{% comment %} -Переиспользуемый компонент для отображения полей формы -Использование: - {% include 'mainapp/components/_form_field.html' with field=form.field_name %} - {% include 'mainapp/components/_form_field.html' with field=form.field_name label_class="custom-label" %} -{% endcomment %} - -
- - - {% if field.field.widget.input_type == 'checkbox' %} -
- {{ field }} -
- {% else %} - {{ field }} - {% endif %} - - {% if field.errors %} -
- {% for error in field.errors %} - {{ error }} - {% endfor %} -
- {% endif %} - - {% if field.help_text %} - {{ field.help_text }} - {% endif %} -
+{% comment %} +Переиспользуемый компонент для отображения полей формы +Использование: + {% include 'mainapp/components/_form_field.html' with field=form.field_name %} + {% include 'mainapp/components/_form_field.html' with field=form.field_name label_class="custom-label" %} +{% endcomment %} + +
+ + + {% if field.field.widget.input_type == 'checkbox' %} +
+ {{ field }} +
+ {% else %} + {{ field }} + {% endif %} + + {% if field.errors %} +
+ {% for error in field.errors %} + {{ error }} + {% endfor %} +
+ {% endif %} + + {% if field.help_text %} + {{ field.help_text }} + {% endif %} +
diff --git a/dbapp/mainapp/templates/mainapp/components/_messages.html b/dbapp/mainapp/templates/mainapp/components/_messages.html index fb2e43a..aa4298a 100644 --- a/dbapp/mainapp/templates/mainapp/components/_messages.html +++ b/dbapp/mainapp/templates/mainapp/components/_messages.html @@ -1,25 +1,25 @@ -{% comment %} -Переиспользуемый компонент для отображения сообщений Django -Использование: - {% include 'mainapp/components/_messages.html' %} -{% endcomment %} - -{% if messages %} -
- {% for message in messages %} - - {% endfor %} -
-{% endif %} +{% comment %} +Переиспользуемый компонент для отображения сообщений Django +Использование: + {% include 'mainapp/components/_messages.html' %} +{% endcomment %} + +{% if messages %} +
+ {% for message in messages %} + + {% endfor %} +
+{% endif %} diff --git a/dbapp/mainapp/templates/mainapp/components/_navbar.html b/dbapp/mainapp/templates/mainapp/components/_navbar.html index b3674ee..722ea46 100644 --- a/dbapp/mainapp/templates/mainapp/components/_navbar.html +++ b/dbapp/mainapp/templates/mainapp/components/_navbar.html @@ -1,57 +1,57 @@ -{% comment %} -Переиспользуемый компонент навигационной панели -Использование: - {% include 'mainapp/components/_navbar.html' %} -{% endcomment %} - - +{% comment %} +Переиспользуемый компонент навигационной панели +Использование: + {% include 'mainapp/components/_navbar.html' %} +{% endcomment %} + + diff --git a/dbapp/mainapp/templates/mainapp/components/_table_header.html b/dbapp/mainapp/templates/mainapp/components/_table_header.html index d1083cc..78b9b47 100644 --- a/dbapp/mainapp/templates/mainapp/components/_table_header.html +++ b/dbapp/mainapp/templates/mainapp/components/_table_header.html @@ -1,32 +1,32 @@ -{% comment %} -Переиспользуемый компонент для заголовков таблиц с сортировкой -Использование: - {% include 'mainapp/components/_table_header.html' with label="Имя" field="name" sort=sort %} - {% include 'mainapp/components/_table_header.html' with label="Частота" field="frequency" sort=sort sortable=False %} -{% endcomment %} - - - {% if sortable != False %} - - {{ label }} - {% if sort == field %} - - - - - {% elif sort == '-'|add:field %} - - - - - {% else %} - - {% endif %} - - {% else %} - {{ label }} - {% endif %} - +{% comment %} +Переиспользуемый компонент для заголовков таблиц с сортировкой +Использование: + {% include 'mainapp/components/_table_header.html' with label="Имя" field="name" sort=sort %} + {% include 'mainapp/components/_table_header.html' with label="Частота" field="frequency" sort=sort sortable=False %} +{% endcomment %} + + + {% if sortable != False %} + + {{ label }} + {% if sort == field %} + + + + + {% elif sort == '-'|add:field %} + + + + + {% else %} + + {% endif %} + + {% else %} + {{ label }} + {% endif %} + diff --git a/dbapp/mainapp/templates/mainapp/fill_lyngsat_data.html b/dbapp/mainapp/templates/mainapp/fill_lyngsat_data.html index e90b9f8..4330df3 100644 --- a/dbapp/mainapp/templates/mainapp/fill_lyngsat_data.html +++ b/dbapp/mainapp/templates/mainapp/fill_lyngsat_data.html @@ -1,118 +1,118 @@ -{% extends 'mainapp/base.html' %} - -{% block title %}Заполнение данных Lyngsat{% endblock %} - -{% block content %} -
-
-
-
-
-

- - - - - Заполнение данных из Lyngsat -

-
-
- - {% include 'mainapp/components/_messages.html' %} - - - -
- {% csrf_token %} - - -
- - {{ form.satellites }} - {% if form.satellites.help_text %} -
{{ form.satellites.help_text }}
- {% endif %} - {% if form.satellites.errors %} -
- {{ form.satellites.errors }} -
- {% endif %} -
- - -
- - {{ form.regions }} - {% if form.regions.help_text %} -
{{ form.regions.help_text }}
- {% endif %} - {% if form.regions.errors %} -
- {{ form.regions.errors }} -
- {% endif %} -
- - -
- - - - - Назад - - -
-
-
-
- - -
-
-
Информация
-

- Эта форма позволяет загрузить данные о транспондерах спутников с сайта Lyngsat. - Выберите один или несколько спутников и регионы для парсинга данных. -

-
    -
  • Данные включают частоты, поляризацию, модуляцию, стандарты и другие параметры
  • -
  • Процесс может занять несколько минут в зависимости от количества выбранных спутников
  • -
  • Существующие записи будут обновлены, новые - созданы
  • -
-
-
-
-
-
- - -{% endblock %} +{% extends 'mainapp/base.html' %} + +{% block title %}Заполнение данных Lyngsat{% endblock %} + +{% block content %} +
+
+
+
+
+

+ + + + + Заполнение данных из Lyngsat +

+
+
+ + {% include 'mainapp/components/_messages.html' %} + + + +
+ {% csrf_token %} + + +
+ + {{ form.satellites }} + {% if form.satellites.help_text %} +
{{ form.satellites.help_text }}
+ {% endif %} + {% if form.satellites.errors %} +
+ {{ form.satellites.errors }} +
+ {% endif %} +
+ + +
+ + {{ form.regions }} + {% if form.regions.help_text %} +
{{ form.regions.help_text }}
+ {% endif %} + {% if form.regions.errors %} +
+ {{ form.regions.errors }} +
+ {% endif %} +
+ + +
+ + + + + Назад + + +
+
+
+
+ + +
+
+
Информация
+

+ Эта форма позволяет загрузить данные о транспондерах спутников с сайта Lyngsat. + Выберите один или несколько спутников и регионы для парсинга данных. +

+
    +
  • Данные включают частоты, поляризацию, модуляцию, стандарты и другие параметры
  • +
  • Процесс может занять несколько минут в зависимости от количества выбранных спутников
  • +
  • Существующие записи будут обновлены, новые - созданы
  • +
+
+
+
+
+
+ + +{% endblock %} diff --git a/dbapp/mainapp/templates/mainapp/link_vch.html b/dbapp/mainapp/templates/mainapp/link_vch.html index a5d01be..2d1b3b7 100644 --- a/dbapp/mainapp/templates/mainapp/link_vch.html +++ b/dbapp/mainapp/templates/mainapp/link_vch.html @@ -1,68 +1,68 @@ -{% extends 'mainapp/base.html' %} - -{% block title %}Привязка ВЧ{% endblock %} - -{% block content %} -
-
-
-
-
-

Привязка ВЧ загрузки

-
-
- {% if messages %} - {% for message in messages %} - - {% endfor %} - {% endif %} - -

Введите допустимый разброс для частоты и полосы

- -
- {% csrf_token %} -
- - {{ form.sat_choice }} - {% if form.sat_choice.errors %} -
{{ form.sat_choice.errors }}
- {% endif %} -
- {% comment %}
- - {{ form.ku_range }} - {% if form.ku_range.errors %} -
{{ form.ku_range.errors }}
- {% endif %} -
{% endcomment %} -
- - {{ form.value1 }} - {% if form.value1.errors %} -
{{ form.value1.errors }}
- {% endif %} -
- -
- - {{ form.value2 }} - {% if form.value2.errors %} -
{{ form.value2.errors }}
- {% endif %} -
- -
- Назад - {% comment %} Сбросить привязку {% endcomment %} - -
-
-
-
-
-
-
+{% extends 'mainapp/base.html' %} + +{% block title %}Привязка ВЧ{% endblock %} + +{% block content %} +
+
+
+
+
+

Привязка ВЧ загрузки

+
+
+ {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + +

Введите допустимый разброс для частоты и полосы

+ +
+ {% csrf_token %} +
+ + {{ form.sat_choice }} + {% if form.sat_choice.errors %} +
{{ form.sat_choice.errors }}
+ {% endif %} +
+ {% comment %}
+ + {{ form.ku_range }} + {% if form.ku_range.errors %} +
{{ form.ku_range.errors }}
+ {% endif %} +
{% endcomment %} +
+ + {{ form.value1 }} + {% if form.value1.errors %} +
{{ form.value1.errors }}
+ {% endif %} +
+ +
+ + {{ form.value2 }} + {% if form.value2.errors %} +
{{ form.value2.errors }}
+ {% endif %} +
+ +
+ Назад + {% comment %} Сбросить привязку {% endcomment %} + +
+
+
+
+
+
+
{% endblock %} \ No newline at end of file diff --git a/dbapp/mainapp/templates/mainapp/lyngsat_task_status.html b/dbapp/mainapp/templates/mainapp/lyngsat_task_status.html index 41f1c36..6b1e176 100644 --- a/dbapp/mainapp/templates/mainapp/lyngsat_task_status.html +++ b/dbapp/mainapp/templates/mainapp/lyngsat_task_status.html @@ -1,241 +1,241 @@ -{% extends 'mainapp/base.html' %} - -{% block title %}Статус задачи Lyngsat{% endblock %} - -{% block content %} -
-
-
-
-
-

- - - - Статус задачи заполнения данных Lyngsat -

-
-
- {% if task_id %} -
- ID задачи: {{ task_id }} -
- - -
-
- Загрузка статуса... - 0% -
-
-
- 0% -
-
-
- - - - - -
-
Результаты обработки
-
-
-
-
-
Обработано спутников
-

-

-
-
-
-
-
-
-
Обработано источников
-

-

-
-
-
-
-
-
-
Создано записей
-

-

-
-
-
-
-
-
-
Обновлено записей
-

-

-
-
-
-
- - -
-
Ошибки при обработке:
-
-
    -
    -
    -
    - - - - - - - {% else %} - - - Перейти к форме - - {% endif %} -
    -
    -
    -
    -
    - -{% if task_id %} - -{% endif %} -{% endblock %} +{% extends 'mainapp/base.html' %} + +{% block title %}Статус задачи Lyngsat{% endblock %} + +{% block content %} +
    +
    +
    +
    +
    +

    + + + + Статус задачи заполнения данных Lyngsat +

    +
    +
    + {% if task_id %} +
    + ID задачи: {{ task_id }} +
    + + +
    +
    + Загрузка статуса... + 0% +
    +
    +
    + 0% +
    +
    +
    + + + + + +
    +
    Результаты обработки
    +
    +
    +
    +
    +
    Обработано спутников
    +

    -

    +
    +
    +
    +
    +
    +
    +
    Обработано источников
    +

    -

    +
    +
    +
    +
    +
    +
    +
    Создано записей
    +

    -

    +
    +
    +
    +
    +
    +
    +
    Обновлено записей
    +

    -

    +
    +
    +
    +
    + + +
    +
    Ошибки при обработке:
    +
    +
      +
      +
      +
      + + + + + + + {% else %} + + + Перейти к форме + + {% endif %} +
      +
      +
      +
      +
      + +{% if task_id %} + +{% endif %} +{% endblock %} diff --git a/dbapp/mainapp/templates/mainapp/objitem_confirm_delete.html b/dbapp/mainapp/templates/mainapp/objitem_confirm_delete.html index 08946a0..6d9f070 100644 --- a/dbapp/mainapp/templates/mainapp/objitem_confirm_delete.html +++ b/dbapp/mainapp/templates/mainapp/objitem_confirm_delete.html @@ -1,25 +1,25 @@ -{% extends 'mainapp/base.html' %} - -{% block title %}Удалить объект{% endblock %} - -{% block content %} -
      -
      -
      -

      Удалить объект "{{ object }}"?

      -
      -
      -
      -
      -
      - {% csrf_token %} -

      Вы уверены, что хотите удалить этот объект? Это действие нельзя будет отменить.

      -
      - - Отмена -
      -
      -
      -
      -
      -{% endblock %} +{% extends 'mainapp/base.html' %} + +{% block title %}Удалить объект{% endblock %} + +{% block content %} +
      +
      +
      +

      Удалить объект "{{ object }}"?

      +
      +
      +
      +
      +
      + {% csrf_token %} +

      Вы уверены, что хотите удалить этот объект? Это действие нельзя будет отменить.

      +
      + + Отмена +
      +
      +
      +
      +
      +{% endblock %} diff --git a/dbapp/mainapp/templates/mainapp/objitem_detail.html b/dbapp/mainapp/templates/mainapp/objitem_detail.html index 4effc6f..c7438d2 100644 --- a/dbapp/mainapp/templates/mainapp/objitem_detail.html +++ b/dbapp/mainapp/templates/mainapp/objitem_detail.html @@ -1,473 +1,473 @@ -{% extends 'mainapp/base.html' %} -{% load static %} -{% load static leaflet_tags %} -{% load l10n %} - -{% block title %}Просмотр объекта: {{ object.name }}{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
      -
      -
      -

      Просмотр объекта: {{ object.name }}

      - -
      -
      - - -
      -
      -

      Основная информация

      -
      -
      -
      -
      - -
      {{ object.name|default:"-" }}
      -
      -
      -
      -
      - -
      - {% if object.created_at %}{{ object.created_at|date:"d.m.Y H:i" }}{% else %}-{% endif %} -
      -
      -
      -
      -
      -
      -
      - -
      - {% if object.created_by %}{{ object.created_by }}{% else %}-{% endif %} -
      -
      -
      -
      -
      - -
      - {% if object.updated_at %}{{ object.updated_at|date:"d.m.Y H:i" }}{% else %}-{% endif %} -
      -
      -
      -
      -
      -
      -
      - -
      - {% if object.updated_by %}{{ object.updated_by }}{% else %}-{% endif %} -
      -
      -
      -
      -
      - - -
      -
      -

      ВЧ загрузка

      -
      - - {% if object.parameter_obj %} -
      -
      -
      - -
      {{ object.parameter_obj.id_satellite.name|default:"-" }}
      -
      -
      -
      -
      - -
      {{ object.parameter_obj.frequency|default:"-" }}
      -
      -
      -
      -
      - -
      {{ object.parameter_obj.freq_range|default:"-" }}
      -
      -
      -
      -
      - -
      {{ object.parameter_obj.polarization.name|default:"-" }}
      -
      -
      -
      -
      -
      -
      - -
      {{ object.parameter_obj.bod_velocity|default:"-" }}
      -
      -
      -
      -
      - -
      {{ object.parameter_obj.modulation.name|default:"-" }}
      -
      -
      -
      -
      - -
      {{ object.parameter_obj.snr|default:"-" }}
      -
      -
      -
      -
      - -
      {{ object.parameter_obj.standard.name|default:"-" }}
      -
      -
      -
      - {% else %} -
      -

      Нет данных о ВЧ загрузке

      -
      - {% endif %} -
      - - -
      -
      -

      Карта

      -
      -
      -
      -
      -
      - - -
      -
      -

      Геоданные

      -
      - - {% if object.geo_obj %} - -
      -
      Координаты геолокации
      -
      -
      -
      - -
      - {% if object.geo_obj.coords %}{{ object.geo_obj.coords.y|floatformat:6 }}{% else %}-{% endif %} -
      -
      -
      -
      - -
      - {% if object.geo_obj.coords %}{{ object.geo_obj.coords.x|floatformat:6 }}{% else %}-{% endif %} -
      -
      -
      -
      - - -
      -
      Координаты Кубсата
      -
      -
      -
      - -
      - {% if object.geo_obj.coords_kupsat %}{{ object.geo_obj.coords_kupsat.x|floatformat:6 }}{% else %}-{% endif %} -
      -
      -
      -
      -
      - -
      - {% if object.geo_obj.coords_kupsat %}{{ object.geo_obj.coords_kupsat.y|floatformat:6 }}{% else %}-{% endif %} -
      -
      -
      -
      -
      - - -
      -
      Координаты оперативников
      -
      -
      -
      - -
      - {% if object.geo_obj.coords_valid %}{{ object.geo_obj.coords_valid.x|floatformat:6 }}{% else %}-{% endif %} -
      -
      -
      -
      -
      - -
      - {% if object.geo_obj.coords_valid %}{{ object.geo_obj.coords_valid.y|floatformat:6 }}{% else %}-{% endif %} -
      -
      -
      -
      -
      - -
      -
      -
      - -
      {{ object.geo_obj.location|default:"-" }}
      -
      -
      -
      -
      - -
      {{ object.geo_obj.comment|default:"-" }}
      -
      -
      -
      - -
      -
      -
      - -
      - {% if object.geo_obj.timestamp %}{{ object.geo_obj.timestamp|date:"d.m.Y H:i" }}{% else %}-{% endif %} -
      -
      -
      -
      - -
      - {% if object.geo_obj.is_average %}Да{% else %}Нет{% endif %} -
      -
      -
      -
      - -
      -
      -
      - -
      - {% if object.geo_obj.distance_coords_kup is not None %} - {{ object.geo_obj.distance_coords_kup|floatformat:2 }} - {% else %} - - - {% endif %} -
      -
      -
      -
      -
      - -
      - {% if object.geo_obj.distance_coords_valid is not None %} - {{ object.geo_obj.distance_coords_valid|floatformat:2 }} - {% else %} - - - {% endif %} -
      -
      -
      -
      -
      - -
      - {% if object.geo_obj.distance_kup_valid is not None %} - {{ object.geo_obj.distance_kup_valid|floatformat:2 }} - {% else %} - - - {% endif %} -
      -
      -
      -
      - {% else %} -

      Нет данных о геолокации

      - {% endif %} -
      -
      - -
      - {% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %} - Редактировать - {% endif %} - Назад -
      -
      -{% endblock %} - -{% block extra_js %} -{{ block.super }} - -{% leaflet_js %} -{% leaflet_css %} - - - +{% extends 'mainapp/base.html' %} +{% load static %} +{% load static leaflet_tags %} +{% load l10n %} + +{% block title %}Просмотр объекта: {{ object.name }}{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
      +
      +
      +

      Просмотр объекта: {{ object.name }}

      + +
      +
      + + +
      +
      +

      Основная информация

      +
      +
      +
      +
      + +
      {{ object.name|default:"-" }}
      +
      +
      +
      +
      + +
      + {% if object.created_at %}{{ object.created_at|date:"d.m.Y H:i" }}{% else %}-{% endif %} +
      +
      +
      +
      +
      +
      +
      + +
      + {% if object.created_by %}{{ object.created_by }}{% else %}-{% endif %} +
      +
      +
      +
      +
      + +
      + {% if object.updated_at %}{{ object.updated_at|date:"d.m.Y H:i" }}{% else %}-{% endif %} +
      +
      +
      +
      +
      +
      +
      + +
      + {% if object.updated_by %}{{ object.updated_by }}{% else %}-{% endif %} +
      +
      +
      +
      +
      + + +
      +
      +

      ВЧ загрузка

      +
      + + {% if object.parameter_obj %} +
      +
      +
      + +
      {{ object.parameter_obj.id_satellite.name|default:"-" }}
      +
      +
      +
      +
      + +
      {{ object.parameter_obj.frequency|default:"-" }}
      +
      +
      +
      +
      + +
      {{ object.parameter_obj.freq_range|default:"-" }}
      +
      +
      +
      +
      + +
      {{ object.parameter_obj.polarization.name|default:"-" }}
      +
      +
      +
      +
      +
      +
      + +
      {{ object.parameter_obj.bod_velocity|default:"-" }}
      +
      +
      +
      +
      + +
      {{ object.parameter_obj.modulation.name|default:"-" }}
      +
      +
      +
      +
      + +
      {{ object.parameter_obj.snr|default:"-" }}
      +
      +
      +
      +
      + +
      {{ object.parameter_obj.standard.name|default:"-" }}
      +
      +
      +
      + {% else %} +
      +

      Нет данных о ВЧ загрузке

      +
      + {% endif %} +
      + + +
      +
      +

      Карта

      +
      +
      +
      +
      +
      + + +
      +
      +

      Геоданные

      +
      + + {% if object.geo_obj %} + +
      +
      Координаты геолокации
      +
      +
      +
      + +
      + {% if object.geo_obj.coords %}{{ object.geo_obj.coords.y|floatformat:6 }}{% else %}-{% endif %} +
      +
      +
      +
      + +
      + {% if object.geo_obj.coords %}{{ object.geo_obj.coords.x|floatformat:6 }}{% else %}-{% endif %} +
      +
      +
      +
      + + +
      +
      Координаты Кубсата
      +
      +
      +
      + +
      + {% if object.geo_obj.coords_kupsat %}{{ object.geo_obj.coords_kupsat.x|floatformat:6 }}{% else %}-{% endif %} +
      +
      +
      +
      +
      + +
      + {% if object.geo_obj.coords_kupsat %}{{ object.geo_obj.coords_kupsat.y|floatformat:6 }}{% else %}-{% endif %} +
      +
      +
      +
      +
      + + +
      +
      Координаты оперативников
      +
      +
      +
      + +
      + {% if object.geo_obj.coords_valid %}{{ object.geo_obj.coords_valid.x|floatformat:6 }}{% else %}-{% endif %} +
      +
      +
      +
      +
      + +
      + {% if object.geo_obj.coords_valid %}{{ object.geo_obj.coords_valid.y|floatformat:6 }}{% else %}-{% endif %} +
      +
      +
      +
      +
      + +
      +
      +
      + +
      {{ object.geo_obj.location|default:"-" }}
      +
      +
      +
      +
      + +
      {{ object.geo_obj.comment|default:"-" }}
      +
      +
      +
      + +
      +
      +
      + +
      + {% if object.geo_obj.timestamp %}{{ object.geo_obj.timestamp|date:"d.m.Y H:i" }}{% else %}-{% endif %} +
      +
      +
      +
      + +
      + {% if object.geo_obj.is_average %}Да{% else %}Нет{% endif %} +
      +
      +
      +
      + +
      +
      +
      + +
      + {% if object.geo_obj.distance_coords_kup is not None %} + {{ object.geo_obj.distance_coords_kup|floatformat:2 }} + {% else %} + - + {% endif %} +
      +
      +
      +
      +
      + +
      + {% if object.geo_obj.distance_coords_valid is not None %} + {{ object.geo_obj.distance_coords_valid|floatformat:2 }} + {% else %} + - + {% endif %} +
      +
      +
      +
      +
      + +
      + {% if object.geo_obj.distance_kup_valid is not None %} + {{ object.geo_obj.distance_kup_valid|floatformat:2 }} + {% else %} + - + {% endif %} +
      +
      +
      +
      + {% else %} +

      Нет данных о геолокации

      + {% endif %} +
      +
      + +
      + {% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %} + Редактировать + {% endif %} + Назад +
      +
      +{% endblock %} + +{% block extra_js %} +{{ block.super }} + +{% leaflet_js %} +{% leaflet_css %} + + + {% endblock %} \ No newline at end of file diff --git a/dbapp/mainapp/templates/mainapp/objitem_form.html b/dbapp/mainapp/templates/mainapp/objitem_form.html index 5308d25..f282f35 100644 --- a/dbapp/mainapp/templates/mainapp/objitem_form.html +++ b/dbapp/mainapp/templates/mainapp/objitem_form.html @@ -1,603 +1,603 @@ -{% extends 'mainapp/base.html' %} -{% load static %} -{% load static leaflet_tags %} -{% load l10n %} - -{% block title %}{% if object %}Редактировать объект: {{ object.name }}{% else %}Создать объект{% endif %}{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
      -
      -
      -

      {% if object %}Редактировать объект: {{ object.name }}{% else %}Создать объект{% endif %}

      -
      - {% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %} - - {% if object %} - Удалить - {% endif %} - {% endif %} - Назад -
      -
      -
      - -
      - {% csrf_token %} - - -
      -
      -

      Основная информация

      -
      -
      -
      - {% include 'mainapp/components/_form_field.html' with field=form.name %} -
      -
      -
      - -
      - {% if object.created_at %}{{ object.created_at|date:"d.m.Y H:i" }}{% else %}-{% endif %} -
      -
      -
      -
      -
      -
      -
      - -
      - {% if object.created_by %}{{ object.created_by }}{% else %}-{% endif %} -
      -
      -
      -
      -
      - -
      - {% if object.updated_at %}{{ object.updated_at|date:"d.m.Y H:i" }}{% else %}-{% endif %} -
      -
      -
      -
      -
      -
      -
      - -
      - {% if object.updated_by %}{{ object.updated_by }}{% else %}-{% endif %} -
      -
      -
      -
      -
      - - -
      -
      -

      ВЧ загрузка

      -
      - -
      -
      -
      - {% include 'mainapp/components/_form_field.html' with field=parameter_form.id_satellite %} -
      -
      - {% include 'mainapp/components/_form_field.html' with field=parameter_form.frequency %} -
      -
      - {% include 'mainapp/components/_form_field.html' with field=parameter_form.freq_range %} -
      -
      - {% include 'mainapp/components/_form_field.html' with field=parameter_form.polarization %} -
      -
      -
      -
      - {% include 'mainapp/components/_form_field.html' with field=parameter_form.bod_velocity %} -
      -
      - {% include 'mainapp/components/_form_field.html' with field=parameter_form.modulation %} -
      -
      - {% include 'mainapp/components/_form_field.html' with field=parameter_form.snr %} -
      -
      - {% include 'mainapp/components/_form_field.html' with field=parameter_form.standard %} -
      -
      -
      -
      - - -
      -
      -

      Карта

      -
      -
      -
      -
      -
      - - -
      -
      -

      Геоданные

      -
      - - - -
      -
      Координаты геолокации
      -
      -
      -
      - - -
      -
      -
      -
      - - -
      -
      -
      -
      - - -
      -
      Координаты Кубсата
      -
      -
      -
      - - -
      -
      -
      -
      - - -
      -
      -
      -
      - - -
      -
      Координаты оперативников
      -
      -
      -
      - - -
      -
      -
      -
      - - -
      -
      -
      -
      - -
      -
      - {% include 'mainapp/components/_form_field.html' with field=geo_form.location %} -
      -
      - {% include 'mainapp/components/_form_field.html' with field=geo_form.comment %} -
      -
      - -
      -
      -
      - -
      -
      - - -
      -
      - - -
      -
      -
      -
      -
      - {% include 'mainapp/components/_form_field.html' with field=geo_form.is_average %} -
      -
      - - {% if object.geo_obj %} -
      -
      -
      - -
      - {% if object.geo_obj.distance_coords_kup is not None %} - {{ object.geo_obj.distance_coords_kup|floatformat:2 }} - {% else %} - - - {% endif %} -
      -
      -
      -
      -
      - -
      - {% if object.geo_obj.distance_coords_valid is not None %} - {{ object.geo_obj.distance_coords_valid|floatformat:2 }} - {% else %} - - - {% endif %} -
      -
      -
      -
      -
      - -
      - {% if object.geo_obj.distance_kup_valid is not None %} - {{ object.geo_obj.distance_kup_valid|floatformat:2 }} - {% else %} - - - {% endif %} -
      -
      -
      -
      - {% endif %} -
      -
      -
      -{% endblock %} - -{% block extra_js %} -{{ block.super }} - -{% leaflet_js %} -{% leaflet_css %} - - - - +{% extends 'mainapp/base.html' %} +{% load static %} +{% load static leaflet_tags %} +{% load l10n %} + +{% block title %}{% if object %}Редактировать объект: {{ object.name }}{% else %}Создать объект{% endif %}{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
      +
      +
      +

      {% if object %}Редактировать объект: {{ object.name }}{% else %}Создать объект{% endif %}

      +
      + {% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %} + + {% if object %} + Удалить + {% endif %} + {% endif %} + Назад +
      +
      +
      + +
      + {% csrf_token %} + + +
      +
      +

      Основная информация

      +
      +
      +
      + {% include 'mainapp/components/_form_field.html' with field=form.name %} +
      +
      +
      + +
      + {% if object.created_at %}{{ object.created_at|date:"d.m.Y H:i" }}{% else %}-{% endif %} +
      +
      +
      +
      +
      +
      +
      + +
      + {% if object.created_by %}{{ object.created_by }}{% else %}-{% endif %} +
      +
      +
      +
      +
      + +
      + {% if object.updated_at %}{{ object.updated_at|date:"d.m.Y H:i" }}{% else %}-{% endif %} +
      +
      +
      +
      +
      +
      +
      + +
      + {% if object.updated_by %}{{ object.updated_by }}{% else %}-{% endif %} +
      +
      +
      +
      +
      + + +
      +
      +

      ВЧ загрузка

      +
      + +
      +
      +
      + {% include 'mainapp/components/_form_field.html' with field=parameter_form.id_satellite %} +
      +
      + {% include 'mainapp/components/_form_field.html' with field=parameter_form.frequency %} +
      +
      + {% include 'mainapp/components/_form_field.html' with field=parameter_form.freq_range %} +
      +
      + {% include 'mainapp/components/_form_field.html' with field=parameter_form.polarization %} +
      +
      +
      +
      + {% include 'mainapp/components/_form_field.html' with field=parameter_form.bod_velocity %} +
      +
      + {% include 'mainapp/components/_form_field.html' with field=parameter_form.modulation %} +
      +
      + {% include 'mainapp/components/_form_field.html' with field=parameter_form.snr %} +
      +
      + {% include 'mainapp/components/_form_field.html' with field=parameter_form.standard %} +
      +
      +
      +
      + + +
      +
      +

      Карта

      +
      +
      +
      +
      +
      + + +
      +
      +

      Геоданные

      +
      + + + +
      +
      Координаты геолокации
      +
      +
      +
      + + +
      +
      +
      +
      + + +
      +
      +
      +
      + + +
      +
      Координаты Кубсата
      +
      +
      +
      + + +
      +
      +
      +
      + + +
      +
      +
      +
      + + +
      +
      Координаты оперативников
      +
      +
      +
      + + +
      +
      +
      +
      + + +
      +
      +
      +
      + +
      +
      + {% include 'mainapp/components/_form_field.html' with field=geo_form.location %} +
      +
      + {% include 'mainapp/components/_form_field.html' with field=geo_form.comment %} +
      +
      + +
      +
      +
      + +
      +
      + + +
      +
      + + +
      +
      +
      +
      +
      + {% include 'mainapp/components/_form_field.html' with field=geo_form.is_average %} +
      +
      + + {% if object.geo_obj %} +
      +
      +
      + +
      + {% if object.geo_obj.distance_coords_kup is not None %} + {{ object.geo_obj.distance_coords_kup|floatformat:2 }} + {% else %} + - + {% endif %} +
      +
      +
      +
      +
      + +
      + {% if object.geo_obj.distance_coords_valid is not None %} + {{ object.geo_obj.distance_coords_valid|floatformat:2 }} + {% else %} + - + {% endif %} +
      +
      +
      +
      +
      + +
      + {% if object.geo_obj.distance_kup_valid is not None %} + {{ object.geo_obj.distance_kup_valid|floatformat:2 }} + {% else %} + - + {% endif %} +
      +
      +
      +
      + {% endif %} +
      +
      +
      +{% endblock %} + +{% block extra_js %} +{{ block.super }} + +{% leaflet_js %} +{% leaflet_css %} + + + + {% endblock %} \ No newline at end of file diff --git a/dbapp/mainapp/templates/mainapp/process_kubsat.html b/dbapp/mainapp/templates/mainapp/process_kubsat.html index e614f5d..441244a 100644 --- a/dbapp/mainapp/templates/mainapp/process_kubsat.html +++ b/dbapp/mainapp/templates/mainapp/process_kubsat.html @@ -1,52 +1,52 @@ -{% extends 'mainapp/base.html' %} - -{% block title %}Новое событие{% endblock %} - -{% block content %} -
      -
      -
      -
      -
      -

      Формирование таблицы Кубсат

      -
      -
      -
      - {% csrf_token %} - {% comment%} -
      - - {{ form.sat_choice }} - {% if form.sat_choice.errors %} -
      {{ form.sat_choice.errors }}
      - {% endif %} -
      {% endcomment %} - - {% comment %}
      - - {{ form.pol_choice }} - {% if form.pol_choice.errors %} -
      {{ form.pol_choice.errors }}
      - {% endif %} -
      {% endcomment %} - -
      - - {{ form.file }} - {% if form.file.errors %} -
      {{ form.file.errors }}
      - {% endif %} -
      Выберите файл для загрузки
      -
      - -
      - Назад - -
      -
      -
      -
      -
      -
      -
      +{% extends 'mainapp/base.html' %} + +{% block title %}Новое событие{% endblock %} + +{% block content %} +
      +
      +
      +
      +
      +

      Формирование таблицы Кубсат

      +
      +
      +
      + {% csrf_token %} + {% comment%} +
      + + {{ form.sat_choice }} + {% if form.sat_choice.errors %} +
      {{ form.sat_choice.errors }}
      + {% endif %} +
      {% endcomment %} + + {% comment %}
      + + {{ form.pol_choice }} + {% if form.pol_choice.errors %} +
      {{ form.pol_choice.errors }}
      + {% endif %} +
      {% endcomment %} + +
      + + {{ form.file }} + {% if form.file.errors %} +
      {{ form.file.errors }}
      + {% endif %} +
      Выберите файл для загрузки
      +
      + +
      + Назад + +
      +
      +
      +
      +
      +
      +
      {% endblock %} \ No newline at end of file diff --git a/dbapp/mainapp/templates/mainapp/transponders_upload.html b/dbapp/mainapp/templates/mainapp/transponders_upload.html index c48af04..d59bd51 100644 --- a/dbapp/mainapp/templates/mainapp/transponders_upload.html +++ b/dbapp/mainapp/templates/mainapp/transponders_upload.html @@ -1,53 +1,53 @@ -{% extends 'mainapp/base.html' %} - -{% block title %}Загрузка данных транспондеров{% endblock %} - -{% block content %} -
      -
      -
      -
      -
      -

      Загрузка данных транспондеров из CellNet

      -
      -
      - {% if messages %} - {% for message in messages %} - - {% endfor %} - {% endif %} - -

      Загрузите xml-файл и выберите спутник для загрузки данных в базу.

      - -
      - {% csrf_token %} -
      - - {{ form.file }} - {% if form.file.errors %} -
      {{ form.file.errors }}
      - {% endif %} -
      Загрузите xml-файл (.xml) с данными для обработки
      -
      - - {% comment %}
      - - {{ form.sat_choice }} - {% if form.sat_choice.errors %} -
      {{ form.sat_choice.errors }}
      - {% endif %} -
      {% endcomment %} -
      - Назад - -
      -
      -
      -
      -
      -
      -
      +{% extends 'mainapp/base.html' %} + +{% block title %}Загрузка данных транспондеров{% endblock %} + +{% block content %} +
      +
      +
      +
      +
      +

      Загрузка данных транспондеров из CellNet

      +
      +
      + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + +

      Загрузите xml-файл и выберите спутник для загрузки данных в базу.

      + +
      + {% csrf_token %} +
      + + {{ form.file }} + {% if form.file.errors %} +
      {{ form.file.errors }}
      + {% endif %} +
      Загрузите xml-файл (.xml) с данными для обработки
      +
      + + {% comment %}
      + + {{ form.sat_choice }} + {% if form.sat_choice.errors %} +
      {{ form.sat_choice.errors }}
      + {% endif %} +
      {% endcomment %} +
      + Назад + +
      +
      +
      +
      +
      +
      +
      {% endblock %} \ No newline at end of file diff --git a/dbapp/mainapp/templates/mainapp/upload_html.html b/dbapp/mainapp/templates/mainapp/upload_html.html index 724268b..2bed0e3 100644 --- a/dbapp/mainapp/templates/mainapp/upload_html.html +++ b/dbapp/mainapp/templates/mainapp/upload_html.html @@ -1,56 +1,56 @@ -{% extends 'mainapp/base.html' %} - -{% block title %}Загрузка данных ВЧ загрузки{% endblock %} - -{% block content %} -
      -
      -
      -
      -
      -

      Загрузка данных ВЧ загрузки

      -
      -
      - {% if messages %} - {% for message in messages %} - - {% endfor %} - {% endif %} - -

      Загрузите HTML-файл с таблицами данных ВЧ загрузки и выберите спутник для привязки данных.

      - -
      - {% csrf_token %} - - -
      - - {{ form.file }} - {% if form.file.errors %} -
      {{ form.file.errors }}
      - {% endif %} -
      Загрузите HTML-файл, содержащий таблицы с данными ВЧ загрузки
      -
      - -
      - - {{ form.sat_choice }} - {% if form.sat_choice.errors %} -
      {{ form.sat_choice.errors }}
      - {% endif %} -
      - -
      - Назад - -
      -
      -
      -
      -
      -
      -
      +{% extends 'mainapp/base.html' %} + +{% block title %}Загрузка данных ВЧ загрузки{% endblock %} + +{% block content %} +
      +
      +
      +
      +
      +

      Загрузка данных ВЧ загрузки

      +
      +
      + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + +

      Загрузите HTML-файл с таблицами данных ВЧ загрузки и выберите спутник для привязки данных.

      + +
      + {% csrf_token %} + + +
      + + {{ form.file }} + {% if form.file.errors %} +
      {{ form.file.errors }}
      + {% endif %} +
      Загрузите HTML-файл, содержащий таблицы с данными ВЧ загрузки
      +
      + +
      + + {{ form.sat_choice }} + {% if form.sat_choice.errors %} +
      {{ form.sat_choice.errors }}
      + {% endif %} +
      + +
      + Назад + +
      +
      +
      +
      +
      +
      +
      {% endblock %} \ No newline at end of file diff --git a/dbapp/mainapp/templatetags/__init__.py b/dbapp/mainapp/templatetags/__init__.py index 906c340..25532f6 100644 --- a/dbapp/mainapp/templatetags/__init__.py +++ b/dbapp/mainapp/templatetags/__init__.py @@ -1,3 +1,3 @@ -""" -Template tags для mainapp. -""" +""" +Template tags для mainapp. +""" diff --git a/dbapp/mainapp/templatetags/coordinate_filters.py b/dbapp/mainapp/templatetags/coordinate_filters.py index f7f7e8e..0a23147 100644 --- a/dbapp/mainapp/templatetags/coordinate_filters.py +++ b/dbapp/mainapp/templatetags/coordinate_filters.py @@ -1,133 +1,133 @@ -""" -Пользовательские фильтры шаблонов для форматирования координат. - -Этот модуль содержит фильтры Django для форматирования географических координат -в читаемый вид в шаблонах. -""" - -# Standard library imports -from typing import Optional - -# Django imports -from django import template -from django.contrib.gis.geos import Point - -register = template.Library() - - -@register.filter(name='format_coords') -def format_coords(point: Optional[Point]) -> str: - """ - Форматирует объект Point в читаемую строку координат. - - Args: - point (Point): Объект Point из GeoDjango или None. - - Returns: - str: Отформатированная строка координат в формате "XXN/S YYE/W" - или "-" если point равен None. - - Example: - В шаблоне: - {{ geo_obj.coords|format_coords }} - - Результат: - "55.75N 37.62E" - """ - if not point: - return "-" - - try: - longitude = point.coords[0] - latitude = point.coords[1] - - lon_direction = "E" if longitude > 0 else "W" - lat_direction = "N" if latitude > 0 else "S" - - lon_value = abs(longitude) - lat_value = abs(latitude) - - return f"{lat_value}{lat_direction} {lon_value}{lon_direction}" - except (AttributeError, IndexError, TypeError): - return "-" - - -@register.filter(name='format_coords_decimal') -def format_coords_decimal(point: Optional[Point], precision: int = 6) -> str: - """ - Форматирует объект Point в десятичные координаты с заданной точностью. - - Args: - point (Point): Объект Point из GeoDjango или None. - precision (int): Количество знаков после запятой (по умолчанию 6). - - Returns: - str: Отформатированная строка координат в формате "lat, lon" - или "-" если point равен None. - - Example: - В шаблоне: - {{ geo_obj.coords|format_coords_decimal:4 }} - - Результат: - "55.7500, 37.6200" - """ - if not point: - return "-" - - try: - longitude = point.coords[0] - latitude = point.coords[1] - - format_str = f"{{:.{precision}f}}, {{:.{precision}f}}" - return format_str.format(latitude, longitude) - except (AttributeError, IndexError, TypeError, ValueError): - return "-" - - -@register.filter(name='coords_to_lat') -def coords_to_lat(point: Optional[Point]) -> Optional[float]: - """ - Извлекает широту из объекта Point. - - Args: - point (Point): Объект Point из GeoDjango или None. - - Returns: - float: Значение широты или None если point равен None. - - Example: - В шаблоне: - {{ geo_obj.coords|coords_to_lat }} - """ - if not point: - return None - - try: - return point.coords[1] - except (AttributeError, IndexError, TypeError): - return None - - -@register.filter(name='coords_to_lon') -def coords_to_lon(point: Optional[Point]) -> Optional[float]: - """ - Извлекает долготу из объекта Point. - - Args: - point (Point): Объект Point из GeoDjango или None. - - Returns: - float: Значение долготы или None если point равен None. - - Example: - В шаблоне: - {{ geo_obj.coords|coords_to_lon }} - """ - if not point: - return None - - try: - return point.coords[0] - except (AttributeError, IndexError, TypeError): - return None +""" +Пользовательские фильтры шаблонов для форматирования координат. + +Этот модуль содержит фильтры Django для форматирования географических координат +в читаемый вид в шаблонах. +""" + +# Standard library imports +from typing import Optional + +# Django imports +from django import template +from django.contrib.gis.geos import Point + +register = template.Library() + + +@register.filter(name='format_coords') +def format_coords(point: Optional[Point]) -> str: + """ + Форматирует объект Point в читаемую строку координат. + + Args: + point (Point): Объект Point из GeoDjango или None. + + Returns: + str: Отформатированная строка координат в формате "XXN/S YYE/W" + или "-" если point равен None. + + Example: + В шаблоне: + {{ geo_obj.coords|format_coords }} + + Результат: + "55.75N 37.62E" + """ + if not point: + return "-" + + try: + longitude = point.coords[0] + latitude = point.coords[1] + + lon_direction = "E" if longitude > 0 else "W" + lat_direction = "N" if latitude > 0 else "S" + + lon_value = abs(longitude) + lat_value = abs(latitude) + + return f"{lat_value}{lat_direction} {lon_value}{lon_direction}" + except (AttributeError, IndexError, TypeError): + return "-" + + +@register.filter(name='format_coords_decimal') +def format_coords_decimal(point: Optional[Point], precision: int = 6) -> str: + """ + Форматирует объект Point в десятичные координаты с заданной точностью. + + Args: + point (Point): Объект Point из GeoDjango или None. + precision (int): Количество знаков после запятой (по умолчанию 6). + + Returns: + str: Отформатированная строка координат в формате "lat, lon" + или "-" если point равен None. + + Example: + В шаблоне: + {{ geo_obj.coords|format_coords_decimal:4 }} + + Результат: + "55.7500, 37.6200" + """ + if not point: + return "-" + + try: + longitude = point.coords[0] + latitude = point.coords[1] + + format_str = f"{{:.{precision}f}}, {{:.{precision}f}}" + return format_str.format(latitude, longitude) + except (AttributeError, IndexError, TypeError, ValueError): + return "-" + + +@register.filter(name='coords_to_lat') +def coords_to_lat(point: Optional[Point]) -> Optional[float]: + """ + Извлекает широту из объекта Point. + + Args: + point (Point): Объект Point из GeoDjango или None. + + Returns: + float: Значение широты или None если point равен None. + + Example: + В шаблоне: + {{ geo_obj.coords|coords_to_lat }} + """ + if not point: + return None + + try: + return point.coords[1] + except (AttributeError, IndexError, TypeError): + return None + + +@register.filter(name='coords_to_lon') +def coords_to_lon(point: Optional[Point]) -> Optional[float]: + """ + Извлекает долготу из объекта Point. + + Args: + point (Point): Объект Point из GeoDjango или None. + + Returns: + float: Значение долготы или None если point равен None. + + Example: + В шаблоне: + {{ geo_obj.coords|coords_to_lon }} + """ + if not point: + return None + + try: + return point.coords[0] + except (AttributeError, IndexError, TypeError): + return None diff --git a/dbapp/mainapp/tests.py b/dbapp/mainapp/tests.py index dd547e6..9df6cca 100644 --- a/dbapp/mainapp/tests.py +++ b/dbapp/mainapp/tests.py @@ -1,179 +1,179 @@ -from django.test import TestCase, RequestFactory -from django.contrib.auth.models import User -from django.contrib.gis.geos import Point -from .models import CustomUser, Geo, ObjItem -from .utils import format_coordinates, parse_pagination_params -from .mixins import RoleRequiredMixin, CoordinateProcessingMixin -from django.views import View - - -class FormatCoordinatesTestCase(TestCase): - """Тесты для функции format_coordinates""" - - def test_format_positive_coordinates(self): - """Тест форматирования положительных координат""" - result = format_coordinates(37.62, 55.75) - self.assertEqual(result, "55.75N 37.62E") - - def test_format_negative_longitude(self): - """Тест форматирования с отрицательной долготой""" - result = format_coordinates(-122.42, 37.77) - self.assertEqual(result, "37.77N 122.42W") - - def test_format_negative_latitude(self): - """Тест форматирования с отрицательной широтой""" - result = format_coordinates(151.21, -33.87) - self.assertEqual(result, "33.87S 151.21E") - - def test_format_both_negative(self): - """Тест форматирования с обеими отрицательными координатами""" - result = format_coordinates(-58.38, -34.60) - self.assertEqual(result, "34.6S 58.38W") - - -class ParsePaginationParamsTestCase(TestCase): - """Тесты для функции parse_pagination_params""" - - def setUp(self): - self.factory = RequestFactory() - - def test_default_values(self): - """Тест значений по умолчанию""" - request = self.factory.get("/") - page, per_page = parse_pagination_params(request) - self.assertEqual(page, 1) - self.assertEqual(per_page, 50) - - def test_custom_values(self): - """Тест пользовательских значений""" - request = self.factory.get("/?page=3&items_per_page=100") - page, per_page = parse_pagination_params(request) - self.assertEqual(page, 3) - self.assertEqual(per_page, 100) - - def test_invalid_page_number(self): - """Тест невалидного номера страницы""" - request = self.factory.get("/?page=invalid") - page, per_page = parse_pagination_params(request) - self.assertEqual(page, 1) - - def test_negative_page_number(self): - """Тест отрицательного номера страницы""" - request = self.factory.get("/?page=-5") - page, per_page = parse_pagination_params(request) - self.assertEqual(page, 1) - - def test_max_items_per_page_limit(self): - """Тест ограничения максимального количества элементов""" - request = self.factory.get("/?items_per_page=20000") - page, per_page = parse_pagination_params(request) - self.assertEqual(per_page, 10000) - - -class RoleRequiredMixinTestCase(TestCase): - """Тесты для RoleRequiredMixin""" - - def setUp(self): - self.factory = RequestFactory() - - def test_admin_has_access(self): - """Тест что администратор имеет доступ""" - user = User.objects.create_user(username="testuser", password="12345") - # Get the automatically created CustomUser and set role to 'admin' - custom_user = CustomUser.objects.get(user=user) - custom_user.role = "admin" - custom_user.save() - - # Refresh user to get updated customuser - user.refresh_from_db() - - class TestView(RoleRequiredMixin, View): - required_roles = ["admin", "moderator"] - - view = TestView() - request = self.factory.get("/") - request.user = user - view.request = request - - self.assertTrue(view.test_func()) - - def test_user_without_role_denied(self): - """Тест что пользователь без роли не имеет доступа""" - user_no_role = User.objects.create_user(username="norole", password="12345") - # Get the automatically created CustomUser - default role is 'user' - custom_user_no_role = CustomUser.objects.get(user=user_no_role) - self.assertEqual(custom_user_no_role.role, "user") - - class TestView(RoleRequiredMixin, View): - required_roles = ["admin", "moderator"] - - view = TestView() - request = self.factory.get("/") - request.user = user_no_role - view.request = request - - self.assertFalse(view.test_func()) - - -class CoordinateProcessingMixinTestCase(TestCase): - """Тесты для CoordinateProcessingMixin""" - - def setUp(self): - self.factory = RequestFactory() - - def test_extract_geo_coordinates(self): - """Тест извлечения координат геолокации""" - - class TestView(CoordinateProcessingMixin, View): - pass - - view = TestView() - request = self.factory.post( - "/", {"geo_longitude": "37.62", "geo_latitude": "55.75"} - ) - view.request = request - - coords = view._extract_coordinates("geo") - self.assertIsNotNone(coords) - self.assertEqual(coords, (37.62, 55.75)) - - def test_extract_invalid_coordinates(self): - """Тест извлечения невалидных координат""" - - class TestView(CoordinateProcessingMixin, View): - pass - - view = TestView() - request = self.factory.post( - "/", {"geo_longitude": "invalid", "geo_latitude": "55.75"} - ) - view.request = request - - coords = view._extract_coordinates("geo") - self.assertIsNone(coords) - - def test_process_coordinates(self): - """Тест обработки координат и применения к объекту Geo""" - - class TestView(CoordinateProcessingMixin, View): - pass - - view = TestView() - request = self.factory.post( - "/", - { - "geo_longitude": "37.62", - "geo_latitude": "55.75", - "kupsat_longitude": "37.63", - "kupsat_latitude": "55.76", - }, - ) - view.request = request - - geo_instance = Geo() - view.process_coordinates(geo_instance) - - self.assertIsNotNone(geo_instance.coords) - self.assertEqual(geo_instance.coords.coords, (37.62, 55.75)) - self.assertIsNotNone(geo_instance.coords_kupsat) - self.assertEqual(geo_instance.coords_kupsat.coords, (37.63, 55.76)) +from django.test import TestCase, RequestFactory +from django.contrib.auth.models import User +from django.contrib.gis.geos import Point +from .models import CustomUser, Geo, ObjItem +from .utils import format_coordinates, parse_pagination_params +from .mixins import RoleRequiredMixin, CoordinateProcessingMixin +from django.views import View + + +class FormatCoordinatesTestCase(TestCase): + """Тесты для функции format_coordinates""" + + def test_format_positive_coordinates(self): + """Тест форматирования положительных координат""" + result = format_coordinates(37.62, 55.75) + self.assertEqual(result, "55.75N 37.62E") + + def test_format_negative_longitude(self): + """Тест форматирования с отрицательной долготой""" + result = format_coordinates(-122.42, 37.77) + self.assertEqual(result, "37.77N 122.42W") + + def test_format_negative_latitude(self): + """Тест форматирования с отрицательной широтой""" + result = format_coordinates(151.21, -33.87) + self.assertEqual(result, "33.87S 151.21E") + + def test_format_both_negative(self): + """Тест форматирования с обеими отрицательными координатами""" + result = format_coordinates(-58.38, -34.60) + self.assertEqual(result, "34.6S 58.38W") + + +class ParsePaginationParamsTestCase(TestCase): + """Тесты для функции parse_pagination_params""" + + def setUp(self): + self.factory = RequestFactory() + + def test_default_values(self): + """Тест значений по умолчанию""" + request = self.factory.get("/") + page, per_page = parse_pagination_params(request) + self.assertEqual(page, 1) + self.assertEqual(per_page, 50) + + def test_custom_values(self): + """Тест пользовательских значений""" + request = self.factory.get("/?page=3&items_per_page=100") + page, per_page = parse_pagination_params(request) + self.assertEqual(page, 3) + self.assertEqual(per_page, 100) + + def test_invalid_page_number(self): + """Тест невалидного номера страницы""" + request = self.factory.get("/?page=invalid") + page, per_page = parse_pagination_params(request) + self.assertEqual(page, 1) + + def test_negative_page_number(self): + """Тест отрицательного номера страницы""" + request = self.factory.get("/?page=-5") + page, per_page = parse_pagination_params(request) + self.assertEqual(page, 1) + + def test_max_items_per_page_limit(self): + """Тест ограничения максимального количества элементов""" + request = self.factory.get("/?items_per_page=20000") + page, per_page = parse_pagination_params(request) + self.assertEqual(per_page, 10000) + + +class RoleRequiredMixinTestCase(TestCase): + """Тесты для RoleRequiredMixin""" + + def setUp(self): + self.factory = RequestFactory() + + def test_admin_has_access(self): + """Тест что администратор имеет доступ""" + user = User.objects.create_user(username="testuser", password="12345") + # Get the automatically created CustomUser and set role to 'admin' + custom_user = CustomUser.objects.get(user=user) + custom_user.role = "admin" + custom_user.save() + + # Refresh user to get updated customuser + user.refresh_from_db() + + class TestView(RoleRequiredMixin, View): + required_roles = ["admin", "moderator"] + + view = TestView() + request = self.factory.get("/") + request.user = user + view.request = request + + self.assertTrue(view.test_func()) + + def test_user_without_role_denied(self): + """Тест что пользователь без роли не имеет доступа""" + user_no_role = User.objects.create_user(username="norole", password="12345") + # Get the automatically created CustomUser - default role is 'user' + custom_user_no_role = CustomUser.objects.get(user=user_no_role) + self.assertEqual(custom_user_no_role.role, "user") + + class TestView(RoleRequiredMixin, View): + required_roles = ["admin", "moderator"] + + view = TestView() + request = self.factory.get("/") + request.user = user_no_role + view.request = request + + self.assertFalse(view.test_func()) + + +class CoordinateProcessingMixinTestCase(TestCase): + """Тесты для CoordinateProcessingMixin""" + + def setUp(self): + self.factory = RequestFactory() + + def test_extract_geo_coordinates(self): + """Тест извлечения координат геолокации""" + + class TestView(CoordinateProcessingMixin, View): + pass + + view = TestView() + request = self.factory.post( + "/", {"geo_longitude": "37.62", "geo_latitude": "55.75"} + ) + view.request = request + + coords = view._extract_coordinates("geo") + self.assertIsNotNone(coords) + self.assertEqual(coords, (37.62, 55.75)) + + def test_extract_invalid_coordinates(self): + """Тест извлечения невалидных координат""" + + class TestView(CoordinateProcessingMixin, View): + pass + + view = TestView() + request = self.factory.post( + "/", {"geo_longitude": "invalid", "geo_latitude": "55.75"} + ) + view.request = request + + coords = view._extract_coordinates("geo") + self.assertIsNone(coords) + + def test_process_coordinates(self): + """Тест обработки координат и применения к объекту Geo""" + + class TestView(CoordinateProcessingMixin, View): + pass + + view = TestView() + request = self.factory.post( + "/", + { + "geo_longitude": "37.62", + "geo_latitude": "55.75", + "kupsat_longitude": "37.63", + "kupsat_latitude": "55.76", + }, + ) + view.request = request + + geo_instance = Geo() + view.process_coordinates(geo_instance) + + self.assertIsNotNone(geo_instance.coords) + self.assertEqual(geo_instance.coords.coords, (37.62, 55.75)) + self.assertIsNotNone(geo_instance.coords_kupsat) + self.assertEqual(geo_instance.coords_kupsat.coords, (37.63, 55.76)) diff --git a/dbapp/mainapp/urls.py b/dbapp/mainapp/urls.py index 78aa97b..1b3e0b0 100644 --- a/dbapp/mainapp/urls.py +++ b/dbapp/mainapp/urls.py @@ -1,32 +1,32 @@ -from django.conf import settings -from django.conf.urls.static import static -from django.urls import path -from . import views - -app_name = 'mainapp' - -urlpatterns = [ - path('', views.HomePageView.as_view(), name='home'), # Home page that redirects based on auth - path('objitems/', views.ObjItemListView.as_view(), name='objitem_list'), # Objects list page - path('actions/', views.ActionsPageView.as_view(), name='actions'), # Move actions to a separate page - path('excel-data', views.LoadExcelDataView.as_view(), name='load_excel_data'), - path('satellites', views.AddSatellitesView.as_view(), name='add_sats'), - path('api/locations//geojson/', views.GetLocationsView.as_view(), name='locations_by_id'), - path('transponders', views.AddTranspondersView.as_view(), name='add_trans'), - path('csv-data', views.LoadCsvDataView.as_view(), name='load_csv_data'), - path('map-points/', views.ShowMapView.as_view(), name='admin_show_map'), - path('show-selected-objects-map/', views.ShowSelectedObjectsMapView.as_view(), name='show_selected_objects_map'), - path('delete-selected-objects/', views.DeleteSelectedObjectsView.as_view(), name='delete_selected_objects'), - path('cluster/', views.ClusterTestView.as_view(), name='cluster'), - path('vch-upload/', views.UploadVchLoadView.as_view(), name='vch_load'), - path('vch-link/', views.LinkVchSigmaView.as_view(), name='link_vch_sigma'), - path('kubsat-excel/', views.ProcessKubsatView.as_view(), name='kubsat_excel'), - path('object/create/', views.ObjItemCreateView.as_view(), name='objitem_create'), - path('object//edit/', views.ObjItemUpdateView.as_view(), name='objitem_update'), - path('object//', views.ObjItemDetailView.as_view(), name='objitem_detail'), - path('object//delete/', views.ObjItemDeleteView.as_view(), name='objitem_delete'), - path('fill-lyngsat-data/', views.FillLyngsatDataView.as_view(), name='fill_lyngsat_data'), - path('lyngsat-task-status/', views.LyngsatTaskStatusView.as_view(), name='lyngsat_task_status'), - path('lyngsat-task-status//', views.LyngsatTaskStatusView.as_view(), name='lyngsat_task_status'), - path('api/lyngsat-task-status//', views.LyngsatTaskStatusAPIView.as_view(), name='lyngsat_task_status_api'), +from django.conf import settings +from django.conf.urls.static import static +from django.urls import path +from . import views + +app_name = 'mainapp' + +urlpatterns = [ + path('', views.HomePageView.as_view(), name='home'), # Home page that redirects based on auth + path('objitems/', views.ObjItemListView.as_view(), name='objitem_list'), # Objects list page + path('actions/', views.ActionsPageView.as_view(), name='actions'), # Move actions to a separate page + path('excel-data', views.LoadExcelDataView.as_view(), name='load_excel_data'), + path('satellites', views.AddSatellitesView.as_view(), name='add_sats'), + path('api/locations//geojson/', views.GetLocationsView.as_view(), name='locations_by_id'), + path('transponders', views.AddTranspondersView.as_view(), name='add_trans'), + path('csv-data', views.LoadCsvDataView.as_view(), name='load_csv_data'), + path('map-points/', views.ShowMapView.as_view(), name='admin_show_map'), + path('show-selected-objects-map/', views.ShowSelectedObjectsMapView.as_view(), name='show_selected_objects_map'), + path('delete-selected-objects/', views.DeleteSelectedObjectsView.as_view(), name='delete_selected_objects'), + path('cluster/', views.ClusterTestView.as_view(), name='cluster'), + path('vch-upload/', views.UploadVchLoadView.as_view(), name='vch_load'), + path('vch-link/', views.LinkVchSigmaView.as_view(), name='link_vch_sigma'), + path('kubsat-excel/', views.ProcessKubsatView.as_view(), name='kubsat_excel'), + path('object/create/', views.ObjItemCreateView.as_view(), name='objitem_create'), + path('object//edit/', views.ObjItemUpdateView.as_view(), name='objitem_update'), + path('object//', views.ObjItemDetailView.as_view(), name='objitem_detail'), + path('object//delete/', views.ObjItemDeleteView.as_view(), name='objitem_delete'), + path('fill-lyngsat-data/', views.FillLyngsatDataView.as_view(), name='fill_lyngsat_data'), + path('lyngsat-task-status/', views.LyngsatTaskStatusView.as_view(), name='lyngsat_task_status'), + path('lyngsat-task-status//', views.LyngsatTaskStatusView.as_view(), name='lyngsat_task_status'), + path('api/lyngsat-task-status//', views.LyngsatTaskStatusAPIView.as_view(), name='lyngsat_task_status_api'), ] \ No newline at end of file diff --git a/dbapp/mainapp/utils.py b/dbapp/mainapp/utils.py index 021910c..f815cb8 100644 --- a/dbapp/mainapp/utils.py +++ b/dbapp/mainapp/utils.py @@ -1,647 +1,647 @@ -# Standard library imports -import io -import json -import re -from datetime import datetime, time - -# Django imports -from django.contrib.gis.geos import Point -from django.db.models import F - -# Third-party imports -import pandas as pd - -# Local imports -from mapsapp.models import Transponders - -from .models import ( - CustomUser, - Geo, - Mirror, - Modulation, - ObjItem, - Parameter, - Polarization, - Satellite, - SigmaParameter, - Standard, -) - -# ============================================================================ -# Константы -# ============================================================================ - -# Значения по умолчанию для пагинации -DEFAULT_ITEMS_PER_PAGE = 50 -MAX_ITEMS_PER_PAGE = 10000 - -# Значения по умолчанию для данных -DEFAULT_NUMERIC_VALUE = -1.0 -MINIMUM_BANDWIDTH_MHZ = 0.08 - - -def get_all_constants(): - sats = [sat.name for sat in Satellite.objects.all()] - standards = [sat.name for sat in Standard.objects.all()] - pols = [sat.name for sat in Polarization.objects.all()] - mirrors = [sat.name for sat in Mirror.objects.all()] - modulations = [sat.name for sat in Modulation.objects.all()] - return sats, standards, pols, mirrors, modulations - - -def coords_transform(coords: str): - lat_part, lon_part = coords.strip().split() - sign_map = {"N": 1, "E": 1, "S": -1, "W": -1} - - lat_sign_char = lat_part[-1] - lat_value = float(lat_part[:-1].replace(",", ".")) - latitude = lat_value * sign_map.get(lat_sign_char, 1) - - lon_sign_char = lon_part[-1] - lon_value = float(lon_part[:-1].replace(",", ".")) - longitude = lon_value * sign_map.get(lon_sign_char, 1) - - return (longitude, latitude) - - -def remove_str(s: str): - if isinstance(s, str): - if ( - s.strip() == "-" - or s.strip() == "" - or s.strip() == " " - or "неизв" in s.strip() - ): - return -1 - return float(s.strip().replace(",", ".")) - return s - - -def fill_data_from_df(df: pd.DataFrame, sat: Satellite, current_user=None): - try: - df.rename(columns={"Модуляция ": "Модуляция"}, inplace=True) - except Exception as e: - print(e) - consts = get_all_constants() - df.fillna(-1, inplace=True) - for stroka in df.iterrows(): - geo_point = Point(coords_transform(stroka[1]["Координаты"]), srid=4326) - valid_point = None - kupsat_point = None - try: - if ( - stroka[1]["Координаты объекта"] != -1 - and stroka[1]["Координаты Кубсата"] != "+" - ): - if ( - "ИРИ" not in stroka[1]["Координаты объекта"] - and "БЛА" not in stroka[1]["Координаты объекта"] - ): - valid_point = list( - map( - float, - stroka[1]["Координаты объекта"] - .replace(",", ".") - .split(". "), - ) - ) - valid_point = Point(valid_point[1], valid_point[0], srid=4326) - if ( - stroka[1]["Координаты Кубсата"] != -1 - and stroka[1]["Координаты Кубсата"] != "+" - ): - kupsat_point = list( - map( - float, - stroka[1]["Координаты Кубсата"].replace(",", ".").split(". "), - ) - ) - kupsat_point = Point(kupsat_point[1], kupsat_point[0], srid=4326) - except KeyError: - print("В таблице нет столбцов с координатами кубсата") - try: - polarization_obj, _ = Polarization.objects.get_or_create( - name=stroka[1]["Поляризация"].strip() - ) - except KeyError: - polarization_obj, _ = Polarization.objects.get_or_create(name="-") - freq = remove_str(stroka[1]["Частота, МГц"]) - freq_line = remove_str(stroka[1]["Полоса, МГц"]) - v = remove_str(stroka[1]["Символьная скорость, БОД"]) - try: - mod_obj, _ = Modulation.objects.get_or_create( - name=stroka[1]["Модуляция"].strip() - ) - except AttributeError: - mod_obj, _ = Modulation.objects.get_or_create(name="-") - snr = remove_str(stroka[1]["ОСШ"]) - date = stroka[1]["Дата"].date() - time_ = stroka[1]["Время"] - if isinstance(time_, str): - time_ = time_.strip() - time_ = time(0, 0, 0) - timestamp = datetime.combine(date, time_) - current_mirrors = [] - mirror_1 = stroka[1]["Зеркало 1"].strip().split("\n") - mirror_2 = stroka[1]["Зеркало 2"].strip().split("\n") - if len(mirror_1) > 1: - for mir in mirror_1: - mir_obj, _ = Mirror.objects.get_or_create(name=mir.strip()) - current_mirrors.append(mir.strip()) - elif mirror_1[0] not in consts[3]: - mir_obj, _ = Mirror.objects.get_or_create(name=mirror_1[0].strip()) - current_mirrors.append(mirror_1[0].strip()) - if len(mirror_2) > 1: - for mir in mirror_2: - mir_obj, _ = Mirror.objects.get_or_create(name=mir.strip()) - current_mirrors.append(mir.strip()) - elif mirror_2[0] not in consts[3]: - mir_obj, _ = Mirror.objects.get_or_create(name=mirror_2[0].strip()) - current_mirrors.append(mirror_2[0].strip()) - location = stroka[1]["Местоопределение"].strip() - comment = stroka[1]["Комментарий"] - source = stroka[1]["Объект наблюдения"] - user_to_use = current_user if current_user else CustomUser.objects.get(id=1) - - geo, _ = Geo.objects.get_or_create( - timestamp=timestamp, - coords=geo_point, - defaults={ - "coords_kupsat": kupsat_point, - "coords_valid": valid_point, - "location": location, - "comment": comment, - "is_average": (comment != -1.0), - }, - ) - geo.save() - geo.mirrors.set(Mirror.objects.filter(name__in=current_mirrors)) - - # Check if ObjItem with same geo already exists - existing_obj_item = ObjItem.objects.filter(geo_obj=geo).first() - if existing_obj_item: - # Check if parameter with same values exists for this object - if ( - hasattr(existing_obj_item, 'parameter_obj') and - existing_obj_item.parameter_obj and - existing_obj_item.parameter_obj.id_satellite == sat and - existing_obj_item.parameter_obj.polarization == polarization_obj and - existing_obj_item.parameter_obj.frequency == freq and - existing_obj_item.parameter_obj.freq_range == freq_line and - existing_obj_item.parameter_obj.bod_velocity == v and - existing_obj_item.parameter_obj.modulation == mod_obj and - existing_obj_item.parameter_obj.snr == snr - ): - # Skip creating duplicate - continue - - # Create new ObjItem and Parameter - obj_item = ObjItem.objects.create(name=source, created_by=user_to_use) - - vch_load_obj = Parameter.objects.create( - id_satellite=sat, - polarization=polarization_obj, - frequency=freq, - freq_range=freq_line, - bod_velocity=v, - modulation=mod_obj, - snr=snr, - objitem=obj_item - ) - - geo.objitem = obj_item - geo.save() - - -def add_satellite_list(): - sats = [ - "AZERSPACE 2", - "Amos 4", - "Astra 4A", - "ComsatBW-1", - "Eutelsat 16A", - "Eutelsat 21B", - "Eutelsat 7B", - "ExpressAM6", - "Hellas Sat 3", - "Intelsat 39", - "Intelsat 17", - "NSS 12", - "Sicral 2", - "SkyNet 5B", - "SkyNet 5D", - "Syracuse 4A", - "Turksat 3A", - "Turksat 4A", - "WGS 10", - "Yamal 402", - ] - - for sat in sats: - sat_obj, _ = Satellite.objects.get_or_create(name=sat) - sat_obj.save() - - -def parse_string(s: str): - pattern = r"^(.+?) (-?\d+\,\d+) \[(-?\d+\,\d+)\] ([^\s]+) ([A-Za-z]) - (\d{1,2}\.\d{1,2}\.\d{1,4} \d{1,2}:\d{1,2}:\d{1,2})$" - match = re.match(pattern, s) - if match: - return list(match.groups()) - else: - raise ValueError("Некорректный формат строки") - - -def get_point_from_json(filepath: str): - with open(filepath, encoding="utf-8-sig") as jf: - data = json.load(jf) - - for obj in data: - if not obj.get("bearingBehavior", {}): - if obj["tacticObjectType"] == "source": - # if not obj['bearingBehavior']: - source_id = obj["id"] - name = obj["name"] - elements = parse_string(name) - sat_name = elements[0] - freq = elements[1] - freq_range = elements[2] - pol = elements[4] - timestamp = datetime.strptime(elements[-1], "%d.%m.%y %H:%M:%S") - lat = None - lon = None - for pos in data: - if pos["id"] == source_id and pos["tacticObjectType"] == "position": - lat = pos["latitude"] - lon = pos["longitude"] - break - print( - f"Name - {sat_name}, f - {freq}, f range - {freq_range}, pol - {pol} " - f"time - {timestamp}, pos - ({lat}, {lon})" - ) - - -def get_points_from_csv(file_content, current_user=None): - df = pd.read_csv( - io.StringIO(file_content), - sep=";", - names=[ - "id", - "obj", - "lat", - "lon", - "h", - "time", - "sat", - "norad_id", - "freq", - "f_range", - "et", - "qaul", - "mir_1", - "mir_2", - "mir_3", - ], - ) - df[["lat", "lon", "freq", "f_range"]] = ( - df[["lat", "lon", "freq", "f_range"]] - .replace(",", ".", regex=True) - .astype(float) - ) - df["time"] = pd.to_datetime(df["time"], format="%d.%m.%Y %H:%M:%S") - for row in df.iterrows(): - row = row[1] - match row["obj"].split(" ")[-1]: - case "V": - pol = "Вертикальная" - case "H": - pol = "Горизонтальная" - case "R": - pol = "Правая" - case "L": - pol = "Левая" - case _: - pol = "-" - pol_obj, _ = Polarization.objects.get_or_create(name=pol) - sat_obj, _ = Satellite.objects.get_or_create( - name=row["sat"], defaults={"norad": row["norad_id"]} - ) - mir_1_obj, _ = Mirror.objects.get_or_create(name=row["mir_1"]) - mir_2_obj, _ = Mirror.objects.get_or_create(name=row["mir_2"]) - mir_lst = [row["mir_1"], row["mir_2"]] - if not pd.isna(row["mir_3"]): - mir_3_obj, _ = Mirror.objects.get_or_create(name=row["mir_3"]) - user_to_use = current_user if current_user else CustomUser.objects.get(id=1) - - geo_obj, _ = Geo.objects.get_or_create( - timestamp=row["time"], - coords=Point(row["lon"], row["lat"], srid=4326), - defaults={ - "is_average": False, - # 'id_user_add': user_to_use, - }, - ) - geo_obj.mirrors.set(Mirror.objects.filter(name__in=mir_lst)) - - # Check if ObjItem with same geo already exists - existing_obj_item = ObjItem.objects.filter(geo_obj=geo_obj).first() - if existing_obj_item: - # Check if parameter with same values exists for this object - if ( - hasattr(existing_obj_item, 'parameter_obj') and - existing_obj_item.parameter_obj and - existing_obj_item.parameter_obj.id_satellite == sat_obj and - existing_obj_item.parameter_obj.polarization == pol_obj and - existing_obj_item.parameter_obj.frequency == row["freq"] and - existing_obj_item.parameter_obj.freq_range == row["f_range"] - ): - # Skip creating duplicate - continue - - # Create new ObjItem and Parameter - obj_item = ObjItem.objects.create(name=row["obj"], created_by=user_to_use) - - vch_load_obj = Parameter.objects.create( - id_satellite=sat_obj, - polarization=pol_obj, - frequency=row["freq"], - freq_range=row["f_range"], - objitem=obj_item - ) - - geo_obj.objitem = obj_item - geo_obj.save() - - -def get_vch_load_from_html(file, sat: Satellite) -> None: - filename = file.name.split("_") - transfer = filename[3] - match filename[2]: - case "H": - pol = "Горизонтальная" - case "V": - pol = "Вертикальная" - case "R": - pol = "Правая" - case "L": - pol = "Левая" - case _: - pol = "-" - - tables = pd.read_html(file, encoding="windows-1251") - df = tables[0] - df = df.drop(0).reset_index(drop=True) - df.columns = df.iloc[0] - df = df.drop(0).reset_index(drop=True) - df.replace("Неизвестно", "-", inplace=True) - df[["Частота, МГц", "Полоса, МГц", "Мощность, дБм"]] = df[ - ["Частота, МГц", "Полоса, МГц", "Мощность, дБм"] - ].apply(pd.to_numeric) - df["Время начала измерения"] = df["Время начала измерения"].apply( - lambda x: datetime.strptime(x, "%d.%m.%Y %H:%M:%S") - ) - df["Время окончания измерения"] = df["Время окончания измерения"].apply( - lambda x: datetime.strptime(x, "%d.%m.%Y %H:%M:%S") - ) - - for stroka in df.iterrows(): - value = stroka[1] - if value["Полоса, МГц"] < 0.08: - continue - if "-" in value["Символьная скорость"]: - bod_velocity = -1.0 - else: - bod_velocity = value["Символьная скорость"] - if "-" in value["Сигнал/шум, дБ"]: - snr = -1.0 - else: - snr = value["Сигнал/шум, дБ"] - if value["Пакетность"] == "да": - pack = True - elif value["Пакетность"] == "нет": - pack = False - else: - pack = None - - polarization, _ = Polarization.objects.get_or_create(name=pol) - - mod, _ = Modulation.objects.get_or_create(name=value["Модуляция"]) - standard, _ = Standard.objects.get_or_create(name=value["Стандарт"]) - sigma_load, _ = SigmaParameter.objects.get_or_create( - id_satellite=sat, - frequency=value["Частота, МГц"], - freq_range=value["Полоса, МГц"], - polarization=polarization, - defaults={ - "transfer": float(transfer), - # "polarization": polarization, - "status": value["Статус"], - "power": value["Мощность, дБм"], - "bod_velocity": bod_velocity, - "modulation": mod, - "snr": snr, - "packets": pack, - "datetime_begin": value["Время начала измерения"], - "datetime_end": value["Время окончания измерения"], - }, - ) - sigma_load.save() - - -def compare_and_link_vch_load( - sat_id: Satellite, eps_freq: float, eps_frange: float, ku_range: float -): - item_obj = ObjItem.objects.filter(parameters_obj__id_satellite=sat_id) - vch_sigma = SigmaParameter.objects.filter(id_satellite=sat_id) - link_count = 0 - obj_count = len(item_obj) - for idx, obj in enumerate(item_obj): - vch_load = obj.parameters_obj.get() - if vch_load.frequency == -1.0: - continue - for sigma in vch_sigma: - if ( - abs(sigma.transfer_frequency - vch_load.frequency) <= eps_freq - and abs(sigma.freq_range - vch_load.freq_range) - <= vch_load.freq_range * eps_frange / 100 - and sigma.polarization == vch_load.polarization - ): - sigma.parameter = vch_load - sigma.save() - link_count += 1 - return obj_count, link_count - - -def kub_report(data_in: io.StringIO) -> pd.DataFrame: - df_in = pd.read_excel(data_in) - df = pd.DataFrame( - columns=[ - "Дата", - "Широта", - "Долгота", - "Высота", - "Населённый пункт", - "ИСЗ", - "Прямой канал, МГц", - "Обратный канал, МГц", - "Перенос, МГц", - "Полоса, МГц", - "Зеркала", - ] - ) - for row in df_in.iterrows(): - value = row[1] - date = datetime.date(datetime.now()) - isz = value["ИСЗ"] - try: - lat = float(value["Широта, град"].strip().replace(",", ".")) - lon = float(value["Долгота, град"].strip().replace(",", ".")) - downlink = float(value["Обратный канал, МГц"].strip().replace(",", ".")) - freq_range = float(value["Полоса, МГц"].strip().replace(",", ".")) - except Exception as e: - lat = value["Широта, град"] - lon = value["Долгота, град"] - downlink = value["Обратный канал, МГц"] - freq_range = value["Полоса, МГц"] - print(e) - norad = int(re.findall(r"\((\d+)\)", isz)[0]) - sat_obj = Satellite.objects.get(norad=norad) - pol_obj = Polarization.objects.get(name=value["Поляризация"].strip()) - transponder = Transponders.objects.filter( - sat_id=sat_obj, - polarization=pol_obj, - downlink__gte=downlink - F("frequency_range") / 2, - downlink__lte=downlink + F("frequency_range") / 2, - ).first() - # try: - # location = geolocator.reverse(f"{lat}, {lon}", language="ru").raw['address'] - # loc_name = location.get('city', '') or location.get('town', '') or location.get('province', '') or location.get('country', '') - # except AttributeError: - # loc_name = '' - # sleep(1) - loc_name = "" - if transponder: # and not (len(transponder) > 1): - transfer = transponder.transfer - uplink = transfer + downlink - new_row = pd.DataFrame( - [ - { - "Дата": date, - "Широта": lat, - "Долгота": lon, - "Высота": 0.0, - "Населённый пункт": loc_name, - "ИСЗ": isz, - "Прямой канал, МГц": uplink, - "Обратный канал, МГц": downlink, - "Перенос, МГц": transfer, - "Полоса, МГц": freq_range, - "Зеркала": "", - } - ] - ) - df = pd.concat([df, new_row], ignore_index=True) - else: - print("Ничего не найдено в транспондерах") - return df - - -# ============================================================================ -# Утилиты для форматирования -# ============================================================================ - - -def format_coordinates(longitude: float, latitude: float) -> str: - """ - Форматирует координаты в читаемый вид. - - Преобразует числовые координаты в формат с указанием направления - (N/S для широты, E/W для долготы). - - Args: - longitude (float): Долгота в десятичных градусах. - latitude (float): Широта в десятичных градусах. - - Returns: - str: Отформатированная строка координат в формате "XXN/S YYE/W". - - Example: - >>> format_coordinates(37.62, 55.75) - '55.75N 37.62E' - >>> format_coordinates(-122.42, 37.77) - '37.77N 122.42W' - """ - lon_direction = "E" if longitude > 0 else "W" - lat_direction = "N" if latitude > 0 else "S" - - lon_value = abs(longitude) - lat_value = abs(latitude) - - return f"{lat_value}{lat_direction} {lon_value}{lon_direction}" - - -def parse_pagination_params( - request, default_per_page: int = DEFAULT_ITEMS_PER_PAGE -) -> tuple: - """ - Извлекает и валидирует параметры пагинации из запроса. - - Args: - request: HTTP запрос Django. - default_per_page (int): Количество элементов на странице по умолчанию. - - Returns: - tuple: Кортеж (page_number, items_per_page), где: - - page_number (int): Номер текущей страницы (по умолчанию 1). - - items_per_page (int): Количество элементов на странице. - - Example: - >>> page, per_page = parse_pagination_params(request, default_per_page=100) - >>> paginator = Paginator(objects, per_page) - >>> page_obj = paginator.get_page(page) - """ - page_number = request.GET.get("page", 1) - items_per_page = request.GET.get("items_per_page", str(default_per_page)) - - # Валидация page_number - try: - page_number = int(page_number) - if page_number < 1: - page_number = 1 - except (ValueError, TypeError): - page_number = 1 - - # Валидация items_per_page - try: - items_per_page = int(items_per_page) - if items_per_page < 1: - items_per_page = default_per_page - # Ограничиваем максимальное значение для предотвращения перегрузки - if items_per_page > MAX_ITEMS_PER_PAGE: - items_per_page = MAX_ITEMS_PER_PAGE - except (ValueError, TypeError): - items_per_page = default_per_page - - return page_number, items_per_page - - -def get_first_param_subquery(field_name: str): - """ - Возвращает F() выражение для доступа к полю параметра через OneToOne связь. - - После рефакторинга связи Parameter-ObjItem с ManyToMany на OneToOne, - эта функция упрощена для возврата прямого F() выражения вместо подзапроса. - - Args: - field_name (str): Имя поля модели Parameter для извлечения. - Может включать связанные поля через __ (например, 'id_satellite__name'). - - Returns: - F: Django F() объект для использования в annotate(). - - Example: - >>> freq_expr = get_first_param_subquery('frequency') - >>> objects = ObjItem.objects.annotate(first_freq=freq_expr) - >>> for obj in objects: - ... print(obj.first_freq) - """ - return F(f"parameter_obj__{field_name}") +# Standard library imports +import io +import json +import re +from datetime import datetime, time + +# Django imports +from django.contrib.gis.geos import Point +from django.db.models import F + +# Third-party imports +import pandas as pd + +# Local imports +from mapsapp.models import Transponders + +from .models import ( + CustomUser, + Geo, + Mirror, + Modulation, + ObjItem, + Parameter, + Polarization, + Satellite, + SigmaParameter, + Standard, +) + +# ============================================================================ +# Константы +# ============================================================================ + +# Значения по умолчанию для пагинации +DEFAULT_ITEMS_PER_PAGE = 50 +MAX_ITEMS_PER_PAGE = 10000 + +# Значения по умолчанию для данных +DEFAULT_NUMERIC_VALUE = -1.0 +MINIMUM_BANDWIDTH_MHZ = 0.08 + + +def get_all_constants(): + sats = [sat.name for sat in Satellite.objects.all()] + standards = [sat.name for sat in Standard.objects.all()] + pols = [sat.name for sat in Polarization.objects.all()] + mirrors = [sat.name for sat in Mirror.objects.all()] + modulations = [sat.name for sat in Modulation.objects.all()] + return sats, standards, pols, mirrors, modulations + + +def coords_transform(coords: str): + lat_part, lon_part = coords.strip().split() + sign_map = {"N": 1, "E": 1, "S": -1, "W": -1} + + lat_sign_char = lat_part[-1] + lat_value = float(lat_part[:-1].replace(",", ".")) + latitude = lat_value * sign_map.get(lat_sign_char, 1) + + lon_sign_char = lon_part[-1] + lon_value = float(lon_part[:-1].replace(",", ".")) + longitude = lon_value * sign_map.get(lon_sign_char, 1) + + return (longitude, latitude) + + +def remove_str(s: str): + if isinstance(s, str): + if ( + s.strip() == "-" + or s.strip() == "" + or s.strip() == " " + or "неизв" in s.strip() + ): + return -1 + return float(s.strip().replace(",", ".")) + return s + + +def fill_data_from_df(df: pd.DataFrame, sat: Satellite, current_user=None): + try: + df.rename(columns={"Модуляция ": "Модуляция"}, inplace=True) + except Exception as e: + print(e) + consts = get_all_constants() + df.fillna(-1, inplace=True) + for stroka in df.iterrows(): + geo_point = Point(coords_transform(stroka[1]["Координаты"]), srid=4326) + valid_point = None + kupsat_point = None + try: + if ( + stroka[1]["Координаты объекта"] != -1 + and stroka[1]["Координаты Кубсата"] != "+" + ): + if ( + "ИРИ" not in stroka[1]["Координаты объекта"] + and "БЛА" not in stroka[1]["Координаты объекта"] + ): + valid_point = list( + map( + float, + stroka[1]["Координаты объекта"] + .replace(",", ".") + .split(". "), + ) + ) + valid_point = Point(valid_point[1], valid_point[0], srid=4326) + if ( + stroka[1]["Координаты Кубсата"] != -1 + and stroka[1]["Координаты Кубсата"] != "+" + ): + kupsat_point = list( + map( + float, + stroka[1]["Координаты Кубсата"].replace(",", ".").split(". "), + ) + ) + kupsat_point = Point(kupsat_point[1], kupsat_point[0], srid=4326) + except KeyError: + print("В таблице нет столбцов с координатами кубсата") + try: + polarization_obj, _ = Polarization.objects.get_or_create( + name=stroka[1]["Поляризация"].strip() + ) + except KeyError: + polarization_obj, _ = Polarization.objects.get_or_create(name="-") + freq = remove_str(stroka[1]["Частота, МГц"]) + freq_line = remove_str(stroka[1]["Полоса, МГц"]) + v = remove_str(stroka[1]["Символьная скорость, БОД"]) + try: + mod_obj, _ = Modulation.objects.get_or_create( + name=stroka[1]["Модуляция"].strip() + ) + except AttributeError: + mod_obj, _ = Modulation.objects.get_or_create(name="-") + snr = remove_str(stroka[1]["ОСШ"]) + date = stroka[1]["Дата"].date() + time_ = stroka[1]["Время"] + if isinstance(time_, str): + time_ = time_.strip() + time_ = time(0, 0, 0) + timestamp = datetime.combine(date, time_) + current_mirrors = [] + mirror_1 = stroka[1]["Зеркало 1"].strip().split("\n") + mirror_2 = stroka[1]["Зеркало 2"].strip().split("\n") + if len(mirror_1) > 1: + for mir in mirror_1: + mir_obj, _ = Mirror.objects.get_or_create(name=mir.strip()) + current_mirrors.append(mir.strip()) + elif mirror_1[0] not in consts[3]: + mir_obj, _ = Mirror.objects.get_or_create(name=mirror_1[0].strip()) + current_mirrors.append(mirror_1[0].strip()) + if len(mirror_2) > 1: + for mir in mirror_2: + mir_obj, _ = Mirror.objects.get_or_create(name=mir.strip()) + current_mirrors.append(mir.strip()) + elif mirror_2[0] not in consts[3]: + mir_obj, _ = Mirror.objects.get_or_create(name=mirror_2[0].strip()) + current_mirrors.append(mirror_2[0].strip()) + location = stroka[1]["Местоопределение"].strip() + comment = stroka[1]["Комментарий"] + source = stroka[1]["Объект наблюдения"] + user_to_use = current_user if current_user else CustomUser.objects.get(id=1) + + geo, _ = Geo.objects.get_or_create( + timestamp=timestamp, + coords=geo_point, + defaults={ + "coords_kupsat": kupsat_point, + "coords_valid": valid_point, + "location": location, + "comment": comment, + "is_average": (comment != -1.0), + }, + ) + geo.save() + geo.mirrors.set(Mirror.objects.filter(name__in=current_mirrors)) + + # Check if ObjItem with same geo already exists + existing_obj_item = ObjItem.objects.filter(geo_obj=geo).first() + if existing_obj_item: + # Check if parameter with same values exists for this object + if ( + hasattr(existing_obj_item, 'parameter_obj') and + existing_obj_item.parameter_obj and + existing_obj_item.parameter_obj.id_satellite == sat and + existing_obj_item.parameter_obj.polarization == polarization_obj and + existing_obj_item.parameter_obj.frequency == freq and + existing_obj_item.parameter_obj.freq_range == freq_line and + existing_obj_item.parameter_obj.bod_velocity == v and + existing_obj_item.parameter_obj.modulation == mod_obj and + existing_obj_item.parameter_obj.snr == snr + ): + # Skip creating duplicate + continue + + # Create new ObjItem and Parameter + obj_item = ObjItem.objects.create(name=source, created_by=user_to_use) + + vch_load_obj = Parameter.objects.create( + id_satellite=sat, + polarization=polarization_obj, + frequency=freq, + freq_range=freq_line, + bod_velocity=v, + modulation=mod_obj, + snr=snr, + objitem=obj_item + ) + + geo.objitem = obj_item + geo.save() + + +def add_satellite_list(): + sats = [ + "AZERSPACE 2", + "Amos 4", + "Astra 4A", + "ComsatBW-1", + "Eutelsat 16A", + "Eutelsat 21B", + "Eutelsat 7B", + "ExpressAM6", + "Hellas Sat 3", + "Intelsat 39", + "Intelsat 17", + "NSS 12", + "Sicral 2", + "SkyNet 5B", + "SkyNet 5D", + "Syracuse 4A", + "Turksat 3A", + "Turksat 4A", + "WGS 10", + "Yamal 402", + ] + + for sat in sats: + sat_obj, _ = Satellite.objects.get_or_create(name=sat) + sat_obj.save() + + +def parse_string(s: str): + pattern = r"^(.+?) (-?\d+\,\d+) \[(-?\d+\,\d+)\] ([^\s]+) ([A-Za-z]) - (\d{1,2}\.\d{1,2}\.\d{1,4} \d{1,2}:\d{1,2}:\d{1,2})$" + match = re.match(pattern, s) + if match: + return list(match.groups()) + else: + raise ValueError("Некорректный формат строки") + + +def get_point_from_json(filepath: str): + with open(filepath, encoding="utf-8-sig") as jf: + data = json.load(jf) + + for obj in data: + if not obj.get("bearingBehavior", {}): + if obj["tacticObjectType"] == "source": + # if not obj['bearingBehavior']: + source_id = obj["id"] + name = obj["name"] + elements = parse_string(name) + sat_name = elements[0] + freq = elements[1] + freq_range = elements[2] + pol = elements[4] + timestamp = datetime.strptime(elements[-1], "%d.%m.%y %H:%M:%S") + lat = None + lon = None + for pos in data: + if pos["id"] == source_id and pos["tacticObjectType"] == "position": + lat = pos["latitude"] + lon = pos["longitude"] + break + print( + f"Name - {sat_name}, f - {freq}, f range - {freq_range}, pol - {pol} " + f"time - {timestamp}, pos - ({lat}, {lon})" + ) + + +def get_points_from_csv(file_content, current_user=None): + df = pd.read_csv( + io.StringIO(file_content), + sep=";", + names=[ + "id", + "obj", + "lat", + "lon", + "h", + "time", + "sat", + "norad_id", + "freq", + "f_range", + "et", + "qaul", + "mir_1", + "mir_2", + "mir_3", + ], + ) + df[["lat", "lon", "freq", "f_range"]] = ( + df[["lat", "lon", "freq", "f_range"]] + .replace(",", ".", regex=True) + .astype(float) + ) + df["time"] = pd.to_datetime(df["time"], format="%d.%m.%Y %H:%M:%S") + for row in df.iterrows(): + row = row[1] + match row["obj"].split(" ")[-1]: + case "V": + pol = "Вертикальная" + case "H": + pol = "Горизонтальная" + case "R": + pol = "Правая" + case "L": + pol = "Левая" + case _: + pol = "-" + pol_obj, _ = Polarization.objects.get_or_create(name=pol) + sat_obj, _ = Satellite.objects.get_or_create( + name=row["sat"], defaults={"norad": row["norad_id"]} + ) + mir_1_obj, _ = Mirror.objects.get_or_create(name=row["mir_1"]) + mir_2_obj, _ = Mirror.objects.get_or_create(name=row["mir_2"]) + mir_lst = [row["mir_1"], row["mir_2"]] + if not pd.isna(row["mir_3"]): + mir_3_obj, _ = Mirror.objects.get_or_create(name=row["mir_3"]) + user_to_use = current_user if current_user else CustomUser.objects.get(id=1) + + geo_obj, _ = Geo.objects.get_or_create( + timestamp=row["time"], + coords=Point(row["lon"], row["lat"], srid=4326), + defaults={ + "is_average": False, + # 'id_user_add': user_to_use, + }, + ) + geo_obj.mirrors.set(Mirror.objects.filter(name__in=mir_lst)) + + # Check if ObjItem with same geo already exists + existing_obj_item = ObjItem.objects.filter(geo_obj=geo_obj).first() + if existing_obj_item: + # Check if parameter with same values exists for this object + if ( + hasattr(existing_obj_item, 'parameter_obj') and + existing_obj_item.parameter_obj and + existing_obj_item.parameter_obj.id_satellite == sat_obj and + existing_obj_item.parameter_obj.polarization == pol_obj and + existing_obj_item.parameter_obj.frequency == row["freq"] and + existing_obj_item.parameter_obj.freq_range == row["f_range"] + ): + # Skip creating duplicate + continue + + # Create new ObjItem and Parameter + obj_item = ObjItem.objects.create(name=row["obj"], created_by=user_to_use) + + vch_load_obj = Parameter.objects.create( + id_satellite=sat_obj, + polarization=pol_obj, + frequency=row["freq"], + freq_range=row["f_range"], + objitem=obj_item + ) + + geo_obj.objitem = obj_item + geo_obj.save() + + +def get_vch_load_from_html(file, sat: Satellite) -> None: + filename = file.name.split("_") + transfer = filename[3] + match filename[2]: + case "H": + pol = "Горизонтальная" + case "V": + pol = "Вертикальная" + case "R": + pol = "Правая" + case "L": + pol = "Левая" + case _: + pol = "-" + + tables = pd.read_html(file, encoding="windows-1251") + df = tables[0] + df = df.drop(0).reset_index(drop=True) + df.columns = df.iloc[0] + df = df.drop(0).reset_index(drop=True) + df.replace("Неизвестно", "-", inplace=True) + df[["Частота, МГц", "Полоса, МГц", "Мощность, дБм"]] = df[ + ["Частота, МГц", "Полоса, МГц", "Мощность, дБм"] + ].apply(pd.to_numeric) + df["Время начала измерения"] = df["Время начала измерения"].apply( + lambda x: datetime.strptime(x, "%d.%m.%Y %H:%M:%S") + ) + df["Время окончания измерения"] = df["Время окончания измерения"].apply( + lambda x: datetime.strptime(x, "%d.%m.%Y %H:%M:%S") + ) + + for stroka in df.iterrows(): + value = stroka[1] + if value["Полоса, МГц"] < 0.08: + continue + if "-" in value["Символьная скорость"]: + bod_velocity = -1.0 + else: + bod_velocity = value["Символьная скорость"] + if "-" in value["Сигнал/шум, дБ"]: + snr = -1.0 + else: + snr = value["Сигнал/шум, дБ"] + if value["Пакетность"] == "да": + pack = True + elif value["Пакетность"] == "нет": + pack = False + else: + pack = None + + polarization, _ = Polarization.objects.get_or_create(name=pol) + + mod, _ = Modulation.objects.get_or_create(name=value["Модуляция"]) + standard, _ = Standard.objects.get_or_create(name=value["Стандарт"]) + sigma_load, _ = SigmaParameter.objects.get_or_create( + id_satellite=sat, + frequency=value["Частота, МГц"], + freq_range=value["Полоса, МГц"], + polarization=polarization, + defaults={ + "transfer": float(transfer), + # "polarization": polarization, + "status": value["Статус"], + "power": value["Мощность, дБм"], + "bod_velocity": bod_velocity, + "modulation": mod, + "snr": snr, + "packets": pack, + "datetime_begin": value["Время начала измерения"], + "datetime_end": value["Время окончания измерения"], + }, + ) + sigma_load.save() + + +def compare_and_link_vch_load( + sat_id: Satellite, eps_freq: float, eps_frange: float, ku_range: float +): + item_obj = ObjItem.objects.filter(parameters_obj__id_satellite=sat_id) + vch_sigma = SigmaParameter.objects.filter(id_satellite=sat_id) + link_count = 0 + obj_count = len(item_obj) + for idx, obj in enumerate(item_obj): + vch_load = obj.parameters_obj.get() + if vch_load.frequency == -1.0: + continue + for sigma in vch_sigma: + if ( + abs(sigma.transfer_frequency - vch_load.frequency) <= eps_freq + and abs(sigma.freq_range - vch_load.freq_range) + <= vch_load.freq_range * eps_frange / 100 + and sigma.polarization == vch_load.polarization + ): + sigma.parameter = vch_load + sigma.save() + link_count += 1 + return obj_count, link_count + + +def kub_report(data_in: io.StringIO) -> pd.DataFrame: + df_in = pd.read_excel(data_in) + df = pd.DataFrame( + columns=[ + "Дата", + "Широта", + "Долгота", + "Высота", + "Населённый пункт", + "ИСЗ", + "Прямой канал, МГц", + "Обратный канал, МГц", + "Перенос, МГц", + "Полоса, МГц", + "Зеркала", + ] + ) + for row in df_in.iterrows(): + value = row[1] + date = datetime.date(datetime.now()) + isz = value["ИСЗ"] + try: + lat = float(value["Широта, град"].strip().replace(",", ".")) + lon = float(value["Долгота, град"].strip().replace(",", ".")) + downlink = float(value["Обратный канал, МГц"].strip().replace(",", ".")) + freq_range = float(value["Полоса, МГц"].strip().replace(",", ".")) + except Exception as e: + lat = value["Широта, град"] + lon = value["Долгота, град"] + downlink = value["Обратный канал, МГц"] + freq_range = value["Полоса, МГц"] + print(e) + norad = int(re.findall(r"\((\d+)\)", isz)[0]) + sat_obj = Satellite.objects.get(norad=norad) + pol_obj = Polarization.objects.get(name=value["Поляризация"].strip()) + transponder = Transponders.objects.filter( + sat_id=sat_obj, + polarization=pol_obj, + downlink__gte=downlink - F("frequency_range") / 2, + downlink__lte=downlink + F("frequency_range") / 2, + ).first() + # try: + # location = geolocator.reverse(f"{lat}, {lon}", language="ru").raw['address'] + # loc_name = location.get('city', '') or location.get('town', '') or location.get('province', '') or location.get('country', '') + # except AttributeError: + # loc_name = '' + # sleep(1) + loc_name = "" + if transponder: # and not (len(transponder) > 1): + transfer = transponder.transfer + uplink = transfer + downlink + new_row = pd.DataFrame( + [ + { + "Дата": date, + "Широта": lat, + "Долгота": lon, + "Высота": 0.0, + "Населённый пункт": loc_name, + "ИСЗ": isz, + "Прямой канал, МГц": uplink, + "Обратный канал, МГц": downlink, + "Перенос, МГц": transfer, + "Полоса, МГц": freq_range, + "Зеркала": "", + } + ] + ) + df = pd.concat([df, new_row], ignore_index=True) + else: + print("Ничего не найдено в транспондерах") + return df + + +# ============================================================================ +# Утилиты для форматирования +# ============================================================================ + + +def format_coordinates(longitude: float, latitude: float) -> str: + """ + Форматирует координаты в читаемый вид. + + Преобразует числовые координаты в формат с указанием направления + (N/S для широты, E/W для долготы). + + Args: + longitude (float): Долгота в десятичных градусах. + latitude (float): Широта в десятичных градусах. + + Returns: + str: Отформатированная строка координат в формате "XXN/S YYE/W". + + Example: + >>> format_coordinates(37.62, 55.75) + '55.75N 37.62E' + >>> format_coordinates(-122.42, 37.77) + '37.77N 122.42W' + """ + lon_direction = "E" if longitude > 0 else "W" + lat_direction = "N" if latitude > 0 else "S" + + lon_value = abs(longitude) + lat_value = abs(latitude) + + return f"{lat_value}{lat_direction} {lon_value}{lon_direction}" + + +def parse_pagination_params( + request, default_per_page: int = DEFAULT_ITEMS_PER_PAGE +) -> tuple: + """ + Извлекает и валидирует параметры пагинации из запроса. + + Args: + request: HTTP запрос Django. + default_per_page (int): Количество элементов на странице по умолчанию. + + Returns: + tuple: Кортеж (page_number, items_per_page), где: + - page_number (int): Номер текущей страницы (по умолчанию 1). + - items_per_page (int): Количество элементов на странице. + + Example: + >>> page, per_page = parse_pagination_params(request, default_per_page=100) + >>> paginator = Paginator(objects, per_page) + >>> page_obj = paginator.get_page(page) + """ + page_number = request.GET.get("page", 1) + items_per_page = request.GET.get("items_per_page", str(default_per_page)) + + # Валидация page_number + try: + page_number = int(page_number) + if page_number < 1: + page_number = 1 + except (ValueError, TypeError): + page_number = 1 + + # Валидация items_per_page + try: + items_per_page = int(items_per_page) + if items_per_page < 1: + items_per_page = default_per_page + # Ограничиваем максимальное значение для предотвращения перегрузки + if items_per_page > MAX_ITEMS_PER_PAGE: + items_per_page = MAX_ITEMS_PER_PAGE + except (ValueError, TypeError): + items_per_page = default_per_page + + return page_number, items_per_page + + +def get_first_param_subquery(field_name: str): + """ + Возвращает F() выражение для доступа к полю параметра через OneToOne связь. + + После рефакторинга связи Parameter-ObjItem с ManyToMany на OneToOne, + эта функция упрощена для возврата прямого F() выражения вместо подзапроса. + + Args: + field_name (str): Имя поля модели Parameter для извлечения. + Может включать связанные поля через __ (например, 'id_satellite__name'). + + Returns: + F: Django F() объект для использования в annotate(). + + Example: + >>> freq_expr = get_first_param_subquery('frequency') + >>> objects = ObjItem.objects.annotate(first_freq=freq_expr) + >>> for obj in objects: + ... print(obj.first_freq) + """ + return F(f"parameter_obj__{field_name}") diff --git a/dbapp/mainapp/views.py b/dbapp/mainapp/views.py index d40a7aa..4c28714 100644 --- a/dbapp/mainapp/views.py +++ b/dbapp/mainapp/views.py @@ -1,1126 +1,1126 @@ -# Standard library imports -from collections import defaultdict -from io import BytesIO - -# Django imports -from django.contrib import messages -from django.contrib.admin.views.decorators import staff_member_required -from django.contrib.auth import logout -from django.contrib.auth.mixins import LoginRequiredMixin -from django.core.paginator import Paginator -from django.db import models -from django.db.models import F -from django.http import HttpResponse, JsonResponse -from django.shortcuts import redirect, render -from django.urls import reverse_lazy -from django.utils.decorators import method_decorator -from django.views import View -from django.views.generic import ( - CreateView, - DeleteView, - FormView, - UpdateView, -) - -# Third-party imports -import pandas as pd - -# Local imports -from .clusters import get_clusters -from .forms import ( - GeoForm, - LoadCsvData, - LoadExcelData, - NewEventForm, - ObjItemForm, - ParameterForm, - UploadFileForm, - UploadVchLoad, - VchLinkForm, - FillLyngsatDataForm, -) -from .mixins import CoordinateProcessingMixin, FormMessageMixin, RoleRequiredMixin -from .models import Geo, Modulation, ObjItem, Polarization, Satellite -from .utils import ( - add_satellite_list, - compare_and_link_vch_load, - fill_data_from_df, - get_points_from_csv, - get_vch_load_from_html, - kub_report, - parse_pagination_params, -) -from mapsapp.utils import parse_transponders_from_xml - - -class AddSatellitesView(LoginRequiredMixin, View): - def get(self, request): - add_satellite_list() - return redirect("mainapp:home") - - -# class AddTranspondersView(View): -# def get(self, request): -# try: -# parse_transponders_from_json(BASE_DIR / "transponders.json") -# except FileNotFoundError: -# print("Файл не найден") -# return redirect('home') - - -class AddTranspondersView(LoginRequiredMixin, FormMessageMixin, FormView): - template_name = "mainapp/transponders_upload.html" - form_class = UploadFileForm - success_message = "Файл успешно обработан" - error_message = "Форма заполнена некорректно" - - def form_valid(self, form): - uploaded_file = self.request.FILES["file"] - try: - content = uploaded_file.read() - parse_transponders_from_xml(BytesIO(content)) - except ValueError as e: - messages.error(self.request, f"Ошибка при чтении таблиц: {e}") - return redirect("mainapp:add_trans") - except Exception as e: - messages.error(self.request, f"Неизвестная ошибка: {e}") - return redirect("mainapp:add_trans") - return super().form_valid(form) - - def get_success_url(self): - return reverse_lazy("mainapp:add_trans") - - -from django.views.generic import View - - -class ActionsPageView(View): - def get(self, request): - if request.user.is_authenticated: - return render(request, "mainapp/actions.html") - else: - return render(request, "mainapp/login_required.html") - - -class HomePageView(View): - def get(self, request): - if request.user.is_authenticated: - # Redirect to objitem list if authenticated - return redirect("mainapp:objitem_list") - else: - return render(request, "mainapp/login_required.html") - - -class LoadExcelDataView(LoginRequiredMixin, FormMessageMixin, FormView): - template_name = "mainapp/add_data_from_excel.html" - form_class = LoadExcelData - error_message = "Форма заполнена некорректно" - - def form_valid(self, form): - uploaded_file = self.request.FILES["file"] - selected_sat = form.cleaned_data["sat_choice"] - number = form.cleaned_data["number_input"] - - try: - import io - - df = pd.read_excel(io.BytesIO(uploaded_file.read())) - if number > 0: - df = df.head(number) - result = fill_data_from_df(df, selected_sat, self.request.user.customuser) - - messages.success( - self.request, f"Данные успешно загружены! Обработано строк: {result}" - ) - except Exception as e: - messages.error(self.request, f"Ошибка при обработке файла: {str(e)}") - - return redirect("mainapp:load_excel_data") - - def get_success_url(self): - return reverse_lazy("mainapp:load_excel_data") - - -class GetLocationsView(LoginRequiredMixin, View): - def get(self, request, sat_id): - locations = ( - ObjItem.objects.filter(parameter_obj__id_satellite=sat_id) - .select_related( - "geo_obj", - "parameter_obj", - "parameter_obj__polarization", - ) - ) - - if not locations.exists(): - return JsonResponse({"error": "Объектов не найдено"}, status=404) - - features = [] - for loc in locations: - if not hasattr(loc, "geo_obj") or not loc.geo_obj or not loc.geo_obj.coords: - continue - - param = getattr(loc, 'parameter_obj', None) - if not param: - continue - - features.append( - { - "type": "Feature", - "geometry": { - "type": "Point", - "coordinates": [loc.geo_obj.coords[0], loc.geo_obj.coords[1]], - }, - "properties": { - "pol": param.polarization.name if param.polarization else "-", - "freq": param.frequency * 1000000 if param.frequency else 0, - "name": loc.name or "-", - "id": loc.geo_obj.id, - }, - } - ) - - return JsonResponse({"type": "FeatureCollection", "features": features}) - - -class LoadCsvDataView(LoginRequiredMixin, FormMessageMixin, FormView): - template_name = "mainapp/add_data_from_csv.html" - form_class = LoadCsvData - success_message = "Данные успешно загружены!" - error_message = "Форма заполнена некорректно" - - def form_valid(self, form): - uploaded_file = self.request.FILES["file"] - try: - content = uploaded_file.read() - if isinstance(content, bytes): - content = content.decode("utf-8") - - get_points_from_csv(content, self.request.user.customuser) - except Exception as e: - messages.error(self.request, f"Ошибка при обработке файла: {str(e)}") - return redirect("mainapp:load_csv_data") - - return super().form_valid(form) - - def get_success_url(self): - return reverse_lazy("mainapp:load_csv_data") - - -@method_decorator(staff_member_required, name="dispatch") -class ShowMapView(RoleRequiredMixin, View): - required_roles = ["admin", "moderator"] - - def get(self, request): - ids = request.GET.get("ids", "") - points = [] - if ids: - id_list = [int(x) for x in ids.split(",") if x.isdigit()] - locations = ObjItem.objects.filter(id__in=id_list).select_related( - "parameter_obj", - "parameter_obj__id_satellite", - "parameter_obj__polarization", - "parameter_obj__modulation", - "parameter_obj__standard", - "geo_obj", - ) - for obj in locations: - if ( - not hasattr(obj, "geo_obj") - or not obj.geo_obj - or not obj.geo_obj.coords - ): - continue - param = getattr(obj, 'parameter_obj', None) - if not param: - continue - points.append( - { - "name": f"{obj.name}", - "freq": f"{param.frequency} [{param.freq_range}] МГц", - "point": (obj.geo_obj.coords.x, obj.geo_obj.coords.y), - } - ) - else: - return redirect("admin") - grouped = defaultdict(list) - for p in points: - grouped[p["name"]].append({"point": p["point"], "frequency": p["freq"]}) - - groups = [ - {"name": name, "points": coords_list} - for name, coords_list in grouped.items() - ] - - context = { - "groups": groups, - } - return render(request, "admin/map_custom.html", context) - - -class ShowSelectedObjectsMapView(LoginRequiredMixin, View): - def get(self, request): - ids = request.GET.get("ids", "") - points = [] - if ids: - id_list = [int(x) for x in ids.split(",") if x.isdigit()] - locations = ObjItem.objects.filter(id__in=id_list).select_related( - "parameter_obj", - "parameter_obj__id_satellite", - "parameter_obj__polarization", - "parameter_obj__modulation", - "parameter_obj__standard", - "geo_obj", - ) - for obj in locations: - if ( - not hasattr(obj, "geo_obj") - or not obj.geo_obj - or not obj.geo_obj.coords - ): - continue - param = getattr(obj, 'parameter_obj', None) - if not param: - continue - points.append( - { - "name": f"{obj.name}", - "freq": f"{param.frequency} [{param.freq_range}] МГц", - "point": (obj.geo_obj.coords.x, obj.geo_obj.coords.y), - } - ) - else: - return redirect("mainapp:objitem_list") - - # Group points by object name - from collections import defaultdict - - grouped = defaultdict(list) - for p in points: - grouped[p["name"]].append({"point": p["point"], "frequency": p["freq"]}) - - groups = [ - {"name": name, "points": coords_list} - for name, coords_list in grouped.items() - ] - - context = { - "groups": groups, - } - return render(request, "mainapp/objitem_map.html", context) - - -class ClusterTestView(LoginRequiredMixin, View): - def get(self, request): - objs = ObjItem.objects.filter( - name__icontains="! Astra 4A 12654,040 [1,962] МГц H" - ) - coords = [] - for obj in objs: - if hasattr(obj, "geo_obj") and obj.geo_obj and obj.geo_obj.coords: - coords.append( - (obj.geo_obj.coords.coords[1], obj.geo_obj.coords.coords[0]) - ) - get_clusters(coords) - - return JsonResponse({"success": "ок"}) - - -def custom_logout(request): - logout(request) - return redirect("mainapp:home") - - -class UploadVchLoadView(LoginRequiredMixin, FormMessageMixin, FormView): - template_name = "mainapp/upload_html.html" - form_class = UploadVchLoad - success_message = "Файл успешно обработан" - error_message = "Форма заполнена некорректно" - - def form_valid(self, form): - selected_sat = form.cleaned_data["sat_choice"] - uploaded_file = self.request.FILES["file"] - try: - get_vch_load_from_html(uploaded_file, selected_sat) - except ValueError as e: - messages.error(self.request, f"Ошибка при чтении таблиц: {e}") - return redirect("mainapp:vch_load") - except Exception as e: - messages.error(self.request, f"Неизвестная ошибка: {e}") - return redirect("mainapp:vch_load") - - return super().form_valid(form) - - def get_success_url(self): - return reverse_lazy("mainapp:vch_load") - - -class LinkVchSigmaView(LoginRequiredMixin, FormView): - template_name = "mainapp/link_vch.html" - form_class = VchLinkForm - - def form_valid(self, form): - freq = form.cleaned_data["value1"] - freq_range = form.cleaned_data["value2"] - # ku_range = float(form.cleaned_data['ku_range']) - sat_id = form.cleaned_data["sat_choice"] - # print(freq, freq_range, ku_range, sat_id.pk) - count_all, link_count = compare_and_link_vch_load(sat_id, freq, freq_range, 1) - messages.success( - self.request, f"Привязано {link_count} из {count_all} объектов" - ) - return redirect("mainapp:link_vch_sigma") - - def form_invalid(self, form): - return self.render_to_response(self.get_context_data(form=form)) - - -class ProcessKubsatView(LoginRequiredMixin, FormMessageMixin, FormView): - template_name = "mainapp/process_kubsat.html" - form_class = NewEventForm - error_message = "Форма заполнена некорректно" - - def form_valid(self, form): - uploaded_file = self.request.FILES["file"] - try: - content = uploaded_file.read() - df = kub_report(BytesIO(content)) - output = BytesIO() - with pd.ExcelWriter(output, engine="openpyxl") as writer: - df.to_excel(writer, index=False, sheet_name="Результат") - output.seek(0) - - response = HttpResponse( - output.getvalue(), - content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ) - response["Content-Disposition"] = ( - 'attachment; filename="kubsat_report.xlsx"' - ) - - messages.success(self.request, "Событие успешно обработано!") - return response - except Exception as e: - messages.error(self.request, f"Ошибка при обработке файла: {str(e)}") - return redirect("mainapp:kubsat_excel") - - -class DeleteSelectedObjectsView(RoleRequiredMixin, View): - required_roles = ["admin", "moderator"] - - def post(self, request): - ids = request.POST.get("ids", "") - if not ids: - return JsonResponse({"error": "Нет ID для удаления"}, status=400) - - try: - id_list = [int(x) for x in ids.split(",") if x.isdigit()] - deleted_count, _ = ObjItem.objects.filter(id__in=id_list).delete() - - return JsonResponse( - { - "success": True, - "message": "Объект успешно удалён", - "deleted_count": deleted_count, - } - ) - except Exception as e: - return JsonResponse({"error": f"Ошибка при удалении: {str(e)}"}, status=500) - - -class ObjItemListView(LoginRequiredMixin, View): - def get(self, request): - satellites = ( - Satellite.objects.filter(parameters__objitem__isnull=False) - .distinct() - .only("id", "name") - .order_by("name") - ) - - selected_sat_id = request.GET.get("satellite_id") - page_number, items_per_page = parse_pagination_params(request) - sort_param = request.GET.get("sort", "") - - freq_min = request.GET.get("freq_min") - freq_max = request.GET.get("freq_max") - range_min = request.GET.get("range_min") - range_max = request.GET.get("range_max") - snr_min = request.GET.get("snr_min") - snr_max = request.GET.get("snr_max") - bod_min = request.GET.get("bod_min") - bod_max = request.GET.get("bod_max") - search_query = request.GET.get("search") - selected_modulations = request.GET.getlist("modulation") - selected_polarizations = request.GET.getlist("polarization") - selected_satellites = request.GET.getlist("satellite_id") - has_kupsat = request.GET.get("has_kupsat") - has_valid = request.GET.get("has_valid") - date_from = request.GET.get("date_from") - date_to = request.GET.get("date_to") - - objects = ObjItem.objects.none() - - if selected_satellites or selected_sat_id: - if selected_sat_id and not selected_satellites: - try: - selected_sat_id_single = int(selected_sat_id) - selected_satellites = [selected_sat_id_single] - except ValueError: - selected_satellites = [] - - if selected_satellites: - objects = ( - ObjItem.objects.select_related( - "geo_obj", - "updated_by__user", - "created_by__user", - "parameter_obj", - "parameter_obj__id_satellite", - "parameter_obj__polarization", - "parameter_obj__modulation", - "parameter_obj__standard", - ) - .filter(parameter_obj__id_satellite_id__in=selected_satellites) - ) - else: - objects = ObjItem.objects.select_related( - "geo_obj", - "updated_by__user", - "created_by__user", - "parameter_obj", - "parameter_obj__id_satellite", - "parameter_obj__polarization", - "parameter_obj__modulation", - "parameter_obj__standard", - ) - - if freq_min is not None and freq_min.strip() != "": - try: - freq_min_val = float(freq_min) - objects = objects.filter( - parameter_obj__frequency__gte=freq_min_val - ) - except ValueError: - pass - if freq_max is not None and freq_max.strip() != "": - try: - freq_max_val = float(freq_max) - objects = objects.filter( - parameter_obj__frequency__lte=freq_max_val - ) - except ValueError: - pass - - if range_min is not None and range_min.strip() != "": - try: - range_min_val = float(range_min) - objects = objects.filter( - parameter_obj__freq_range__gte=range_min_val - ) - except ValueError: - pass - if range_max is not None and range_max.strip() != "": - try: - range_max_val = float(range_max) - objects = objects.filter( - parameter_obj__freq_range__lte=range_max_val - ) - except ValueError: - pass - - if snr_min is not None and snr_min.strip() != "": - try: - snr_min_val = float(snr_min) - objects = objects.filter(parameter_obj__snr__gte=snr_min_val) - except ValueError: - pass - if snr_max is not None and snr_max.strip() != "": - try: - snr_max_val = float(snr_max) - objects = objects.filter(parameter_obj__snr__lte=snr_max_val) - except ValueError: - pass - - if bod_min is not None and bod_min.strip() != "": - try: - bod_min_val = float(bod_min) - objects = objects.filter( - parameter_obj__bod_velocity__gte=bod_min_val - ) - except ValueError: - pass - if bod_max is not None and bod_max.strip() != "": - try: - bod_max_val = float(bod_max) - objects = objects.filter( - parameter_obj__bod_velocity__lte=bod_max_val - ) - except ValueError: - pass - - if selected_modulations: - objects = objects.filter( - parameter_obj__modulation__id__in=selected_modulations - ) - - if selected_polarizations: - objects = objects.filter( - parameter_obj__polarization__id__in=selected_polarizations - ) - - if has_kupsat == "1": - objects = objects.filter(geo_obj__coords_kupsat__isnull=False) - elif has_kupsat == "0": - objects = objects.filter(geo_obj__coords_kupsat__isnull=True) - - if has_valid == "1": - objects = objects.filter(geo_obj__coords_valid__isnull=False) - elif has_valid == "0": - objects = objects.filter(geo_obj__coords_valid__isnull=True) - - # Date filter for geo_obj timestamp - date_from = request.GET.get("date_from") - date_to = request.GET.get("date_to") - - if date_from and date_from.strip(): - try: - from datetime import datetime - date_from_obj = datetime.strptime(date_from, "%Y-%m-%d") - objects = objects.filter(geo_obj__timestamp__gte=date_from_obj) - except (ValueError, TypeError): - pass - - if date_to and date_to.strip(): - try: - from datetime import datetime, timedelta - date_to_obj = datetime.strptime(date_to, "%Y-%m-%d") - # Add one day to include the entire end date - date_to_obj = date_to_obj + timedelta(days=1) - objects = objects.filter(geo_obj__timestamp__lt=date_to_obj) - except (ValueError, TypeError): - pass - - if search_query: - search_query = search_query.strip() - if search_query: - objects = objects.filter( - models.Q(name__icontains=search_query) - | models.Q(geo_obj__location__icontains=search_query) - ) - else: - selected_sat_id = None - - objects = objects.annotate( - first_param_freq=F("parameter_obj__frequency"), - first_param_range=F("parameter_obj__freq_range"), - first_param_snr=F("parameter_obj__snr"), - first_param_bod=F("parameter_obj__bod_velocity"), - first_param_sat_name=F("parameter_obj__id_satellite__name"), - first_param_pol_name=F("parameter_obj__polarization__name"), - first_param_mod_name=F("parameter_obj__modulation__name"), - ) - - valid_sort_fields = { - "name": "name", - "-name": "-name", - "updated_at": "updated_at", - "-updated_at": "-updated_at", - "created_at": "created_at", - "-created_at": "-created_at", - "updated_by": "updated_by__user__username", - "-updated_by": "-updated_by__user__username", - "created_by": "created_by__user__username", - "-created_by": "-created_by__user__username", - "geo_timestamp": "geo_obj__timestamp", - "-geo_timestamp": "-geo_obj__timestamp", - "frequency": "first_param_freq", - "-frequency": "-first_param_freq", - "freq_range": "first_param_range", - "-freq_range": "-first_param_range", - "snr": "first_param_snr", - "-snr": "-first_param_snr", - "bod_velocity": "first_param_bod", - "-bod_velocity": "-first_param_bod", - "satellite": "first_param_sat_name", - "-satellite": "-first_param_sat_name", - "polarization": "first_param_pol_name", - "-polarization": "-first_param_pol_name", - "modulation": "first_param_mod_name", - "-modulation": "-first_param_mod_name", - } - - if sort_param in valid_sort_fields: - objects = objects.order_by(valid_sort_fields[sort_param]) - - paginator = Paginator(objects, items_per_page) - page_obj = paginator.get_page(page_number) - - processed_objects = [] - for obj in page_obj: - param = getattr(obj, 'parameter_obj', None) - - geo_coords = "-" - geo_timestamp = "-" - geo_location = "-" - kupsat_coords = "-" - valid_coords = "-" - distance_geo_kup = "-" - distance_geo_valid = "-" - distance_kup_valid = "-" - - if hasattr(obj, "geo_obj") and obj.geo_obj: - geo_timestamp = obj.geo_obj.timestamp - geo_location = obj.geo_obj.location - - if obj.geo_obj.coords: - longitude = obj.geo_obj.coords.coords[0] - latitude = obj.geo_obj.coords.coords[1] - lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" - lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" - geo_coords = f"{lat} {lon}" - - if obj.geo_obj.coords_kupsat: - longitude = obj.geo_obj.coords_kupsat.coords[0] - latitude = obj.geo_obj.coords_kupsat.coords[1] - lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" - lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" - kupsat_coords = f"{lat} {lon}" - elif obj.geo_obj.coords_kupsat is not None: - kupsat_coords = "-" - - if obj.geo_obj.coords_valid: - longitude = obj.geo_obj.coords_valid.coords[0] - latitude = obj.geo_obj.coords_valid.coords[1] - lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" - lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" - valid_coords = f"{lat} {lon}" - elif obj.geo_obj.coords_valid is not None: - valid_coords = "-" - - if obj.geo_obj.distance_coords_kup is not None: - distance_geo_kup = f"{obj.geo_obj.distance_coords_kup:.3f}" - - if obj.geo_obj.distance_coords_valid is not None: - distance_geo_valid = f"{obj.geo_obj.distance_coords_valid:.3f}" - - if obj.geo_obj.distance_kup_valid is not None: - distance_kup_valid = f"{obj.geo_obj.distance_kup_valid:.3f}" - - satellite_name = "-" - frequency = "-" - freq_range = "-" - polarization_name = "-" - bod_velocity = "-" - modulation_name = "-" - snr = "-" - standard_name = "-" - comment = "-" - is_average = "-" - - if param: - if hasattr(param, "id_satellite") and param.id_satellite: - satellite_name = ( - param.id_satellite.name - if hasattr(param.id_satellite, "name") - else "-" - ) - - frequency = ( - f"{param.frequency:.3f}" if param.frequency is not None else "-" - ) - freq_range = ( - f"{param.freq_range:.3f}" if param.freq_range is not None else "-" - ) - bod_velocity = ( - f"{param.bod_velocity:.0f}" - if param.bod_velocity is not None - else "-" - ) - snr = f"{param.snr:.0f}" if param.snr is not None else "-" - - if hasattr(param, "polarization") and param.polarization: - polarization_name = ( - param.polarization.name - if hasattr(param.polarization, "name") - else "-" - ) - - if hasattr(param, "modulation") and param.modulation: - modulation_name = ( - param.modulation.name - if hasattr(param.modulation, "name") - else "-" - ) - - if hasattr(param, "standard") and param.standard: - standard_name = ( - param.standard.name - if hasattr(param.standard, "name") - else "-" - ) - - if hasattr(obj, "geo_obj") and obj.geo_obj: - comment = obj.geo_obj.comment or "-" - is_average = "Да" if obj.geo_obj.is_average else "Нет" if obj.geo_obj.is_average is not None else "-" - - processed_objects.append( - { - "id": obj.id, - "name": obj.name or "-", - "satellite_name": satellite_name, - "frequency": frequency, - "freq_range": freq_range, - "polarization": polarization_name, - "bod_velocity": bod_velocity, - "modulation": modulation_name, - "snr": snr, - "geo_timestamp": geo_timestamp, - "geo_location": geo_location, - "geo_coords": geo_coords, - "kupsat_coords": kupsat_coords, - "valid_coords": valid_coords, - "distance_geo_kup": distance_geo_kup, - "distance_geo_valid": distance_geo_valid, - "distance_kup_valid": distance_kup_valid, - "updated_by": obj.updated_by if obj.updated_by else "-", - "comment": comment, - "is_average": is_average, - "standard": standard_name, - "obj": obj, - } - ) - - modulations = Modulation.objects.all() - polarizations = Polarization.objects.all() - - context = { - "satellites": satellites, - "selected_satellite_id": selected_sat_id, - "page_obj": page_obj, - "processed_objects": processed_objects, - "items_per_page": items_per_page, - "available_items_per_page": [50, 100, 500, 1000], - "freq_min": freq_min, - "freq_max": freq_max, - "range_min": range_min, - "range_max": range_max, - "snr_min": snr_min, - "snr_max": snr_max, - "bod_min": bod_min, - "bod_max": bod_max, - "search_query": search_query, - "selected_modulations": [ - int(x) for x in selected_modulations if x.isdigit() - ], - "selected_polarizations": [ - int(x) for x in selected_polarizations if x.isdigit() - ], - "selected_satellites": [int(x) for x in selected_satellites if x.isdigit()], - "has_kupsat": has_kupsat, - "has_valid": has_valid, - "date_from": date_from, - "date_to": date_to, - "modulations": modulations, - "polarizations": polarizations, - "full_width_page": True, - "sort": sort_param, - } - - return render(request, "mainapp/objitem_list.html", context) - - -class ObjItemFormView( - RoleRequiredMixin, CoordinateProcessingMixin, FormMessageMixin, UpdateView -): - """ - Базовый класс для создания и редактирования ObjItem. - - Содержит общую логику обработки форм, координат и параметров. - """ - - model = ObjItem - form_class = ObjItemForm - template_name = "mainapp/objitem_form.html" - success_url = reverse_lazy("mainapp:home") - required_roles = ["admin", "moderator"] - - def get_success_url(self): - """Возвращает URL с сохраненными параметрами фильтров.""" - # Получаем все параметры из GET запроса и сохраняем их в URL - if self.request.GET: - from urllib.parse import urlencode - query_string = urlencode(self.request.GET) - return reverse_lazy("mainapp:objitem_list") + '?' + query_string - return reverse_lazy("mainapp:objitem_list") - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["LEAFLET_CONFIG"] = { - "DEFAULT_CENTER": (55.75, 37.62), - "DEFAULT_ZOOM": 5, - } - - # Сохраняем параметры возврата для кнопки "Назад" - context["return_params"] = self.request.GET.get('return_params', '') - - # Работаем с одной формой параметра вместо formset - if self.object and hasattr(self.object, "parameter_obj") and self.object.parameter_obj: - context["parameter_form"] = ParameterForm( - instance=self.object.parameter_obj, prefix="parameter" - ) - else: - context["parameter_form"] = ParameterForm(prefix="parameter") - - if self.object and hasattr(self.object, "geo_obj") and self.object.geo_obj: - context["geo_form"] = GeoForm( - instance=self.object.geo_obj, prefix="geo" - ) - else: - context["geo_form"] = GeoForm(prefix="geo") - - return context - - def form_valid(self, form): - # Получаем форму параметра - if self.object and hasattr(self.object, "parameter_obj") and self.object.parameter_obj: - parameter_form = ParameterForm( - self.request.POST, - instance=self.object.parameter_obj, - prefix="parameter" - ) - else: - parameter_form = ParameterForm(self.request.POST, prefix="parameter") - - if self.object and hasattr(self.object, "geo_obj") and self.object.geo_obj: - geo_form = GeoForm(self.request.POST, instance=self.object.geo_obj, prefix="geo") - else: - geo_form = GeoForm(self.request.POST, prefix="geo") - - # Сохраняем основной объект - self.object = form.save(commit=False) - self.set_user_fields() - self.object.save() - - # Сохраняем связанный параметр - if parameter_form.is_valid(): - self.save_parameter(parameter_form) - else: - context = self.get_context_data() - context.update({ - 'form': form, - 'parameter_form': parameter_form, - 'geo_form': geo_form, - }) - return self.render_to_response(context) - - # Сохраняем геоданные - if geo_form.is_valid(): - self.save_geo_data(geo_form) - else: - context = self.get_context_data() - context.update({ - 'form': form, - 'parameter_form': parameter_form, - 'geo_form': geo_form, - }) - return self.render_to_response(context) - - return super().form_valid(form) - - def set_user_fields(self): - """Устанавливает поля пользователя для объекта.""" - raise NotImplementedError("Subclasses must implement set_user_fields()") - - def save_parameter(self, parameter_form): - """Сохраняет параметр объекта через OneToOne связь.""" - if parameter_form.is_valid(): - instance = parameter_form.save(commit=False) - instance.objitem = self.object - instance.save() - - def save_geo_data(self, geo_form): - """Сохраняет геоданные объекта.""" - geo_instance = self.get_or_create_geo_instance() - - # Обновляем поля из geo_form - if geo_form.is_valid(): - geo_instance.location = geo_form.cleaned_data["location"] - geo_instance.comment = geo_form.cleaned_data["comment"] - geo_instance.is_average = geo_form.cleaned_data["is_average"] - - # Обрабатываем координаты - self.process_coordinates(geo_instance) - - # Обрабатываем дату/время - self.process_timestamp(geo_instance) - - geo_instance.save() - - def get_or_create_geo_instance(self): - """Получает или создает экземпляр Geo.""" - if hasattr(self.object, "geo_obj") and self.object.geo_obj: - return self.object.geo_obj - return Geo(objitem=self.object) - - -class ObjItemUpdateView(ObjItemFormView): - """Представление для редактирования ObjItem.""" - - success_message = "Объект успешно сохранён!" - - def set_user_fields(self): - self.object.updated_by = self.request.user.customuser - - -class ObjItemCreateView(ObjItemFormView, CreateView): - """Представление для создания ObjItem.""" - - success_message = "Объект успешно создан!" - - def set_user_fields(self): - self.object.created_by = self.request.user.customuser - self.object.updated_by = self.request.user.customuser - - -class ObjItemDeleteView(RoleRequiredMixin, FormMessageMixin, DeleteView): - model = ObjItem - template_name = "mainapp/objitem_confirm_delete.html" - success_url = reverse_lazy("mainapp:objitem_list") - success_message = "Объект успешно удалён!" - required_roles = ["admin", "moderator"] - - def get_success_url(self): - """Возвращает URL с сохраненными параметрами фильтров.""" - # Получаем все параметры из GET запроса и сохраняем их в URL - if self.request.GET: - from urllib.parse import urlencode - query_string = urlencode(self.request.GET) - return reverse_lazy("mainapp:objitem_list") + '?' + query_string - return reverse_lazy("mainapp:objitem_list") - - -class ObjItemDetailView(LoginRequiredMixin, View): - """ - Представление для просмотра деталей ObjItem в режиме чтения. - - Доступно для всех авторизованных пользователей, показывает данные в режиме чтения. - """ - def get(self, request, pk): - obj = ObjItem.objects.filter(pk=pk).select_related( - 'geo_obj', - 'updated_by__user', - 'created_by__user', - 'parameter_obj', - 'parameter_obj__id_satellite', - 'parameter_obj__polarization', - 'parameter_obj__modulation', - 'parameter_obj__standard', - ).first() - - if not obj: - from django.http import Http404 - raise Http404("Объект не найден") - - # Сохраняем параметры возврата для кнопки "Назад" - return_params = request.GET.get('return_params', '') - - context = { - 'object': obj, - 'return_params': return_params - } - - return render(request, "mainapp/objitem_detail.html", context) - - -class FillLyngsatDataView(LoginRequiredMixin, FormMessageMixin, FormView): - """ - Представление для заполнения данных из Lyngsat. - - Позволяет выбрать спутники и регионы для парсинга данных с сайта Lyngsat. - Запускает асинхронную задачу Celery для обработки. - """ - template_name = "mainapp/fill_lyngsat_data.html" - form_class = FillLyngsatDataForm - success_url = reverse_lazy("mainapp:lyngsat_task_status") - error_message = "Форма заполнена некорректно" - - def form_valid(self, form): - satellites = form.cleaned_data["satellites"] - regions = form.cleaned_data["regions"] - - # Получаем названия спутников - target_sats = [sat.name for sat in satellites] - - try: - from lyngsatapp.tasks import fill_lyngsat_data_task - - # Запускаем асинхронную задачу - task = fill_lyngsat_data_task.delay(target_sats, regions) - - messages.success( - self.request, - f"Задача запущена! ID задачи: {task.id}. " - "Вы будете перенаправлены на страницу отслеживания прогресса." - ) - - # Перенаправляем на страницу статуса задачи - return redirect('mainapp:lyngsat_task_status', task_id=task.id) - - except Exception as e: - messages.error(self.request, f"Ошибка при запуске задачи: {str(e)}") - return redirect("mainapp:fill_lyngsat_data") - - -class LyngsatTaskStatusView(LoginRequiredMixin, View): - """ - Представление для отслеживания статуса задачи заполнения данных Lyngsat. - """ - template_name = "mainapp/lyngsat_task_status.html" - - def get(self, request, task_id=None): - context = { - 'task_id': task_id - } - return render(request, self.template_name, context) - - -class LyngsatTaskStatusAPIView(LoginRequiredMixin, View): - """ - API для получения статуса задачи Celery. - """ - def get(self, request, task_id): - from celery.result import AsyncResult - from django.core.cache import cache - - task = AsyncResult(task_id) - - response_data = { - 'task_id': task_id, - 'state': task.state, - 'result': None, - 'error': None - } - - if task.state == 'PENDING': - response_data['status'] = 'Задача в очереди...' - elif task.state == 'PROGRESS': - response_data['status'] = task.info.get('status', '') - response_data['current'] = task.info.get('current', 0) - response_data['total'] = task.info.get('total', 1) - response_data['percent'] = int((task.info.get('current', 0) / task.info.get('total', 1)) * 100) - elif task.state == 'SUCCESS': - # Получаем результат из кеша - result = cache.get(f'lyngsat_task_{task_id}') - if result: - response_data['result'] = result - response_data['status'] = 'Задача завершена успешно' - else: - response_data['result'] = task.result - response_data['status'] = 'Задача завершена' - elif task.state == 'FAILURE': - response_data['status'] = 'Ошибка при выполнении задачи' - response_data['error'] = str(task.info) - else: - response_data['status'] = task.state - - return JsonResponse(response_data) +# Standard library imports +from collections import defaultdict +from io import BytesIO + +# Django imports +from django.contrib import messages +from django.contrib.admin.views.decorators import staff_member_required +from django.contrib.auth import logout +from django.contrib.auth.mixins import LoginRequiredMixin +from django.core.paginator import Paginator +from django.db import models +from django.db.models import F +from django.http import HttpResponse, JsonResponse +from django.shortcuts import redirect, render +from django.urls import reverse_lazy +from django.utils.decorators import method_decorator +from django.views import View +from django.views.generic import ( + CreateView, + DeleteView, + FormView, + UpdateView, +) + +# Third-party imports +import pandas as pd + +# Local imports +from .clusters import get_clusters +from .forms import ( + GeoForm, + LoadCsvData, + LoadExcelData, + NewEventForm, + ObjItemForm, + ParameterForm, + UploadFileForm, + UploadVchLoad, + VchLinkForm, + FillLyngsatDataForm, +) +from .mixins import CoordinateProcessingMixin, FormMessageMixin, RoleRequiredMixin +from .models import Geo, Modulation, ObjItem, Polarization, Satellite +from .utils import ( + add_satellite_list, + compare_and_link_vch_load, + fill_data_from_df, + get_points_from_csv, + get_vch_load_from_html, + kub_report, + parse_pagination_params, +) +from mapsapp.utils import parse_transponders_from_xml + + +class AddSatellitesView(LoginRequiredMixin, View): + def get(self, request): + add_satellite_list() + return redirect("mainapp:home") + + +# class AddTranspondersView(View): +# def get(self, request): +# try: +# parse_transponders_from_json(BASE_DIR / "transponders.json") +# except FileNotFoundError: +# print("Файл не найден") +# return redirect('home') + + +class AddTranspondersView(LoginRequiredMixin, FormMessageMixin, FormView): + template_name = "mainapp/transponders_upload.html" + form_class = UploadFileForm + success_message = "Файл успешно обработан" + error_message = "Форма заполнена некорректно" + + def form_valid(self, form): + uploaded_file = self.request.FILES["file"] + try: + content = uploaded_file.read() + parse_transponders_from_xml(BytesIO(content)) + except ValueError as e: + messages.error(self.request, f"Ошибка при чтении таблиц: {e}") + return redirect("mainapp:add_trans") + except Exception as e: + messages.error(self.request, f"Неизвестная ошибка: {e}") + return redirect("mainapp:add_trans") + return super().form_valid(form) + + def get_success_url(self): + return reverse_lazy("mainapp:add_trans") + + +from django.views.generic import View + + +class ActionsPageView(View): + def get(self, request): + if request.user.is_authenticated: + return render(request, "mainapp/actions.html") + else: + return render(request, "mainapp/login_required.html") + + +class HomePageView(View): + def get(self, request): + if request.user.is_authenticated: + # Redirect to objitem list if authenticated + return redirect("mainapp:objitem_list") + else: + return render(request, "mainapp/login_required.html") + + +class LoadExcelDataView(LoginRequiredMixin, FormMessageMixin, FormView): + template_name = "mainapp/add_data_from_excel.html" + form_class = LoadExcelData + error_message = "Форма заполнена некорректно" + + def form_valid(self, form): + uploaded_file = self.request.FILES["file"] + selected_sat = form.cleaned_data["sat_choice"] + number = form.cleaned_data["number_input"] + + try: + import io + + df = pd.read_excel(io.BytesIO(uploaded_file.read())) + if number > 0: + df = df.head(number) + result = fill_data_from_df(df, selected_sat, self.request.user.customuser) + + messages.success( + self.request, f"Данные успешно загружены! Обработано строк: {result}" + ) + except Exception as e: + messages.error(self.request, f"Ошибка при обработке файла: {str(e)}") + + return redirect("mainapp:load_excel_data") + + def get_success_url(self): + return reverse_lazy("mainapp:load_excel_data") + + +class GetLocationsView(LoginRequiredMixin, View): + def get(self, request, sat_id): + locations = ( + ObjItem.objects.filter(parameter_obj__id_satellite=sat_id) + .select_related( + "geo_obj", + "parameter_obj", + "parameter_obj__polarization", + ) + ) + + if not locations.exists(): + return JsonResponse({"error": "Объектов не найдено"}, status=404) + + features = [] + for loc in locations: + if not hasattr(loc, "geo_obj") or not loc.geo_obj or not loc.geo_obj.coords: + continue + + param = getattr(loc, 'parameter_obj', None) + if not param: + continue + + features.append( + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [loc.geo_obj.coords[0], loc.geo_obj.coords[1]], + }, + "properties": { + "pol": param.polarization.name if param.polarization else "-", + "freq": param.frequency * 1000000 if param.frequency else 0, + "name": loc.name or "-", + "id": loc.geo_obj.id, + }, + } + ) + + return JsonResponse({"type": "FeatureCollection", "features": features}) + + +class LoadCsvDataView(LoginRequiredMixin, FormMessageMixin, FormView): + template_name = "mainapp/add_data_from_csv.html" + form_class = LoadCsvData + success_message = "Данные успешно загружены!" + error_message = "Форма заполнена некорректно" + + def form_valid(self, form): + uploaded_file = self.request.FILES["file"] + try: + content = uploaded_file.read() + if isinstance(content, bytes): + content = content.decode("utf-8") + + get_points_from_csv(content, self.request.user.customuser) + except Exception as e: + messages.error(self.request, f"Ошибка при обработке файла: {str(e)}") + return redirect("mainapp:load_csv_data") + + return super().form_valid(form) + + def get_success_url(self): + return reverse_lazy("mainapp:load_csv_data") + + +@method_decorator(staff_member_required, name="dispatch") +class ShowMapView(RoleRequiredMixin, View): + required_roles = ["admin", "moderator"] + + def get(self, request): + ids = request.GET.get("ids", "") + points = [] + if ids: + id_list = [int(x) for x in ids.split(",") if x.isdigit()] + locations = ObjItem.objects.filter(id__in=id_list).select_related( + "parameter_obj", + "parameter_obj__id_satellite", + "parameter_obj__polarization", + "parameter_obj__modulation", + "parameter_obj__standard", + "geo_obj", + ) + for obj in locations: + if ( + not hasattr(obj, "geo_obj") + or not obj.geo_obj + or not obj.geo_obj.coords + ): + continue + param = getattr(obj, 'parameter_obj', None) + if not param: + continue + points.append( + { + "name": f"{obj.name}", + "freq": f"{param.frequency} [{param.freq_range}] МГц", + "point": (obj.geo_obj.coords.x, obj.geo_obj.coords.y), + } + ) + else: + return redirect("admin") + grouped = defaultdict(list) + for p in points: + grouped[p["name"]].append({"point": p["point"], "frequency": p["freq"]}) + + groups = [ + {"name": name, "points": coords_list} + for name, coords_list in grouped.items() + ] + + context = { + "groups": groups, + } + return render(request, "admin/map_custom.html", context) + + +class ShowSelectedObjectsMapView(LoginRequiredMixin, View): + def get(self, request): + ids = request.GET.get("ids", "") + points = [] + if ids: + id_list = [int(x) for x in ids.split(",") if x.isdigit()] + locations = ObjItem.objects.filter(id__in=id_list).select_related( + "parameter_obj", + "parameter_obj__id_satellite", + "parameter_obj__polarization", + "parameter_obj__modulation", + "parameter_obj__standard", + "geo_obj", + ) + for obj in locations: + if ( + not hasattr(obj, "geo_obj") + or not obj.geo_obj + or not obj.geo_obj.coords + ): + continue + param = getattr(obj, 'parameter_obj', None) + if not param: + continue + points.append( + { + "name": f"{obj.name}", + "freq": f"{param.frequency} [{param.freq_range}] МГц", + "point": (obj.geo_obj.coords.x, obj.geo_obj.coords.y), + } + ) + else: + return redirect("mainapp:objitem_list") + + # Group points by object name + from collections import defaultdict + + grouped = defaultdict(list) + for p in points: + grouped[p["name"]].append({"point": p["point"], "frequency": p["freq"]}) + + groups = [ + {"name": name, "points": coords_list} + for name, coords_list in grouped.items() + ] + + context = { + "groups": groups, + } + return render(request, "mainapp/objitem_map.html", context) + + +class ClusterTestView(LoginRequiredMixin, View): + def get(self, request): + objs = ObjItem.objects.filter( + name__icontains="! Astra 4A 12654,040 [1,962] МГц H" + ) + coords = [] + for obj in objs: + if hasattr(obj, "geo_obj") and obj.geo_obj and obj.geo_obj.coords: + coords.append( + (obj.geo_obj.coords.coords[1], obj.geo_obj.coords.coords[0]) + ) + get_clusters(coords) + + return JsonResponse({"success": "ок"}) + + +def custom_logout(request): + logout(request) + return redirect("mainapp:home") + + +class UploadVchLoadView(LoginRequiredMixin, FormMessageMixin, FormView): + template_name = "mainapp/upload_html.html" + form_class = UploadVchLoad + success_message = "Файл успешно обработан" + error_message = "Форма заполнена некорректно" + + def form_valid(self, form): + selected_sat = form.cleaned_data["sat_choice"] + uploaded_file = self.request.FILES["file"] + try: + get_vch_load_from_html(uploaded_file, selected_sat) + except ValueError as e: + messages.error(self.request, f"Ошибка при чтении таблиц: {e}") + return redirect("mainapp:vch_load") + except Exception as e: + messages.error(self.request, f"Неизвестная ошибка: {e}") + return redirect("mainapp:vch_load") + + return super().form_valid(form) + + def get_success_url(self): + return reverse_lazy("mainapp:vch_load") + + +class LinkVchSigmaView(LoginRequiredMixin, FormView): + template_name = "mainapp/link_vch.html" + form_class = VchLinkForm + + def form_valid(self, form): + freq = form.cleaned_data["value1"] + freq_range = form.cleaned_data["value2"] + # ku_range = float(form.cleaned_data['ku_range']) + sat_id = form.cleaned_data["sat_choice"] + # print(freq, freq_range, ku_range, sat_id.pk) + count_all, link_count = compare_and_link_vch_load(sat_id, freq, freq_range, 1) + messages.success( + self.request, f"Привязано {link_count} из {count_all} объектов" + ) + return redirect("mainapp:link_vch_sigma") + + def form_invalid(self, form): + return self.render_to_response(self.get_context_data(form=form)) + + +class ProcessKubsatView(LoginRequiredMixin, FormMessageMixin, FormView): + template_name = "mainapp/process_kubsat.html" + form_class = NewEventForm + error_message = "Форма заполнена некорректно" + + def form_valid(self, form): + uploaded_file = self.request.FILES["file"] + try: + content = uploaded_file.read() + df = kub_report(BytesIO(content)) + output = BytesIO() + with pd.ExcelWriter(output, engine="openpyxl") as writer: + df.to_excel(writer, index=False, sheet_name="Результат") + output.seek(0) + + response = HttpResponse( + output.getvalue(), + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + response["Content-Disposition"] = ( + 'attachment; filename="kubsat_report.xlsx"' + ) + + messages.success(self.request, "Событие успешно обработано!") + return response + except Exception as e: + messages.error(self.request, f"Ошибка при обработке файла: {str(e)}") + return redirect("mainapp:kubsat_excel") + + +class DeleteSelectedObjectsView(RoleRequiredMixin, View): + required_roles = ["admin", "moderator"] + + def post(self, request): + ids = request.POST.get("ids", "") + if not ids: + return JsonResponse({"error": "Нет ID для удаления"}, status=400) + + try: + id_list = [int(x) for x in ids.split(",") if x.isdigit()] + deleted_count, _ = ObjItem.objects.filter(id__in=id_list).delete() + + return JsonResponse( + { + "success": True, + "message": "Объект успешно удалён", + "deleted_count": deleted_count, + } + ) + except Exception as e: + return JsonResponse({"error": f"Ошибка при удалении: {str(e)}"}, status=500) + + +class ObjItemListView(LoginRequiredMixin, View): + def get(self, request): + satellites = ( + Satellite.objects.filter(parameters__objitem__isnull=False) + .distinct() + .only("id", "name") + .order_by("name") + ) + + selected_sat_id = request.GET.get("satellite_id") + page_number, items_per_page = parse_pagination_params(request) + sort_param = request.GET.get("sort", "") + + freq_min = request.GET.get("freq_min") + freq_max = request.GET.get("freq_max") + range_min = request.GET.get("range_min") + range_max = request.GET.get("range_max") + snr_min = request.GET.get("snr_min") + snr_max = request.GET.get("snr_max") + bod_min = request.GET.get("bod_min") + bod_max = request.GET.get("bod_max") + search_query = request.GET.get("search") + selected_modulations = request.GET.getlist("modulation") + selected_polarizations = request.GET.getlist("polarization") + selected_satellites = request.GET.getlist("satellite_id") + has_kupsat = request.GET.get("has_kupsat") + has_valid = request.GET.get("has_valid") + date_from = request.GET.get("date_from") + date_to = request.GET.get("date_to") + + objects = ObjItem.objects.none() + + if selected_satellites or selected_sat_id: + if selected_sat_id and not selected_satellites: + try: + selected_sat_id_single = int(selected_sat_id) + selected_satellites = [selected_sat_id_single] + except ValueError: + selected_satellites = [] + + if selected_satellites: + objects = ( + ObjItem.objects.select_related( + "geo_obj", + "updated_by__user", + "created_by__user", + "parameter_obj", + "parameter_obj__id_satellite", + "parameter_obj__polarization", + "parameter_obj__modulation", + "parameter_obj__standard", + ) + .filter(parameter_obj__id_satellite_id__in=selected_satellites) + ) + else: + objects = ObjItem.objects.select_related( + "geo_obj", + "updated_by__user", + "created_by__user", + "parameter_obj", + "parameter_obj__id_satellite", + "parameter_obj__polarization", + "parameter_obj__modulation", + "parameter_obj__standard", + ) + + if freq_min is not None and freq_min.strip() != "": + try: + freq_min_val = float(freq_min) + objects = objects.filter( + parameter_obj__frequency__gte=freq_min_val + ) + except ValueError: + pass + if freq_max is not None and freq_max.strip() != "": + try: + freq_max_val = float(freq_max) + objects = objects.filter( + parameter_obj__frequency__lte=freq_max_val + ) + except ValueError: + pass + + if range_min is not None and range_min.strip() != "": + try: + range_min_val = float(range_min) + objects = objects.filter( + parameter_obj__freq_range__gte=range_min_val + ) + except ValueError: + pass + if range_max is not None and range_max.strip() != "": + try: + range_max_val = float(range_max) + objects = objects.filter( + parameter_obj__freq_range__lte=range_max_val + ) + except ValueError: + pass + + if snr_min is not None and snr_min.strip() != "": + try: + snr_min_val = float(snr_min) + objects = objects.filter(parameter_obj__snr__gte=snr_min_val) + except ValueError: + pass + if snr_max is not None and snr_max.strip() != "": + try: + snr_max_val = float(snr_max) + objects = objects.filter(parameter_obj__snr__lte=snr_max_val) + except ValueError: + pass + + if bod_min is not None and bod_min.strip() != "": + try: + bod_min_val = float(bod_min) + objects = objects.filter( + parameter_obj__bod_velocity__gte=bod_min_val + ) + except ValueError: + pass + if bod_max is not None and bod_max.strip() != "": + try: + bod_max_val = float(bod_max) + objects = objects.filter( + parameter_obj__bod_velocity__lte=bod_max_val + ) + except ValueError: + pass + + if selected_modulations: + objects = objects.filter( + parameter_obj__modulation__id__in=selected_modulations + ) + + if selected_polarizations: + objects = objects.filter( + parameter_obj__polarization__id__in=selected_polarizations + ) + + if has_kupsat == "1": + objects = objects.filter(geo_obj__coords_kupsat__isnull=False) + elif has_kupsat == "0": + objects = objects.filter(geo_obj__coords_kupsat__isnull=True) + + if has_valid == "1": + objects = objects.filter(geo_obj__coords_valid__isnull=False) + elif has_valid == "0": + objects = objects.filter(geo_obj__coords_valid__isnull=True) + + # Date filter for geo_obj timestamp + date_from = request.GET.get("date_from") + date_to = request.GET.get("date_to") + + if date_from and date_from.strip(): + try: + from datetime import datetime + date_from_obj = datetime.strptime(date_from, "%Y-%m-%d") + objects = objects.filter(geo_obj__timestamp__gte=date_from_obj) + except (ValueError, TypeError): + pass + + if date_to and date_to.strip(): + try: + from datetime import datetime, timedelta + date_to_obj = datetime.strptime(date_to, "%Y-%m-%d") + # Add one day to include the entire end date + date_to_obj = date_to_obj + timedelta(days=1) + objects = objects.filter(geo_obj__timestamp__lt=date_to_obj) + except (ValueError, TypeError): + pass + + if search_query: + search_query = search_query.strip() + if search_query: + objects = objects.filter( + models.Q(name__icontains=search_query) + | models.Q(geo_obj__location__icontains=search_query) + ) + else: + selected_sat_id = None + + objects = objects.annotate( + first_param_freq=F("parameter_obj__frequency"), + first_param_range=F("parameter_obj__freq_range"), + first_param_snr=F("parameter_obj__snr"), + first_param_bod=F("parameter_obj__bod_velocity"), + first_param_sat_name=F("parameter_obj__id_satellite__name"), + first_param_pol_name=F("parameter_obj__polarization__name"), + first_param_mod_name=F("parameter_obj__modulation__name"), + ) + + valid_sort_fields = { + "name": "name", + "-name": "-name", + "updated_at": "updated_at", + "-updated_at": "-updated_at", + "created_at": "created_at", + "-created_at": "-created_at", + "updated_by": "updated_by__user__username", + "-updated_by": "-updated_by__user__username", + "created_by": "created_by__user__username", + "-created_by": "-created_by__user__username", + "geo_timestamp": "geo_obj__timestamp", + "-geo_timestamp": "-geo_obj__timestamp", + "frequency": "first_param_freq", + "-frequency": "-first_param_freq", + "freq_range": "first_param_range", + "-freq_range": "-first_param_range", + "snr": "first_param_snr", + "-snr": "-first_param_snr", + "bod_velocity": "first_param_bod", + "-bod_velocity": "-first_param_bod", + "satellite": "first_param_sat_name", + "-satellite": "-first_param_sat_name", + "polarization": "first_param_pol_name", + "-polarization": "-first_param_pol_name", + "modulation": "first_param_mod_name", + "-modulation": "-first_param_mod_name", + } + + if sort_param in valid_sort_fields: + objects = objects.order_by(valid_sort_fields[sort_param]) + + paginator = Paginator(objects, items_per_page) + page_obj = paginator.get_page(page_number) + + processed_objects = [] + for obj in page_obj: + param = getattr(obj, 'parameter_obj', None) + + geo_coords = "-" + geo_timestamp = "-" + geo_location = "-" + kupsat_coords = "-" + valid_coords = "-" + distance_geo_kup = "-" + distance_geo_valid = "-" + distance_kup_valid = "-" + + if hasattr(obj, "geo_obj") and obj.geo_obj: + geo_timestamp = obj.geo_obj.timestamp + geo_location = obj.geo_obj.location + + if obj.geo_obj.coords: + longitude = obj.geo_obj.coords.coords[0] + latitude = obj.geo_obj.coords.coords[1] + lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" + lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" + geo_coords = f"{lat} {lon}" + + if obj.geo_obj.coords_kupsat: + longitude = obj.geo_obj.coords_kupsat.coords[0] + latitude = obj.geo_obj.coords_kupsat.coords[1] + lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" + lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" + kupsat_coords = f"{lat} {lon}" + elif obj.geo_obj.coords_kupsat is not None: + kupsat_coords = "-" + + if obj.geo_obj.coords_valid: + longitude = obj.geo_obj.coords_valid.coords[0] + latitude = obj.geo_obj.coords_valid.coords[1] + lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" + lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" + valid_coords = f"{lat} {lon}" + elif obj.geo_obj.coords_valid is not None: + valid_coords = "-" + + if obj.geo_obj.distance_coords_kup is not None: + distance_geo_kup = f"{obj.geo_obj.distance_coords_kup:.3f}" + + if obj.geo_obj.distance_coords_valid is not None: + distance_geo_valid = f"{obj.geo_obj.distance_coords_valid:.3f}" + + if obj.geo_obj.distance_kup_valid is not None: + distance_kup_valid = f"{obj.geo_obj.distance_kup_valid:.3f}" + + satellite_name = "-" + frequency = "-" + freq_range = "-" + polarization_name = "-" + bod_velocity = "-" + modulation_name = "-" + snr = "-" + standard_name = "-" + comment = "-" + is_average = "-" + + if param: + if hasattr(param, "id_satellite") and param.id_satellite: + satellite_name = ( + param.id_satellite.name + if hasattr(param.id_satellite, "name") + else "-" + ) + + frequency = ( + f"{param.frequency:.3f}" if param.frequency is not None else "-" + ) + freq_range = ( + f"{param.freq_range:.3f}" if param.freq_range is not None else "-" + ) + bod_velocity = ( + f"{param.bod_velocity:.0f}" + if param.bod_velocity is not None + else "-" + ) + snr = f"{param.snr:.0f}" if param.snr is not None else "-" + + if hasattr(param, "polarization") and param.polarization: + polarization_name = ( + param.polarization.name + if hasattr(param.polarization, "name") + else "-" + ) + + if hasattr(param, "modulation") and param.modulation: + modulation_name = ( + param.modulation.name + if hasattr(param.modulation, "name") + else "-" + ) + + if hasattr(param, "standard") and param.standard: + standard_name = ( + param.standard.name + if hasattr(param.standard, "name") + else "-" + ) + + if hasattr(obj, "geo_obj") and obj.geo_obj: + comment = obj.geo_obj.comment or "-" + is_average = "Да" if obj.geo_obj.is_average else "Нет" if obj.geo_obj.is_average is not None else "-" + + processed_objects.append( + { + "id": obj.id, + "name": obj.name or "-", + "satellite_name": satellite_name, + "frequency": frequency, + "freq_range": freq_range, + "polarization": polarization_name, + "bod_velocity": bod_velocity, + "modulation": modulation_name, + "snr": snr, + "geo_timestamp": geo_timestamp, + "geo_location": geo_location, + "geo_coords": geo_coords, + "kupsat_coords": kupsat_coords, + "valid_coords": valid_coords, + "distance_geo_kup": distance_geo_kup, + "distance_geo_valid": distance_geo_valid, + "distance_kup_valid": distance_kup_valid, + "updated_by": obj.updated_by if obj.updated_by else "-", + "comment": comment, + "is_average": is_average, + "standard": standard_name, + "obj": obj, + } + ) + + modulations = Modulation.objects.all() + polarizations = Polarization.objects.all() + + context = { + "satellites": satellites, + "selected_satellite_id": selected_sat_id, + "page_obj": page_obj, + "processed_objects": processed_objects, + "items_per_page": items_per_page, + "available_items_per_page": [50, 100, 500, 1000], + "freq_min": freq_min, + "freq_max": freq_max, + "range_min": range_min, + "range_max": range_max, + "snr_min": snr_min, + "snr_max": snr_max, + "bod_min": bod_min, + "bod_max": bod_max, + "search_query": search_query, + "selected_modulations": [ + int(x) for x in selected_modulations if x.isdigit() + ], + "selected_polarizations": [ + int(x) for x in selected_polarizations if x.isdigit() + ], + "selected_satellites": [int(x) for x in selected_satellites if x.isdigit()], + "has_kupsat": has_kupsat, + "has_valid": has_valid, + "date_from": date_from, + "date_to": date_to, + "modulations": modulations, + "polarizations": polarizations, + "full_width_page": True, + "sort": sort_param, + } + + return render(request, "mainapp/objitem_list.html", context) + + +class ObjItemFormView( + RoleRequiredMixin, CoordinateProcessingMixin, FormMessageMixin, UpdateView +): + """ + Базовый класс для создания и редактирования ObjItem. + + Содержит общую логику обработки форм, координат и параметров. + """ + + model = ObjItem + form_class = ObjItemForm + template_name = "mainapp/objitem_form.html" + success_url = reverse_lazy("mainapp:home") + required_roles = ["admin", "moderator"] + + def get_success_url(self): + """Возвращает URL с сохраненными параметрами фильтров.""" + # Получаем все параметры из GET запроса и сохраняем их в URL + if self.request.GET: + from urllib.parse import urlencode + query_string = urlencode(self.request.GET) + return reverse_lazy("mainapp:objitem_list") + '?' + query_string + return reverse_lazy("mainapp:objitem_list") + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["LEAFLET_CONFIG"] = { + "DEFAULT_CENTER": (55.75, 37.62), + "DEFAULT_ZOOM": 5, + } + + # Сохраняем параметры возврата для кнопки "Назад" + context["return_params"] = self.request.GET.get('return_params', '') + + # Работаем с одной формой параметра вместо formset + if self.object and hasattr(self.object, "parameter_obj") and self.object.parameter_obj: + context["parameter_form"] = ParameterForm( + instance=self.object.parameter_obj, prefix="parameter" + ) + else: + context["parameter_form"] = ParameterForm(prefix="parameter") + + if self.object and hasattr(self.object, "geo_obj") and self.object.geo_obj: + context["geo_form"] = GeoForm( + instance=self.object.geo_obj, prefix="geo" + ) + else: + context["geo_form"] = GeoForm(prefix="geo") + + return context + + def form_valid(self, form): + # Получаем форму параметра + if self.object and hasattr(self.object, "parameter_obj") and self.object.parameter_obj: + parameter_form = ParameterForm( + self.request.POST, + instance=self.object.parameter_obj, + prefix="parameter" + ) + else: + parameter_form = ParameterForm(self.request.POST, prefix="parameter") + + if self.object and hasattr(self.object, "geo_obj") and self.object.geo_obj: + geo_form = GeoForm(self.request.POST, instance=self.object.geo_obj, prefix="geo") + else: + geo_form = GeoForm(self.request.POST, prefix="geo") + + # Сохраняем основной объект + self.object = form.save(commit=False) + self.set_user_fields() + self.object.save() + + # Сохраняем связанный параметр + if parameter_form.is_valid(): + self.save_parameter(parameter_form) + else: + context = self.get_context_data() + context.update({ + 'form': form, + 'parameter_form': parameter_form, + 'geo_form': geo_form, + }) + return self.render_to_response(context) + + # Сохраняем геоданные + if geo_form.is_valid(): + self.save_geo_data(geo_form) + else: + context = self.get_context_data() + context.update({ + 'form': form, + 'parameter_form': parameter_form, + 'geo_form': geo_form, + }) + return self.render_to_response(context) + + return super().form_valid(form) + + def set_user_fields(self): + """Устанавливает поля пользователя для объекта.""" + raise NotImplementedError("Subclasses must implement set_user_fields()") + + def save_parameter(self, parameter_form): + """Сохраняет параметр объекта через OneToOne связь.""" + if parameter_form.is_valid(): + instance = parameter_form.save(commit=False) + instance.objitem = self.object + instance.save() + + def save_geo_data(self, geo_form): + """Сохраняет геоданные объекта.""" + geo_instance = self.get_or_create_geo_instance() + + # Обновляем поля из geo_form + if geo_form.is_valid(): + geo_instance.location = geo_form.cleaned_data["location"] + geo_instance.comment = geo_form.cleaned_data["comment"] + geo_instance.is_average = geo_form.cleaned_data["is_average"] + + # Обрабатываем координаты + self.process_coordinates(geo_instance) + + # Обрабатываем дату/время + self.process_timestamp(geo_instance) + + geo_instance.save() + + def get_or_create_geo_instance(self): + """Получает или создает экземпляр Geo.""" + if hasattr(self.object, "geo_obj") and self.object.geo_obj: + return self.object.geo_obj + return Geo(objitem=self.object) + + +class ObjItemUpdateView(ObjItemFormView): + """Представление для редактирования ObjItem.""" + + success_message = "Объект успешно сохранён!" + + def set_user_fields(self): + self.object.updated_by = self.request.user.customuser + + +class ObjItemCreateView(ObjItemFormView, CreateView): + """Представление для создания ObjItem.""" + + success_message = "Объект успешно создан!" + + def set_user_fields(self): + self.object.created_by = self.request.user.customuser + self.object.updated_by = self.request.user.customuser + + +class ObjItemDeleteView(RoleRequiredMixin, FormMessageMixin, DeleteView): + model = ObjItem + template_name = "mainapp/objitem_confirm_delete.html" + success_url = reverse_lazy("mainapp:objitem_list") + success_message = "Объект успешно удалён!" + required_roles = ["admin", "moderator"] + + def get_success_url(self): + """Возвращает URL с сохраненными параметрами фильтров.""" + # Получаем все параметры из GET запроса и сохраняем их в URL + if self.request.GET: + from urllib.parse import urlencode + query_string = urlencode(self.request.GET) + return reverse_lazy("mainapp:objitem_list") + '?' + query_string + return reverse_lazy("mainapp:objitem_list") + + +class ObjItemDetailView(LoginRequiredMixin, View): + """ + Представление для просмотра деталей ObjItem в режиме чтения. + + Доступно для всех авторизованных пользователей, показывает данные в режиме чтения. + """ + def get(self, request, pk): + obj = ObjItem.objects.filter(pk=pk).select_related( + 'geo_obj', + 'updated_by__user', + 'created_by__user', + 'parameter_obj', + 'parameter_obj__id_satellite', + 'parameter_obj__polarization', + 'parameter_obj__modulation', + 'parameter_obj__standard', + ).first() + + if not obj: + from django.http import Http404 + raise Http404("Объект не найден") + + # Сохраняем параметры возврата для кнопки "Назад" + return_params = request.GET.get('return_params', '') + + context = { + 'object': obj, + 'return_params': return_params + } + + return render(request, "mainapp/objitem_detail.html", context) + + +class FillLyngsatDataView(LoginRequiredMixin, FormMessageMixin, FormView): + """ + Представление для заполнения данных из Lyngsat. + + Позволяет выбрать спутники и регионы для парсинга данных с сайта Lyngsat. + Запускает асинхронную задачу Celery для обработки. + """ + template_name = "mainapp/fill_lyngsat_data.html" + form_class = FillLyngsatDataForm + success_url = reverse_lazy("mainapp:lyngsat_task_status") + error_message = "Форма заполнена некорректно" + + def form_valid(self, form): + satellites = form.cleaned_data["satellites"] + regions = form.cleaned_data["regions"] + + # Получаем названия спутников + target_sats = [sat.name for sat in satellites] + + try: + from lyngsatapp.tasks import fill_lyngsat_data_task + + # Запускаем асинхронную задачу + task = fill_lyngsat_data_task.delay(target_sats, regions) + + messages.success( + self.request, + f"Задача запущена! ID задачи: {task.id}. " + "Вы будете перенаправлены на страницу отслеживания прогресса." + ) + + # Перенаправляем на страницу статуса задачи + return redirect('mainapp:lyngsat_task_status', task_id=task.id) + + except Exception as e: + messages.error(self.request, f"Ошибка при запуске задачи: {str(e)}") + return redirect("mainapp:fill_lyngsat_data") + + +class LyngsatTaskStatusView(LoginRequiredMixin, View): + """ + Представление для отслеживания статуса задачи заполнения данных Lyngsat. + """ + template_name = "mainapp/lyngsat_task_status.html" + + def get(self, request, task_id=None): + context = { + 'task_id': task_id + } + return render(request, self.template_name, context) + + +class LyngsatTaskStatusAPIView(LoginRequiredMixin, View): + """ + API для получения статуса задачи Celery. + """ + def get(self, request, task_id): + from celery.result import AsyncResult + from django.core.cache import cache + + task = AsyncResult(task_id) + + response_data = { + 'task_id': task_id, + 'state': task.state, + 'result': None, + 'error': None + } + + if task.state == 'PENDING': + response_data['status'] = 'Задача в очереди...' + elif task.state == 'PROGRESS': + response_data['status'] = task.info.get('status', '') + response_data['current'] = task.info.get('current', 0) + response_data['total'] = task.info.get('total', 1) + response_data['percent'] = int((task.info.get('current', 0) / task.info.get('total', 1)) * 100) + elif task.state == 'SUCCESS': + # Получаем результат из кеша + result = cache.get(f'lyngsat_task_{task_id}') + if result: + response_data['result'] = result + response_data['status'] = 'Задача завершена успешно' + else: + response_data['result'] = task.result + response_data['status'] = 'Задача завершена' + elif task.state == 'FAILURE': + response_data['status'] = 'Ошибка при выполнении задачи' + response_data['error'] = str(task.info) + else: + response_data['status'] = task.state + + return JsonResponse(response_data) diff --git a/dbapp/manage.py b/dbapp/manage.py index 1d33d3e..21e03b5 100644 --- a/dbapp/manage.py +++ b/dbapp/manage.py @@ -1,22 +1,22 @@ -#!/usr/bin/env python -"""Django's command-line utility for administrative tasks.""" -import os -import sys - - -def main(): - """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dbapp.settings') - try: - from django.core.management import execute_from_command_line - except ImportError as exc: - raise ImportError( - "Couldn't import Django. Are you sure it's installed and " - "available on your PYTHONPATH environment variable? Did you " - "forget to activate a virtual environment?" - ) from exc - execute_from_command_line(sys.argv) - - -if __name__ == '__main__': - main() +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dbapp.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/dbapp/mapsapp/admin.py b/dbapp/mapsapp/admin.py index 303e91d..c7ec1e5 100644 --- a/dbapp/mapsapp/admin.py +++ b/dbapp/mapsapp/admin.py @@ -1,67 +1,67 @@ -# Django imports -from django.contrib import admin - -# Third-party imports -from import_export.admin import ImportExportActionModelAdmin -from more_admin_filters import MultiSelectRelatedDropdownFilter -from rangefilter.filters import NumericRangeFilterBuilder - -# Local imports -from .models import Transponders - - -# ============================================================================ -# Base Admin Classes -# ============================================================================ - -class BaseAdmin(admin.ModelAdmin): - """ - Базовый класс для всех admin моделей mapsapp. - - Предоставляет общую функциональность: - - Кнопки сохранения сверху и снизу - - Настройка количества элементов на странице - """ - save_on_top = True - list_per_page = 50 - - -# ============================================================================ -# Admin Classes -# ============================================================================ - -@admin.register(Transponders) -class TranspondersAdmin(ImportExportActionModelAdmin, BaseAdmin): - """ - Админ-панель для модели Transponders. - - Оптимизирована для работы с транспондерами: - - Использует select_related для оптимизации запросов - - Предоставляет фильтры по спутникам, поляризации и зоне - - Поддерживает импорт/экспорт данных - """ - list_display = ( - "sat_id", - "name", - "zone_name", - "downlink", - "uplink", - "frequency_range", - "transfer", - "polarization", - ) - list_display_links = ("name",) - list_select_related = ("polarization", "sat_id") - - list_filter = ( - ("polarization", MultiSelectRelatedDropdownFilter), - ("sat_id", MultiSelectRelatedDropdownFilter), - ("downlink", NumericRangeFilterBuilder()), - ("uplink", NumericRangeFilterBuilder()), - ("frequency_range", NumericRangeFilterBuilder()), - "zone_name", - ) - - search_fields = ("name", "sat_id__name", "zone_name") - ordering = ("name",) - autocomplete_fields = ("sat_id", "polarization") +# Django imports +from django.contrib import admin + +# Third-party imports +from import_export.admin import ImportExportActionModelAdmin +from more_admin_filters import MultiSelectRelatedDropdownFilter +from rangefilter.filters import NumericRangeFilterBuilder + +# Local imports +from .models import Transponders + + +# ============================================================================ +# Base Admin Classes +# ============================================================================ + +class BaseAdmin(admin.ModelAdmin): + """ + Базовый класс для всех admin моделей mapsapp. + + Предоставляет общую функциональность: + - Кнопки сохранения сверху и снизу + - Настройка количества элементов на странице + """ + save_on_top = True + list_per_page = 50 + + +# ============================================================================ +# Admin Classes +# ============================================================================ + +@admin.register(Transponders) +class TranspondersAdmin(ImportExportActionModelAdmin, BaseAdmin): + """ + Админ-панель для модели Transponders. + + Оптимизирована для работы с транспондерами: + - Использует select_related для оптимизации запросов + - Предоставляет фильтры по спутникам, поляризации и зоне + - Поддерживает импорт/экспорт данных + """ + list_display = ( + "sat_id", + "name", + "zone_name", + "downlink", + "uplink", + "frequency_range", + "transfer", + "polarization", + ) + list_display_links = ("name",) + list_select_related = ("polarization", "sat_id") + + list_filter = ( + ("polarization", MultiSelectRelatedDropdownFilter), + ("sat_id", MultiSelectRelatedDropdownFilter), + ("downlink", NumericRangeFilterBuilder()), + ("uplink", NumericRangeFilterBuilder()), + ("frequency_range", NumericRangeFilterBuilder()), + "zone_name", + ) + + search_fields = ("name", "sat_id__name", "zone_name") + ordering = ("name",) + autocomplete_fields = ("sat_id", "polarization") diff --git a/dbapp/mapsapp/apps.py b/dbapp/mapsapp/apps.py index c4e242b..57d74a7 100644 --- a/dbapp/mapsapp/apps.py +++ b/dbapp/mapsapp/apps.py @@ -1,6 +1,6 @@ -from django.apps import AppConfig - - -class MapsappConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'mapsapp' +from django.apps import AppConfig + + +class MapsappConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'mapsapp' diff --git a/dbapp/mapsapp/migrations/0001_initial.py b/dbapp/mapsapp/migrations/0001_initial.py index e32dd58..452d8d5 100644 --- a/dbapp/mapsapp/migrations/0001_initial.py +++ b/dbapp/mapsapp/migrations/0001_initial.py @@ -1,37 +1,37 @@ -# Generated by Django 5.2.7 on 2025-10-31 13:36 - -import django.db.models.deletion -import django.db.models.expressions -import django.db.models.functions.math -import mainapp.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('mainapp', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='Transponders', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(blank=True, max_length=30, null=True, verbose_name='Название транспондера')), - ('downlink', models.FloatField(blank=True, null=True, verbose_name='Downlink')), - ('frequency_range', models.FloatField(blank=True, null=True, verbose_name='Полоса')), - ('uplink', models.FloatField(blank=True, null=True, verbose_name='Uplink')), - ('zone_name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Название зоны')), - ('transfer', models.GeneratedField(db_persist=True, expression=models.ExpressionWrapper(django.db.models.functions.math.Abs(django.db.models.expressions.CombinedExpression(models.F('downlink'), '-', models.F('uplink'))), output_field=models.FloatField()), null=True, output_field=models.FloatField(), verbose_name='Перенос')), - ('polarization', models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='tran_polarizations', to='mainapp.polarization', verbose_name='Поляризация')), - ('sat_id', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='tran_satellite', to='mainapp.satellite', verbose_name='Спутник')), - ], - options={ - 'verbose_name': 'Транспондер', - 'verbose_name_plural': 'Транспондеры', - }, - ), - ] +# Generated by Django 5.2.7 on 2025-10-31 13:36 + +import django.db.models.deletion +import django.db.models.expressions +import django.db.models.functions.math +import mainapp.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('mainapp', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Transponders', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(blank=True, max_length=30, null=True, verbose_name='Название транспондера')), + ('downlink', models.FloatField(blank=True, null=True, verbose_name='Downlink')), + ('frequency_range', models.FloatField(blank=True, null=True, verbose_name='Полоса')), + ('uplink', models.FloatField(blank=True, null=True, verbose_name='Uplink')), + ('zone_name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Название зоны')), + ('transfer', models.GeneratedField(db_persist=True, expression=models.ExpressionWrapper(django.db.models.functions.math.Abs(django.db.models.expressions.CombinedExpression(models.F('downlink'), '-', models.F('uplink'))), output_field=models.FloatField()), null=True, output_field=models.FloatField(), verbose_name='Перенос')), + ('polarization', models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='tran_polarizations', to='mainapp.polarization', verbose_name='Поляризация')), + ('sat_id', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='tran_satellite', to='mainapp.satellite', verbose_name='Спутник')), + ], + options={ + 'verbose_name': 'Транспондер', + 'verbose_name_plural': 'Транспондеры', + }, + ), + ] diff --git a/dbapp/mapsapp/migrations/0002_alter_transponders_options_and_more.py b/dbapp/mapsapp/migrations/0002_alter_transponders_options_and_more.py index 1a9a0b8..70a32d8 100644 --- a/dbapp/mapsapp/migrations/0002_alter_transponders_options_and_more.py +++ b/dbapp/mapsapp/migrations/0002_alter_transponders_options_and_more.py @@ -1,64 +1,64 @@ -# Generated by Django 5.2.7 on 2025-11-07 20:58 - -import django.core.validators -import django.db.models.deletion -import mainapp.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('mainapp', '0006_alter_customuser_options_alter_geo_options_and_more'), - ('mapsapp', '0001_initial'), - ] - - operations = [ - migrations.AlterModelOptions( - name='transponders', - options={'ordering': ['sat_id', 'downlink'], 'verbose_name': 'Транспондер', 'verbose_name_plural': 'Транспондеры'}, - ), - migrations.AlterField( - model_name='transponders', - name='downlink', - field=models.FloatField(blank=True, help_text='Частота downlink в МГц (0-50000)', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(50000)], verbose_name='Downlink'), - ), - migrations.AlterField( - model_name='transponders', - name='frequency_range', - field=models.FloatField(blank=True, help_text='Полоса частот в МГц (0-1000)', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1000)], verbose_name='Полоса'), - ), - migrations.AlterField( - model_name='transponders', - name='name', - field=models.CharField(blank=True, db_index=True, help_text='Название транспондера', max_length=30, null=True, verbose_name='Название транспондера'), - ), - migrations.AlterField( - model_name='transponders', - name='polarization', - field=models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, help_text='Поляризация сигнала', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='tran_polarizations', to='mainapp.polarization', verbose_name='Поляризация'), - ), - migrations.AlterField( - model_name='transponders', - name='sat_id', - field=models.ForeignKey(help_text='Спутник, которому принадлежит транспондер', on_delete=django.db.models.deletion.PROTECT, related_name='tran_satellite', to='mainapp.satellite', verbose_name='Спутник'), - ), - migrations.AlterField( - model_name='transponders', - name='uplink', - field=models.FloatField(blank=True, help_text='Частота uplink в МГц (0-50000)', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(50000)], verbose_name='Uplink'), - ), - migrations.AlterField( - model_name='transponders', - name='zone_name', - field=models.CharField(blank=True, db_index=True, help_text='Название зоны покрытия транспондера', max_length=255, null=True, verbose_name='Название зоны'), - ), - migrations.AddIndex( - model_name='transponders', - index=models.Index(fields=['sat_id', 'downlink'], name='mapsapp_tra_sat_id__3e3fd7_idx'), - ), - migrations.AddIndex( - model_name='transponders', - index=models.Index(fields=['sat_id', 'zone_name'], name='mapsapp_tra_sat_id__305ae7_idx'), - ), - ] +# Generated by Django 5.2.7 on 2025-11-07 20:58 + +import django.core.validators +import django.db.models.deletion +import mainapp.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mainapp', '0006_alter_customuser_options_alter_geo_options_and_more'), + ('mapsapp', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='transponders', + options={'ordering': ['sat_id', 'downlink'], 'verbose_name': 'Транспондер', 'verbose_name_plural': 'Транспондеры'}, + ), + migrations.AlterField( + model_name='transponders', + name='downlink', + field=models.FloatField(blank=True, help_text='Частота downlink в МГц (0-50000)', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(50000)], verbose_name='Downlink'), + ), + migrations.AlterField( + model_name='transponders', + name='frequency_range', + field=models.FloatField(blank=True, help_text='Полоса частот в МГц (0-1000)', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1000)], verbose_name='Полоса'), + ), + migrations.AlterField( + model_name='transponders', + name='name', + field=models.CharField(blank=True, db_index=True, help_text='Название транспондера', max_length=30, null=True, verbose_name='Название транспондера'), + ), + migrations.AlterField( + model_name='transponders', + name='polarization', + field=models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, help_text='Поляризация сигнала', null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='tran_polarizations', to='mainapp.polarization', verbose_name='Поляризация'), + ), + migrations.AlterField( + model_name='transponders', + name='sat_id', + field=models.ForeignKey(help_text='Спутник, которому принадлежит транспондер', on_delete=django.db.models.deletion.PROTECT, related_name='tran_satellite', to='mainapp.satellite', verbose_name='Спутник'), + ), + migrations.AlterField( + model_name='transponders', + name='uplink', + field=models.FloatField(blank=True, help_text='Частота uplink в МГц (0-50000)', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(50000)], verbose_name='Uplink'), + ), + migrations.AlterField( + model_name='transponders', + name='zone_name', + field=models.CharField(blank=True, db_index=True, help_text='Название зоны покрытия транспондера', max_length=255, null=True, verbose_name='Название зоны'), + ), + migrations.AddIndex( + model_name='transponders', + index=models.Index(fields=['sat_id', 'downlink'], name='mapsapp_tra_sat_id__3e3fd7_idx'), + ), + migrations.AddIndex( + model_name='transponders', + index=models.Index(fields=['sat_id', 'zone_name'], name='mapsapp_tra_sat_id__305ae7_idx'), + ), + ] diff --git a/dbapp/mapsapp/models.py b/dbapp/mapsapp/models.py index 5baab11..132ac01 100644 --- a/dbapp/mapsapp/models.py +++ b/dbapp/mapsapp/models.py @@ -1,117 +1,117 @@ -# Django imports -from django.core.exceptions import ValidationError -from django.core.validators import MaxValueValidator, MinValueValidator -from django.db import models -from django.db.models import ExpressionWrapper, F -from django.db.models.functions import Abs - -# Local imports -from mainapp.models import Polarization, Satellite, get_default_polarization - - -class Transponders(models.Model): - """ - Модель транспондера спутника. - - Хранит информацию о частотах uplink/downlink, зоне покрытия и поляризации. - """ - - # Основные поля - name = models.CharField( - max_length=30, - null=True, - blank=True, - verbose_name="Название транспондера", - db_index=True, - help_text="Название транспондера" - ) - downlink = models.FloatField( - blank=True, - null=True, - verbose_name="Downlink", - validators=[MinValueValidator(0), MaxValueValidator(50000)], - help_text="Частота downlink в МГц (0-50000)" - ) - frequency_range = models.FloatField( - blank=True, - null=True, - verbose_name="Полоса", - validators=[MinValueValidator(0), MaxValueValidator(1000)], - help_text="Полоса частот в МГц (0-1000)" - ) - uplink = models.FloatField( - blank=True, - null=True, - verbose_name="Uplink", - validators=[MinValueValidator(0), MaxValueValidator(50000)], - help_text="Частота uplink в МГц (0-50000)" - ) - zone_name = models.CharField( - max_length=255, - blank=True, - null=True, - verbose_name="Название зоны", - db_index=True, - help_text="Название зоны покрытия транспондера" - ) - - # Связи - polarization = models.ForeignKey( - Polarization, - default=get_default_polarization, - on_delete=models.SET_DEFAULT, - related_name="tran_polarizations", - null=True, - blank=True, - verbose_name="Поляризация", - help_text="Поляризация сигнала" - ) - sat_id = models.ForeignKey( - Satellite, - on_delete=models.PROTECT, - related_name="tran_satellite", - verbose_name="Спутник", - db_index=True, - help_text="Спутник, которому принадлежит транспондер" - ) - - # Вычисляемые поля - transfer = models.GeneratedField( - expression=ExpressionWrapper( - Abs(F('downlink') - F('uplink')), - output_field=models.FloatField() - ), - output_field=models.FloatField(), - db_persist=True, - null=True, - blank=True, - verbose_name="Перенос" - ) - - def clean(self): - """Валидация на уровне модели""" - super().clean() - - # Проверка что downlink и uplink заданы - if self.downlink and self.uplink: - # Обычно uplink выше downlink для спутниковой связи - if self.uplink < self.downlink: - raise ValidationError({ - 'uplink': 'Частота uplink обычно выше частоты downlink' - }) - - def __str__(self): - if self.name: - return self.name - return f"Транспондер {self.sat_id.name if self.sat_id else 'Unknown'}" - - class Meta: - verbose_name = "Транспондер" - verbose_name_plural = "Транспондеры" - ordering = ['sat_id', 'downlink'] - indexes = [ - models.Index(fields=['sat_id', 'downlink']), - models.Index(fields=['sat_id', 'zone_name']), - ] - - +# Django imports +from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.db.models import ExpressionWrapper, F +from django.db.models.functions import Abs + +# Local imports +from mainapp.models import Polarization, Satellite, get_default_polarization + + +class Transponders(models.Model): + """ + Модель транспондера спутника. + + Хранит информацию о частотах uplink/downlink, зоне покрытия и поляризации. + """ + + # Основные поля + name = models.CharField( + max_length=30, + null=True, + blank=True, + verbose_name="Название транспондера", + db_index=True, + help_text="Название транспондера" + ) + downlink = models.FloatField( + blank=True, + null=True, + verbose_name="Downlink", + validators=[MinValueValidator(0), MaxValueValidator(50000)], + help_text="Частота downlink в МГц (0-50000)" + ) + frequency_range = models.FloatField( + blank=True, + null=True, + verbose_name="Полоса", + validators=[MinValueValidator(0), MaxValueValidator(1000)], + help_text="Полоса частот в МГц (0-1000)" + ) + uplink = models.FloatField( + blank=True, + null=True, + verbose_name="Uplink", + validators=[MinValueValidator(0), MaxValueValidator(50000)], + help_text="Частота uplink в МГц (0-50000)" + ) + zone_name = models.CharField( + max_length=255, + blank=True, + null=True, + verbose_name="Название зоны", + db_index=True, + help_text="Название зоны покрытия транспондера" + ) + + # Связи + polarization = models.ForeignKey( + Polarization, + default=get_default_polarization, + on_delete=models.SET_DEFAULT, + related_name="tran_polarizations", + null=True, + blank=True, + verbose_name="Поляризация", + help_text="Поляризация сигнала" + ) + sat_id = models.ForeignKey( + Satellite, + on_delete=models.PROTECT, + related_name="tran_satellite", + verbose_name="Спутник", + db_index=True, + help_text="Спутник, которому принадлежит транспондер" + ) + + # Вычисляемые поля + transfer = models.GeneratedField( + expression=ExpressionWrapper( + Abs(F('downlink') - F('uplink')), + output_field=models.FloatField() + ), + output_field=models.FloatField(), + db_persist=True, + null=True, + blank=True, + verbose_name="Перенос" + ) + + def clean(self): + """Валидация на уровне модели""" + super().clean() + + # Проверка что downlink и uplink заданы + if self.downlink and self.uplink: + # Обычно uplink выше downlink для спутниковой связи + if self.uplink < self.downlink: + raise ValidationError({ + 'uplink': 'Частота uplink обычно выше частоты downlink' + }) + + def __str__(self): + if self.name: + return self.name + return f"Транспондер {self.sat_id.name if self.sat_id else 'Unknown'}" + + class Meta: + verbose_name = "Транспондер" + verbose_name_plural = "Транспондеры" + ordering = ['sat_id', 'downlink'] + indexes = [ + models.Index(fields=['sat_id', 'downlink']), + models.Index(fields=['sat_id', 'zone_name']), + ] + + diff --git a/dbapp/mapsapp/templates/mapsapp/map2d.html b/dbapp/mapsapp/templates/mapsapp/map2d.html index 6f0451d..76054de 100644 --- a/dbapp/mapsapp/templates/mapsapp/map2d.html +++ b/dbapp/mapsapp/templates/mapsapp/map2d.html @@ -1,561 +1,561 @@ -{% extends "mapsapp/map2d_base.html" %} -{% load static %} -{% block content %} - -
      -
      Объекты из базы
      - - - - - - -
      - -
      -
      Области покрытия
      -
      - - -
      -
      -
      -{% endblock content %} - -{% block extra_js %} - +{% extends "mapsapp/map2d_base.html" %} +{% load static %} +{% block content %} + +
      +
      Объекты из базы
      + + + + + + +
      + +
      +
      Области покрытия
      +
      + + +
      +
      +
      +{% endblock content %} + +{% block extra_js %} + {% endblock extra_js %} \ No newline at end of file diff --git a/dbapp/mapsapp/templates/mapsapp/map2d_base.html b/dbapp/mapsapp/templates/mapsapp/map2d_base.html index 933379c..df9a613 100644 --- a/dbapp/mapsapp/templates/mapsapp/map2d_base.html +++ b/dbapp/mapsapp/templates/mapsapp/map2d_base.html @@ -1,83 +1,83 @@ -{% load static %} - - - - - - {% block title %}Карта{% endblock %} - - - - - - - - - {% block extra_css %}{% endblock %} - - - - - -
      - {% block content %} - {% endblock %} - - - - - - - - {% block extra_js %}{% endblock %} - +{% load static %} + + + + + + {% block title %}Карта{% endblock %} + + + + + + + + + {% block extra_css %}{% endblock %} + + + + + +
      + {% block content %} + {% endblock %} + + + + + + + + {% block extra_js %}{% endblock %} + \ No newline at end of file diff --git a/dbapp/mapsapp/templates/mapsapp/map3d.html b/dbapp/mapsapp/templates/mapsapp/map3d.html index 24f412f..a7a9aff 100644 --- a/dbapp/mapsapp/templates/mapsapp/map3d.html +++ b/dbapp/mapsapp/templates/mapsapp/map3d.html @@ -1,118 +1,118 @@ -{% load static %} - - - - - - - Cesium Map Editor - - - - - - - - -
      - - -
      - -
      -
      Рисование
      -
      - - - - -
      -
      - -
      -
      Импорт/экспорт всех объектов
      -
      - - -
      -
      - -
      -
      Действия
      -
      - - -
      -
      - -
      - Режим: Выделение - - Нажмите ESC для отмены -
      -
      - - -
      -
      Объекты из базы
      - - -
      - -
      -
      Области покрытия
      -
      - - -
      -
      -
      - - - - - - - - +{% load static %} + + + + + + + Cesium Map Editor + + + + + + + + +
      + + +
      + +
      +
      Рисование
      +
      + + + + +
      +
      + +
      +
      Импорт/экспорт всех объектов
      +
      + + +
      +
      + +
      +
      Действия
      +
      + + +
      +
      + +
      + Режим: Выделение + + Нажмите ESC для отмены +
      +
      + + +
      +
      Объекты из базы
      + + +
      + +
      +
      Области покрытия
      +
      + + +
      +
      +
      + + + + + + + + \ No newline at end of file diff --git a/dbapp/mapsapp/tests.py b/dbapp/mapsapp/tests.py index 7ce503c..de8bdc0 100644 --- a/dbapp/mapsapp/tests.py +++ b/dbapp/mapsapp/tests.py @@ -1,3 +1,3 @@ -from django.test import TestCase - -# Create your tests here. +from django.test import TestCase + +# Create your tests here. diff --git a/dbapp/mapsapp/urls.py b/dbapp/mapsapp/urls.py index 376014d..c35cb6f 100644 --- a/dbapp/mapsapp/urls.py +++ b/dbapp/mapsapp/urls.py @@ -1,14 +1,14 @@ -from django.conf import settings -from django.conf.urls.static import static -from django.urls import path -from . import views - -app_name = 'mapsapp' - -urlpatterns = [ - path('3dmap', views.CesiumMapView.as_view(), name='3dmap'), - path('2dmap', views.LeafletMapView.as_view(), name='2dmap'), - path('api/footprint-names/', views.GetFootprintsView.as_view(), name="footprint_names"), - path('api/transponders/', views.GetTransponderOnSatIdView.as_view(), name='transponders_data'), - path('tiles////.png', views.TileProxyView.as_view(), name='tile_proxy'), +from django.conf import settings +from django.conf.urls.static import static +from django.urls import path +from . import views + +app_name = 'mapsapp' + +urlpatterns = [ + path('3dmap', views.CesiumMapView.as_view(), name='3dmap'), + path('2dmap', views.LeafletMapView.as_view(), name='2dmap'), + path('api/footprint-names/', views.GetFootprintsView.as_view(), name="footprint_names"), + path('api/transponders/', views.GetTransponderOnSatIdView.as_view(), name='transponders_data'), + path('tiles////.png', views.TileProxyView.as_view(), name='tile_proxy'), ] \ No newline at end of file diff --git a/dbapp/mapsapp/utils.py b/dbapp/mapsapp/utils.py index aa91615..5d2e165 100644 --- a/dbapp/mapsapp/utils.py +++ b/dbapp/mapsapp/utils.py @@ -1,165 +1,165 @@ -# Standard library imports -import json -import re -from io import BytesIO - -# Third-party imports -import requests - -# Local imports -from mainapp.models import Polarization, Satellite - -from .models import Transponders - -def search_satellite_on_page(data: dict, satellite_name: str): - for pos, value in data.get('page', {}).get('positions').items(): - for name in value['satellites']: - if name['other_names'] is None: - name['other_names'] = '' - if satellite_name.lower() in name['name'].lower() or satellite_name.lower() in name['other_names'].lower(): - return pos, name['id'] - return '', '' - -def get_footprint_data(position: str = 62) -> dict: - """Возвращает словарь с данным по footprint для спутников на выбранной долготе""" - response = requests.get(f"https://www.satbeams.com/footprints?position={position}") - response.raise_for_status() - match = re.search(r'var data = ({.*?});', response.text, re.DOTALL) - if match: - json_str = match.group(1) - try: - data = json.loads(json_str) - return data.get("page", {}).get("footprint_data", {}).get("beams",[]) - except json.JSONDecodeError as e: - print("Ошибка парсинга JSON:", e) - else: - print("Нужных данных не найдено") - return {} - - - -def get_all_page_data(url:str = 'https://www.satbeams.com/footprints') -> dict: - """Возвращает словарь с данными по всем спутникам на странице""" - response = requests.get(url) - response.raise_for_status() - match = re.search(r'var data = ({.*?});', response.text, re.DOTALL) - if match: - json_str = match.group(1) - try: - data = json.loads(json_str) - # Файл json на диске для достоверности - with open('data.json', 'w') as jf: - json.dump(data, jf, indent=2) - return data - except json.JSONDecodeError as e: - print("Ошибка парсинга JSON:", e) - else: - print("Нужных данных не найдено") - return {} - - -def get_names_footprints_for_satellite(footprint_data: dict, sat_id: str) -> list[str]: - names = [] - for beam in footprint_data: - if 'ku' in beam['band'].lower() and sat_id in beam['satellite_id']: - names.append( - { - "name": beam['name'], - "fullname": beam['fullname'][8:] - } - ) - return names - - -def get_band_names(satellite_name: str) -> list[str]: - data = get_all_page_data() - pos, sat_id = search_satellite_on_page(data, satellite_name) - footprints = get_footprint_data(pos) - names = get_names_footprints_for_satellite(footprints, sat_id) - return names - -def parse_transponders_from_json(filepath: str): - with open(filepath, encoding="utf-8") as jf: - data = json.load(jf) - for sat_name, trans_zone in data["satellites"].items(): - for zone, trans in trans_zone.items(): - for tran in trans: - f_b, f_e = tran["freq"][0].split("-") - f = round((float(f_b) + float(f_e))/2, 3) - f_range = round(abs(float(f_e) - float(f_b)), 3) - tran_obj = Transponders.objects.create( - name=tran["name"], - frequency=f, - frequency_range=f_range, - zone_name=zone, - polarization=Polarization.objects.get(name=tran["pol"]), - sat_id=Satellite.objects.get(name__iexact=sat_name) - ) - tran_obj.save() - - -# Third-party imports (additional) -from lxml import etree - -def parse_transponders_from_xml(data_in: BytesIO): - - tree = etree.parse(data_in) - ns = { - 'i': 'http://www.w3.org/2001/XMLSchema-instance', - 'ns': 'http://schemas.datacontract.org/2004/07/Geolocation.Domain.Utils.Repository.SatellitesSerialization.Memos', - 'tr': 'http://schemas.datacontract.org/2004/07/Geolocation.Common.Extensions' - } - satellites = tree.xpath('//ns:SatelliteMemo', namespaces=ns) - for sat in satellites[:]: - name = sat.xpath('./ns:name/text()', namespaces=ns)[0] - if name == 'X' or 'DONT USE' in name: - continue - norad = sat.xpath('./ns:norad/text()', namespaces=ns) - beams = sat.xpath('.//ns:BeamMemo', namespaces=ns) - zones = {} - for zone in beams: - zone_name = zone.xpath('./ns:name/text()', namespaces=ns)[0] if zone.xpath('./ns:name/text()', namespaces=ns) else '-' - zones[zone.xpath('./ns:id/text()', namespaces=ns)[0]] = { - "name": zone_name, - "pol": zone.xpath('./ns:polarization/text()', namespaces=ns)[0], - } - transponders = sat.xpath('.//ns:TransponderMemo', namespaces=ns) - for transponder in transponders: - tr_id = transponder.xpath('./ns:downlinkBeamId/text()', namespaces=ns)[0] - downlink_start = float(transponder.xpath('./ns:downlinkFrequency/tr:start/text()', namespaces=ns)[0]) - downlink_end = float(transponder.xpath('./ns:downlinkFrequency/tr:end/text()', namespaces=ns)[0]) - uplink_start = float(transponder.xpath('./ns:uplinkFrequency/tr:start/text()', namespaces=ns)[0]) - uplink_end = float(transponder.xpath('./ns:uplinkFrequency/tr:end/text()', namespaces=ns)[0]) - tr_data = zones[tr_id] - # p = tr_data['pol'][0] if tr_data['pol'] else '-' - match tr_data['pol']: - case 'Horizontal': - pol = 'Горизонтальная' - case 'Vertical': - pol = 'Вертикальная' - case 'CircularRight': - pol = 'Правая' - case 'CircularLeft': - pol = 'Левая' - case _: - pol = '-' - tr_name = transponder.xpath('./ns:name/text()', namespaces=ns)[0] - - pol_obj, _ = Polarization.objects.get_or_create(name=pol) - sat_obj, _ = Satellite.objects.get_or_create( - name=name, - defaults={ - "norad": int(norad[0]) if norad else -1 - }) - trans_obj, _ = Transponders.objects.get_or_create( - polarization=pol_obj, - downlink=(downlink_start+downlink_end)/2/1000000, - uplink=(uplink_start+uplink_end)/2/1000000, - frequency_range=abs(downlink_end-downlink_start)/1000000, - name=tr_name, - defaults={ - "zone_name": tr_data['name'], - "sat_id": sat_obj, - } - ) - trans_obj.save() +# Standard library imports +import json +import re +from io import BytesIO + +# Third-party imports +import requests + +# Local imports +from mainapp.models import Polarization, Satellite + +from .models import Transponders + +def search_satellite_on_page(data: dict, satellite_name: str): + for pos, value in data.get('page', {}).get('positions').items(): + for name in value['satellites']: + if name['other_names'] is None: + name['other_names'] = '' + if satellite_name.lower() in name['name'].lower() or satellite_name.lower() in name['other_names'].lower(): + return pos, name['id'] + return '', '' + +def get_footprint_data(position: str = 62) -> dict: + """Возвращает словарь с данным по footprint для спутников на выбранной долготе""" + response = requests.get(f"https://www.satbeams.com/footprints?position={position}") + response.raise_for_status() + match = re.search(r'var data = ({.*?});', response.text, re.DOTALL) + if match: + json_str = match.group(1) + try: + data = json.loads(json_str) + return data.get("page", {}).get("footprint_data", {}).get("beams",[]) + except json.JSONDecodeError as e: + print("Ошибка парсинга JSON:", e) + else: + print("Нужных данных не найдено") + return {} + + + +def get_all_page_data(url:str = 'https://www.satbeams.com/footprints') -> dict: + """Возвращает словарь с данными по всем спутникам на странице""" + response = requests.get(url) + response.raise_for_status() + match = re.search(r'var data = ({.*?});', response.text, re.DOTALL) + if match: + json_str = match.group(1) + try: + data = json.loads(json_str) + # Файл json на диске для достоверности + with open('data.json', 'w') as jf: + json.dump(data, jf, indent=2) + return data + except json.JSONDecodeError as e: + print("Ошибка парсинга JSON:", e) + else: + print("Нужных данных не найдено") + return {} + + +def get_names_footprints_for_satellite(footprint_data: dict, sat_id: str) -> list[str]: + names = [] + for beam in footprint_data: + if 'ku' in beam['band'].lower() and sat_id in beam['satellite_id']: + names.append( + { + "name": beam['name'], + "fullname": beam['fullname'][8:] + } + ) + return names + + +def get_band_names(satellite_name: str) -> list[str]: + data = get_all_page_data() + pos, sat_id = search_satellite_on_page(data, satellite_name) + footprints = get_footprint_data(pos) + names = get_names_footprints_for_satellite(footprints, sat_id) + return names + +def parse_transponders_from_json(filepath: str): + with open(filepath, encoding="utf-8") as jf: + data = json.load(jf) + for sat_name, trans_zone in data["satellites"].items(): + for zone, trans in trans_zone.items(): + for tran in trans: + f_b, f_e = tran["freq"][0].split("-") + f = round((float(f_b) + float(f_e))/2, 3) + f_range = round(abs(float(f_e) - float(f_b)), 3) + tran_obj = Transponders.objects.create( + name=tran["name"], + frequency=f, + frequency_range=f_range, + zone_name=zone, + polarization=Polarization.objects.get(name=tran["pol"]), + sat_id=Satellite.objects.get(name__iexact=sat_name) + ) + tran_obj.save() + + +# Third-party imports (additional) +from lxml import etree + +def parse_transponders_from_xml(data_in: BytesIO): + + tree = etree.parse(data_in) + ns = { + 'i': 'http://www.w3.org/2001/XMLSchema-instance', + 'ns': 'http://schemas.datacontract.org/2004/07/Geolocation.Domain.Utils.Repository.SatellitesSerialization.Memos', + 'tr': 'http://schemas.datacontract.org/2004/07/Geolocation.Common.Extensions' + } + satellites = tree.xpath('//ns:SatelliteMemo', namespaces=ns) + for sat in satellites[:]: + name = sat.xpath('./ns:name/text()', namespaces=ns)[0] + if name == 'X' or 'DONT USE' in name: + continue + norad = sat.xpath('./ns:norad/text()', namespaces=ns) + beams = sat.xpath('.//ns:BeamMemo', namespaces=ns) + zones = {} + for zone in beams: + zone_name = zone.xpath('./ns:name/text()', namespaces=ns)[0] if zone.xpath('./ns:name/text()', namespaces=ns) else '-' + zones[zone.xpath('./ns:id/text()', namespaces=ns)[0]] = { + "name": zone_name, + "pol": zone.xpath('./ns:polarization/text()', namespaces=ns)[0], + } + transponders = sat.xpath('.//ns:TransponderMemo', namespaces=ns) + for transponder in transponders: + tr_id = transponder.xpath('./ns:downlinkBeamId/text()', namespaces=ns)[0] + downlink_start = float(transponder.xpath('./ns:downlinkFrequency/tr:start/text()', namespaces=ns)[0]) + downlink_end = float(transponder.xpath('./ns:downlinkFrequency/tr:end/text()', namespaces=ns)[0]) + uplink_start = float(transponder.xpath('./ns:uplinkFrequency/tr:start/text()', namespaces=ns)[0]) + uplink_end = float(transponder.xpath('./ns:uplinkFrequency/tr:end/text()', namespaces=ns)[0]) + tr_data = zones[tr_id] + # p = tr_data['pol'][0] if tr_data['pol'] else '-' + match tr_data['pol']: + case 'Horizontal': + pol = 'Горизонтальная' + case 'Vertical': + pol = 'Вертикальная' + case 'CircularRight': + pol = 'Правая' + case 'CircularLeft': + pol = 'Левая' + case _: + pol = '-' + tr_name = transponder.xpath('./ns:name/text()', namespaces=ns)[0] + + pol_obj, _ = Polarization.objects.get_or_create(name=pol) + sat_obj, _ = Satellite.objects.get_or_create( + name=name, + defaults={ + "norad": int(norad[0]) if norad else -1 + }) + trans_obj, _ = Transponders.objects.get_or_create( + polarization=pol_obj, + downlink=(downlink_start+downlink_end)/2/1000000, + uplink=(uplink_start+uplink_end)/2/1000000, + frequency_range=abs(downlink_end-downlink_start)/1000000, + name=tr_name, + defaults={ + "zone_name": tr_data['name'], + "sat_id": sat_obj, + } + ) + trans_obj.save() diff --git a/dbapp/mapsapp/views.py b/dbapp/mapsapp/views.py index 5dff4a1..3ba973d 100644 --- a/dbapp/mapsapp/views.py +++ b/dbapp/mapsapp/views.py @@ -1,148 +1,148 @@ -# Standard library imports -from typing import Any, Dict - -# Django imports -from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import HttpResponse, HttpResponseNotFound, JsonResponse -from django.utils.decorators import method_decorator -from django.views import View -from django.views.decorators.cache import cache_page -from django.views.decorators.http import require_GET -from django.views.generic import TemplateView - -# Third-party imports -import requests - -# Local imports -from mainapp.models import Satellite -from .models import Transponders -from .utils import get_band_names - - -class CesiumMapView(LoginRequiredMixin, TemplateView): - """ - Представление для отображения 3D карты с использованием Cesium. - - Отображает спутники и их зоны покрытия на интерактивной 3D карте. - """ - template_name = 'mapsapp/map3d.html' - - def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: - context = super().get_context_data(**kwargs) - # Оптимизированный запрос - загружаем только необходимые поля - context['sats'] = Satellite.objects.filter( - parameters__objitems__isnull=False - ).distinct().only('id', 'name').order_by('name') - return context - -class GetFootprintsView(LoginRequiredMixin, View): - """ - API для получения зон покрытия (footprints) спутника. - - Возвращает список названий зон покрытия для указанного спутника. - """ - def get(self, request, sat_id): - try: - # Оптимизированный запрос - загружаем только поле name - sat_name = Satellite.objects.only('name').get(id=sat_id).name - footprint_names = get_band_names(sat_name) - - return JsonResponse(footprint_names, safe=False) - except Satellite.DoesNotExist: - return JsonResponse({"error": "Спутник не найден"}, status=404) - except Exception as e: - return JsonResponse({"error": str(e)}, status=500) - - -class TileProxyView(View): - """ - Прокси для загрузки тайлов карты покрытия спутников. - - Кэширует тайлы на 7 дней для улучшения производительности. - """ - # Константы - TILE_BASE_URL = "https://static.satbeams.com/tiles" - CACHE_DURATION = 60 * 60 * 24 * 7 # 7 дней - REQUEST_TIMEOUT = 10 # секунд - - @method_decorator(require_GET) - @method_decorator(cache_page(CACHE_DURATION)) - def dispatch(self, *args, **kwargs): - return super().dispatch(*args, **kwargs) - - def get(self, request, footprint_name, z, x, y): - # Валидация имени footprint - if not footprint_name.replace('-', '').replace('_', '').isalnum(): - return HttpResponse("Invalid footprint name", status=400) - - url = f"{self.TILE_BASE_URL}/{footprint_name}/{z}/{x}/{y}.png" - - try: - resp = requests.get(url, timeout=self.REQUEST_TIMEOUT) - if resp.status_code == 200: - response = HttpResponse(resp.content, content_type='image/png') - response["Access-Control-Allow-Origin"] = "*" - response["Cache-Control"] = f"public, max-age={self.CACHE_DURATION}" - return response - else: - return HttpResponseNotFound("Tile not found") - except requests.Timeout: - return HttpResponse("Request timeout", status=504) - except requests.RequestException as e: - return HttpResponse(f"Proxy error: {e}", status=500) - -class LeafletMapView(LoginRequiredMixin, TemplateView): - """ - Представление для отображения 2D карты с использованием Leaflet. - - Отображает спутники и транспондеры на интерактивной 2D карте. - """ - template_name = 'mapsapp/map2d.html' - - def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: - context = super().get_context_data(**kwargs) - # Оптимизированные запросы - загружаем только необходимые поля - context['sats'] = Satellite.objects.filter( - parameters__objitems__isnull=False - ).distinct().only('id', 'name').order_by('name') - - context['trans'] = Transponders.objects.select_related( - 'sat_id', 'polarization' - ).only( - 'id', 'name', 'sat_id__name', 'polarization__name', - 'downlink', 'frequency_range', 'zone_name' - ) - return context - - -class GetTransponderOnSatIdView(LoginRequiredMixin, View): - """ - API для получения транспондеров спутника. - - Возвращает список транспондеров для указанного спутника с оптимизированными запросами. - """ - def get(self, request, sat_id): - # Оптимизированный запрос с select_related и only - trans = Transponders.objects.filter( - sat_id=sat_id - ).select_related('polarization').only( - 'name', 'downlink', 'frequency_range', - 'zone_name', 'polarization__name' - ) - - if not trans.exists(): - return JsonResponse({'error': 'Объектов не найдено'}, status=404) - - # Используем list comprehension для лучшей производительности - output = [ - { - "name": tran.name, - "frequency": tran.downlink, - "frequency_range": tran.frequency_range, - "zone_name": tran.zone_name, - "polarization": tran.polarization.name if tran.polarization else "-" - } - for tran in trans - ] - +# Standard library imports +from typing import Any, Dict + +# Django imports +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import HttpResponse, HttpResponseNotFound, JsonResponse +from django.utils.decorators import method_decorator +from django.views import View +from django.views.decorators.cache import cache_page +from django.views.decorators.http import require_GET +from django.views.generic import TemplateView + +# Third-party imports +import requests + +# Local imports +from mainapp.models import Satellite +from .models import Transponders +from .utils import get_band_names + + +class CesiumMapView(LoginRequiredMixin, TemplateView): + """ + Представление для отображения 3D карты с использованием Cesium. + + Отображает спутники и их зоны покрытия на интерактивной 3D карте. + """ + template_name = 'mapsapp/map3d.html' + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + context = super().get_context_data(**kwargs) + # Оптимизированный запрос - загружаем только необходимые поля + context['sats'] = Satellite.objects.filter( + parameters__objitems__isnull=False + ).distinct().only('id', 'name').order_by('name') + return context + +class GetFootprintsView(LoginRequiredMixin, View): + """ + API для получения зон покрытия (footprints) спутника. + + Возвращает список названий зон покрытия для указанного спутника. + """ + def get(self, request, sat_id): + try: + # Оптимизированный запрос - загружаем только поле name + sat_name = Satellite.objects.only('name').get(id=sat_id).name + footprint_names = get_band_names(sat_name) + + return JsonResponse(footprint_names, safe=False) + except Satellite.DoesNotExist: + return JsonResponse({"error": "Спутник не найден"}, status=404) + except Exception as e: + return JsonResponse({"error": str(e)}, status=500) + + +class TileProxyView(View): + """ + Прокси для загрузки тайлов карты покрытия спутников. + + Кэширует тайлы на 7 дней для улучшения производительности. + """ + # Константы + TILE_BASE_URL = "https://static.satbeams.com/tiles" + CACHE_DURATION = 60 * 60 * 24 * 7 # 7 дней + REQUEST_TIMEOUT = 10 # секунд + + @method_decorator(require_GET) + @method_decorator(cache_page(CACHE_DURATION)) + def dispatch(self, *args, **kwargs): + return super().dispatch(*args, **kwargs) + + def get(self, request, footprint_name, z, x, y): + # Валидация имени footprint + if not footprint_name.replace('-', '').replace('_', '').isalnum(): + return HttpResponse("Invalid footprint name", status=400) + + url = f"{self.TILE_BASE_URL}/{footprint_name}/{z}/{x}/{y}.png" + + try: + resp = requests.get(url, timeout=self.REQUEST_TIMEOUT) + if resp.status_code == 200: + response = HttpResponse(resp.content, content_type='image/png') + response["Access-Control-Allow-Origin"] = "*" + response["Cache-Control"] = f"public, max-age={self.CACHE_DURATION}" + return response + else: + return HttpResponseNotFound("Tile not found") + except requests.Timeout: + return HttpResponse("Request timeout", status=504) + except requests.RequestException as e: + return HttpResponse(f"Proxy error: {e}", status=500) + +class LeafletMapView(LoginRequiredMixin, TemplateView): + """ + Представление для отображения 2D карты с использованием Leaflet. + + Отображает спутники и транспондеры на интерактивной 2D карте. + """ + template_name = 'mapsapp/map2d.html' + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + context = super().get_context_data(**kwargs) + # Оптимизированные запросы - загружаем только необходимые поля + context['sats'] = Satellite.objects.filter( + parameters__objitems__isnull=False + ).distinct().only('id', 'name').order_by('name') + + context['trans'] = Transponders.objects.select_related( + 'sat_id', 'polarization' + ).only( + 'id', 'name', 'sat_id__name', 'polarization__name', + 'downlink', 'frequency_range', 'zone_name' + ) + return context + + +class GetTransponderOnSatIdView(LoginRequiredMixin, View): + """ + API для получения транспондеров спутника. + + Возвращает список транспондеров для указанного спутника с оптимизированными запросами. + """ + def get(self, request, sat_id): + # Оптимизированный запрос с select_related и only + trans = Transponders.objects.filter( + sat_id=sat_id + ).select_related('polarization').only( + 'name', 'downlink', 'frequency_range', + 'zone_name', 'polarization__name' + ) + + if not trans.exists(): + return JsonResponse({'error': 'Объектов не найдено'}, status=404) + + # Используем list comprehension для лучшей производительности + output = [ + { + "name": tran.name, + "frequency": tran.downlink, + "frequency_range": tran.frequency_range, + "zone_name": tran.zone_name, + "polarization": tran.polarization.name if tran.polarization else "-" + } + for tran in trans + ] + return JsonResponse(output, safe=False) \ No newline at end of file diff --git a/dbapp/pyproject.toml b/dbapp/pyproject.toml index 53f08d0..0f5ce65 100644 --- a/dbapp/pyproject.toml +++ b/dbapp/pyproject.toml @@ -8,11 +8,14 @@ dependencies = [ "aiosqlite>=0.21.0", "bcrypt>=5.0.0", "beautifulsoup4>=4.14.2", + "celery>=5.5.3", "django>=5.2.7", "django-admin-interface>=0.30.1", "django-admin-multiple-choice-list-filter>=0.1.1", "django-admin-rangefilter>=0.13.3", "django-autocomplete-light>=3.12.1", + "django-celery-beat>=2.6.0", + "django-celery-results>=2.5.1", "django-daisy>=1.1.2", "django-debug-toolbar>=6.0.0", "django-dynamic-raw-id>=4.4", @@ -21,6 +24,7 @@ dependencies = [ "django-map-widgets>=0.5.1", "django-more-admin-filters>=1.13", "dotenv>=0.9.9", + "flower>=2.0.1", "geopy>=2.4.1", "gunicorn>=23.0.0", "lxml>=6.0.2", diff --git a/dbapp/requirements.txt b/dbapp/requirements.txt deleted file mode 100644 index 9e41cc3..0000000 --- a/dbapp/requirements.txt +++ /dev/null @@ -1,33 +0,0 @@ -aiosqlite>=0.21.0 -bcrypt>=5.0.0 -beautifulsoup4>=4.14.2 -django>=5.2.7 -django-admin-interface>=0.30.1 -django-admin-multiple-choice-list-filter>=0.1.1 -django-admin-rangefilter>=0.13.3 -django-autocomplete-light>=3.12.1 -django-daisy>=1.1.2 -django-debug-toolbar>=6.0.0 -django-dynamic-raw-id>=4.4 -django-import-export>=4.3.10 -django-leaflet>=0.32.0 -django-map-widgets>=0.5.1 -django-more-admin-filters>=1.13 -dotenv>=0.9.9 -geopy>=2.4.1 -gunicorn>=23.0.0 -lxml>=6.0.2 -matplotlib>=3.10.7 -numpy>=2.3.3 -openpyxl>=3.1.5 -pandas>=2.3.3 -psycopg>=3.2.10 -psycopg2-binary>=2.9.11 -redis>=6.4.0 -celery>=5.4.0 -django-celery-results>=2.5.1 -requests>=2.32.5 -reverse-geocoder>=1.5.1 -scikit-learn>=1.7.2 -selenium>=4.38.0 -setuptools>=80.9.0 diff --git a/dbapp/start_celery_worker.sh b/dbapp/start_celery_worker.sh index d9f544a..925e01d 100755 --- a/dbapp/start_celery_worker.sh +++ b/dbapp/start_celery_worker.sh @@ -1,5 +1,5 @@ -#!/bin/bash -# Script to start Celery worker - -echo "Starting Celery worker..." -celery -A dbapp worker --loglevel=info --logfile=logs/celery_worker.log +#!/bin/bash +# Script to start Celery worker + +echo "Starting Celery worker..." +celery -A dbapp worker --loglevel=info --logfile=logs/celery_worker.log diff --git a/dbapp/static/leaflet-measure/languages/de.json b/dbapp/static/leaflet-measure/languages/de.json index 85d5a07..58a0ab1 100644 --- a/dbapp/static/leaflet-measure/languages/de.json +++ b/dbapp/static/leaflet-measure/languages/de.json @@ -1,30 +1,30 @@ -{ - "measure": "Messung", - "measureDistancesAndAreas": "Messung von Abständen und Flächen", - "createNewMeasurement": "Eine neue Messung durchführen", - "startCreating": "Führen Sie die Messung durch, indem Sie der Karte Punkte hinzufügen.", - "finishMeasurement": "Messung beenden", - "lastPoint": "Letzter Punkt", - "area": "Fläche", - "perimeter": "Rand", - "pointLocation": "Lage des Punkts", - "areaMeasurement": "Gemessene Fläche", - "linearMeasurement": "Gemessener Abstand", - "pathDistance": "Abstand entlang des Pfads", - "centerOnArea": "Auf diese Fläche zentrieren", - "centerOnLine": "Auf diesen Linienzug zentrieren", - "centerOnLocation": "Auf diesen Ort zentrieren", - "cancel": "Abbrechen", - "delete": "Löschen", - "acres": "Morgen", - "feet": "Fuß", - "kilometers": "Kilometer", - "hectares": "Hektar", - "meters": "Meter", - "miles": "Meilen", - "sqfeet": "Quadratfuß", - "sqmeters": "Quadratmeter", - "sqmiles": "Quadratmeilen", - "decPoint": ",", - "thousandsSep": "." -} +{ + "measure": "Messung", + "measureDistancesAndAreas": "Messung von Abständen und Flächen", + "createNewMeasurement": "Eine neue Messung durchführen", + "startCreating": "Führen Sie die Messung durch, indem Sie der Karte Punkte hinzufügen.", + "finishMeasurement": "Messung beenden", + "lastPoint": "Letzter Punkt", + "area": "Fläche", + "perimeter": "Rand", + "pointLocation": "Lage des Punkts", + "areaMeasurement": "Gemessene Fläche", + "linearMeasurement": "Gemessener Abstand", + "pathDistance": "Abstand entlang des Pfads", + "centerOnArea": "Auf diese Fläche zentrieren", + "centerOnLine": "Auf diesen Linienzug zentrieren", + "centerOnLocation": "Auf diesen Ort zentrieren", + "cancel": "Abbrechen", + "delete": "Löschen", + "acres": "Morgen", + "feet": "Fuß", + "kilometers": "Kilometer", + "hectares": "Hektar", + "meters": "Meter", + "miles": "Meilen", + "sqfeet": "Quadratfuß", + "sqmeters": "Quadratmeter", + "sqmiles": "Quadratmeilen", + "decPoint": ",", + "thousandsSep": "." +} diff --git a/dbapp/static/leaflet/leaflet-src.esm.js b/dbapp/static/leaflet/leaflet-src.esm.js index c9c0d4e..a9a6479 100644 --- a/dbapp/static/leaflet/leaflet-src.esm.js +++ b/dbapp/static/leaflet/leaflet-src.esm.js @@ -5,243 +5,243 @@ var version = "1.7.1"; -/* - * @namespace Util - * - * Various utility functions, used by Leaflet internally. - */ - -// @function extend(dest: Object, src?: Object): Object -// Merges the properties of the `src` object (or multiple objects) into `dest` object and returns the latter. Has an `L.extend` shortcut. -function extend(dest) { - var i, j, len, src; - - for (j = 1, len = arguments.length; j < len; j++) { - src = arguments[j]; - for (i in src) { - dest[i] = src[i]; - } - } - return dest; -} - -// @function create(proto: Object, properties?: Object): Object -// Compatibility polyfill for [Object.create](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object/create) -var create = Object.create || (function () { - function F() {} - return function (proto) { - F.prototype = proto; - return new F(); - }; -})(); - -// @function bind(fn: Function, …): Function -// Returns a new function bound to the arguments passed, like [Function.prototype.bind](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Function/bind). -// Has a `L.bind()` shortcut. -function bind(fn, obj) { - var slice = Array.prototype.slice; - - if (fn.bind) { - return fn.bind.apply(fn, slice.call(arguments, 1)); - } - - var args = slice.call(arguments, 2); - - return function () { - return fn.apply(obj, args.length ? args.concat(slice.call(arguments)) : arguments); - }; -} - -// @property lastId: Number -// Last unique ID used by [`stamp()`](#util-stamp) -var lastId = 0; - -// @function stamp(obj: Object): Number -// Returns the unique ID of an object, assigning it one if it doesn't have it. -function stamp(obj) { - /*eslint-disable */ - obj._leaflet_id = obj._leaflet_id || ++lastId; - return obj._leaflet_id; - /* eslint-enable */ -} - -// @function throttle(fn: Function, time: Number, context: Object): Function -// Returns a function which executes function `fn` with the given scope `context` -// (so that the `this` keyword refers to `context` inside `fn`'s code). The function -// `fn` will be called no more than one time per given amount of `time`. The arguments -// received by the bound function will be any arguments passed when binding the -// function, followed by any arguments passed when invoking the bound function. -// Has an `L.throttle` shortcut. -function throttle(fn, time, context) { - var lock, args, wrapperFn, later; - - later = function () { - // reset lock and call if queued - lock = false; - if (args) { - wrapperFn.apply(context, args); - args = false; - } - }; - - wrapperFn = function () { - if (lock) { - // called too soon, queue to call later - args = arguments; - - } else { - // call and lock until later - fn.apply(context, arguments); - setTimeout(later, time); - lock = true; - } - }; - - return wrapperFn; -} - -// @function wrapNum(num: Number, range: Number[], includeMax?: Boolean): Number -// Returns the number `num` modulo `range` in such a way so it lies within -// `range[0]` and `range[1]`. The returned value will be always smaller than -// `range[1]` unless `includeMax` is set to `true`. -function wrapNum(x, range, includeMax) { - var max = range[1], - min = range[0], - d = max - min; - return x === max && includeMax ? x : ((x - min) % d + d) % d + min; -} - -// @function falseFn(): Function -// Returns a function which always returns `false`. -function falseFn() { return false; } - -// @function formatNum(num: Number, digits?: Number): Number -// Returns the number `num` rounded to `digits` decimals, or to 6 decimals by default. -function formatNum(num, digits) { - var pow = Math.pow(10, (digits === undefined ? 6 : digits)); - return Math.round(num * pow) / pow; -} - -// @function trim(str: String): String -// Compatibility polyfill for [String.prototype.trim](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String/Trim) -function trim(str) { - return str.trim ? str.trim() : str.replace(/^\s+|\s+$/g, ''); -} - -// @function splitWords(str: String): String[] -// Trims and splits the string on whitespace and returns the array of parts. -function splitWords(str) { - return trim(str).split(/\s+/); -} - -// @function setOptions(obj: Object, options: Object): Object -// Merges the given properties to the `options` of the `obj` object, returning the resulting options. See `Class options`. Has an `L.setOptions` shortcut. -function setOptions(obj, options) { - if (!Object.prototype.hasOwnProperty.call(obj, 'options')) { - obj.options = obj.options ? create(obj.options) : {}; - } - for (var i in options) { - obj.options[i] = options[i]; - } - return obj.options; -} - -// @function getParamString(obj: Object, existingUrl?: String, uppercase?: Boolean): String -// Converts an object into a parameter URL string, e.g. `{a: "foo", b: "bar"}` -// translates to `'?a=foo&b=bar'`. If `existingUrl` is set, the parameters will -// be appended at the end. If `uppercase` is `true`, the parameter names will -// be uppercased (e.g. `'?A=foo&B=bar'`) -function getParamString(obj, existingUrl, uppercase) { - var params = []; - for (var i in obj) { - params.push(encodeURIComponent(uppercase ? i.toUpperCase() : i) + '=' + encodeURIComponent(obj[i])); - } - return ((!existingUrl || existingUrl.indexOf('?') === -1) ? '?' : '&') + params.join('&'); -} - -var templateRe = /\{ *([\w_-]+) *\}/g; - -// @function template(str: String, data: Object): String -// Simple templating facility, accepts a template string of the form `'Hello {a}, {b}'` -// and a data object like `{a: 'foo', b: 'bar'}`, returns evaluated string -// `('Hello foo, bar')`. You can also specify functions instead of strings for -// data values — they will be evaluated passing `data` as an argument. -function template(str, data) { - return str.replace(templateRe, function (str, key) { - var value = data[key]; - - if (value === undefined) { - throw new Error('No value provided for variable ' + str); - - } else if (typeof value === 'function') { - value = value(data); - } - return value; - }); -} - -// @function isArray(obj): Boolean -// Compatibility polyfill for [Array.isArray](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/isArray) -var isArray = Array.isArray || function (obj) { - return (Object.prototype.toString.call(obj) === '[object Array]'); -}; - -// @function indexOf(array: Array, el: Object): Number -// Compatibility polyfill for [Array.prototype.indexOf](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/indexOf) -function indexOf(array, el) { - for (var i = 0; i < array.length; i++) { - if (array[i] === el) { return i; } - } - return -1; -} - -// @property emptyImageUrl: String -// Data URI string containing a base64-encoded empty GIF image. -// Used as a hack to free memory from unused images on WebKit-powered -// mobile devices (by setting image `src` to this string). -var emptyImageUrl = 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='; - -// inspired by http://paulirish.com/2011/requestanimationframe-for-smart-animating/ - -function getPrefixed(name) { - return window['webkit' + name] || window['moz' + name] || window['ms' + name]; -} - -var lastTime = 0; - -// fallback for IE 7-8 -function timeoutDefer(fn) { - var time = +new Date(), - timeToCall = Math.max(0, 16 - (time - lastTime)); - - lastTime = time + timeToCall; - return window.setTimeout(fn, timeToCall); -} - -var requestFn = window.requestAnimationFrame || getPrefixed('RequestAnimationFrame') || timeoutDefer; -var cancelFn = window.cancelAnimationFrame || getPrefixed('CancelAnimationFrame') || - getPrefixed('CancelRequestAnimationFrame') || function (id) { window.clearTimeout(id); }; - -// @function requestAnimFrame(fn: Function, context?: Object, immediate?: Boolean): Number -// Schedules `fn` to be executed when the browser repaints. `fn` is bound to -// `context` if given. When `immediate` is set, `fn` is called immediately if -// the browser doesn't have native support for -// [`window.requestAnimationFrame`](https://developer.mozilla.org/docs/Web/API/window/requestAnimationFrame), -// otherwise it's delayed. Returns a request ID that can be used to cancel the request. -function requestAnimFrame(fn, context, immediate) { - if (immediate && requestFn === timeoutDefer) { - fn.call(context); - } else { - return requestFn.call(window, bind(fn, context)); - } -} - -// @function cancelAnimFrame(id: Number): undefined -// Cancels a previous `requestAnimFrame`. See also [window.cancelAnimationFrame](https://developer.mozilla.org/docs/Web/API/window/cancelAnimationFrame). -function cancelAnimFrame(id) { - if (id) { - cancelFn.call(window, id); - } +/* + * @namespace Util + * + * Various utility functions, used by Leaflet internally. + */ + +// @function extend(dest: Object, src?: Object): Object +// Merges the properties of the `src` object (or multiple objects) into `dest` object and returns the latter. Has an `L.extend` shortcut. +function extend(dest) { + var i, j, len, src; + + for (j = 1, len = arguments.length; j < len; j++) { + src = arguments[j]; + for (i in src) { + dest[i] = src[i]; + } + } + return dest; +} + +// @function create(proto: Object, properties?: Object): Object +// Compatibility polyfill for [Object.create](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object/create) +var create = Object.create || (function () { + function F() {} + return function (proto) { + F.prototype = proto; + return new F(); + }; +})(); + +// @function bind(fn: Function, …): Function +// Returns a new function bound to the arguments passed, like [Function.prototype.bind](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Function/bind). +// Has a `L.bind()` shortcut. +function bind(fn, obj) { + var slice = Array.prototype.slice; + + if (fn.bind) { + return fn.bind.apply(fn, slice.call(arguments, 1)); + } + + var args = slice.call(arguments, 2); + + return function () { + return fn.apply(obj, args.length ? args.concat(slice.call(arguments)) : arguments); + }; +} + +// @property lastId: Number +// Last unique ID used by [`stamp()`](#util-stamp) +var lastId = 0; + +// @function stamp(obj: Object): Number +// Returns the unique ID of an object, assigning it one if it doesn't have it. +function stamp(obj) { + /*eslint-disable */ + obj._leaflet_id = obj._leaflet_id || ++lastId; + return obj._leaflet_id; + /* eslint-enable */ +} + +// @function throttle(fn: Function, time: Number, context: Object): Function +// Returns a function which executes function `fn` with the given scope `context` +// (so that the `this` keyword refers to `context` inside `fn`'s code). The function +// `fn` will be called no more than one time per given amount of `time`. The arguments +// received by the bound function will be any arguments passed when binding the +// function, followed by any arguments passed when invoking the bound function. +// Has an `L.throttle` shortcut. +function throttle(fn, time, context) { + var lock, args, wrapperFn, later; + + later = function () { + // reset lock and call if queued + lock = false; + if (args) { + wrapperFn.apply(context, args); + args = false; + } + }; + + wrapperFn = function () { + if (lock) { + // called too soon, queue to call later + args = arguments; + + } else { + // call and lock until later + fn.apply(context, arguments); + setTimeout(later, time); + lock = true; + } + }; + + return wrapperFn; +} + +// @function wrapNum(num: Number, range: Number[], includeMax?: Boolean): Number +// Returns the number `num` modulo `range` in such a way so it lies within +// `range[0]` and `range[1]`. The returned value will be always smaller than +// `range[1]` unless `includeMax` is set to `true`. +function wrapNum(x, range, includeMax) { + var max = range[1], + min = range[0], + d = max - min; + return x === max && includeMax ? x : ((x - min) % d + d) % d + min; +} + +// @function falseFn(): Function +// Returns a function which always returns `false`. +function falseFn() { return false; } + +// @function formatNum(num: Number, digits?: Number): Number +// Returns the number `num` rounded to `digits` decimals, or to 6 decimals by default. +function formatNum(num, digits) { + var pow = Math.pow(10, (digits === undefined ? 6 : digits)); + return Math.round(num * pow) / pow; +} + +// @function trim(str: String): String +// Compatibility polyfill for [String.prototype.trim](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String/Trim) +function trim(str) { + return str.trim ? str.trim() : str.replace(/^\s+|\s+$/g, ''); +} + +// @function splitWords(str: String): String[] +// Trims and splits the string on whitespace and returns the array of parts. +function splitWords(str) { + return trim(str).split(/\s+/); +} + +// @function setOptions(obj: Object, options: Object): Object +// Merges the given properties to the `options` of the `obj` object, returning the resulting options. See `Class options`. Has an `L.setOptions` shortcut. +function setOptions(obj, options) { + if (!Object.prototype.hasOwnProperty.call(obj, 'options')) { + obj.options = obj.options ? create(obj.options) : {}; + } + for (var i in options) { + obj.options[i] = options[i]; + } + return obj.options; +} + +// @function getParamString(obj: Object, existingUrl?: String, uppercase?: Boolean): String +// Converts an object into a parameter URL string, e.g. `{a: "foo", b: "bar"}` +// translates to `'?a=foo&b=bar'`. If `existingUrl` is set, the parameters will +// be appended at the end. If `uppercase` is `true`, the parameter names will +// be uppercased (e.g. `'?A=foo&B=bar'`) +function getParamString(obj, existingUrl, uppercase) { + var params = []; + for (var i in obj) { + params.push(encodeURIComponent(uppercase ? i.toUpperCase() : i) + '=' + encodeURIComponent(obj[i])); + } + return ((!existingUrl || existingUrl.indexOf('?') === -1) ? '?' : '&') + params.join('&'); +} + +var templateRe = /\{ *([\w_-]+) *\}/g; + +// @function template(str: String, data: Object): String +// Simple templating facility, accepts a template string of the form `'Hello {a}, {b}'` +// and a data object like `{a: 'foo', b: 'bar'}`, returns evaluated string +// `('Hello foo, bar')`. You can also specify functions instead of strings for +// data values — they will be evaluated passing `data` as an argument. +function template(str, data) { + return str.replace(templateRe, function (str, key) { + var value = data[key]; + + if (value === undefined) { + throw new Error('No value provided for variable ' + str); + + } else if (typeof value === 'function') { + value = value(data); + } + return value; + }); +} + +// @function isArray(obj): Boolean +// Compatibility polyfill for [Array.isArray](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/isArray) +var isArray = Array.isArray || function (obj) { + return (Object.prototype.toString.call(obj) === '[object Array]'); +}; + +// @function indexOf(array: Array, el: Object): Number +// Compatibility polyfill for [Array.prototype.indexOf](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/indexOf) +function indexOf(array, el) { + for (var i = 0; i < array.length; i++) { + if (array[i] === el) { return i; } + } + return -1; +} + +// @property emptyImageUrl: String +// Data URI string containing a base64-encoded empty GIF image. +// Used as a hack to free memory from unused images on WebKit-powered +// mobile devices (by setting image `src` to this string). +var emptyImageUrl = 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='; + +// inspired by http://paulirish.com/2011/requestanimationframe-for-smart-animating/ + +function getPrefixed(name) { + return window['webkit' + name] || window['moz' + name] || window['ms' + name]; +} + +var lastTime = 0; + +// fallback for IE 7-8 +function timeoutDefer(fn) { + var time = +new Date(), + timeToCall = Math.max(0, 16 - (time - lastTime)); + + lastTime = time + timeToCall; + return window.setTimeout(fn, timeToCall); +} + +var requestFn = window.requestAnimationFrame || getPrefixed('RequestAnimationFrame') || timeoutDefer; +var cancelFn = window.cancelAnimationFrame || getPrefixed('CancelAnimationFrame') || + getPrefixed('CancelRequestAnimationFrame') || function (id) { window.clearTimeout(id); }; + +// @function requestAnimFrame(fn: Function, context?: Object, immediate?: Boolean): Number +// Schedules `fn` to be executed when the browser repaints. `fn` is bound to +// `context` if given. When `immediate` is set, `fn` is called immediately if +// the browser doesn't have native support for +// [`window.requestAnimationFrame`](https://developer.mozilla.org/docs/Web/API/window/requestAnimationFrame), +// otherwise it's delayed. Returns a request ID that can be used to cancel the request. +function requestAnimFrame(fn, context, immediate) { + if (immediate && requestFn === timeoutDefer) { + fn.call(context); + } else { + return requestFn.call(window, bind(fn, context)); + } +} + +// @function cancelAnimFrame(id: Number): undefined +// Cancels a previous `requestAnimFrame`. See also [window.cancelAnimationFrame](https://developer.mozilla.org/docs/Web/API/window/cancelAnimationFrame). +function cancelAnimFrame(id) { + if (id) { + cancelFn.call(window, id); + } } var Util = ({ @@ -268,1333 +268,1333 @@ var Util = ({ cancelAnimFrame: cancelAnimFrame }); -// @class Class -// @aka L.Class - -// @section -// @uninheritable - -// Thanks to John Resig and Dean Edwards for inspiration! - -function Class() {} - -Class.extend = function (props) { - - // @function extend(props: Object): Function - // [Extends the current class](#class-inheritance) given the properties to be included. - // Returns a Javascript function that is a class constructor (to be called with `new`). - var NewClass = function () { - - // call the constructor - if (this.initialize) { - this.initialize.apply(this, arguments); - } - - // call all constructor hooks - this.callInitHooks(); - }; - - var parentProto = NewClass.__super__ = this.prototype; - - var proto = create(parentProto); - proto.constructor = NewClass; - - NewClass.prototype = proto; - - // inherit parent's statics - for (var i in this) { - if (Object.prototype.hasOwnProperty.call(this, i) && i !== 'prototype' && i !== '__super__') { - NewClass[i] = this[i]; - } - } - - // mix static properties into the class - if (props.statics) { - extend(NewClass, props.statics); - delete props.statics; - } - - // mix includes into the prototype - if (props.includes) { - checkDeprecatedMixinEvents(props.includes); - extend.apply(null, [proto].concat(props.includes)); - delete props.includes; - } - - // merge options - if (proto.options) { - props.options = extend(create(proto.options), props.options); - } - - // mix given properties into the prototype - extend(proto, props); - - proto._initHooks = []; - - // add method for calling all hooks - proto.callInitHooks = function () { - - if (this._initHooksCalled) { return; } - - if (parentProto.callInitHooks) { - parentProto.callInitHooks.call(this); - } - - this._initHooksCalled = true; - - for (var i = 0, len = proto._initHooks.length; i < len; i++) { - proto._initHooks[i].call(this); - } - }; - - return NewClass; -}; - - -// @function include(properties: Object): this -// [Includes a mixin](#class-includes) into the current class. -Class.include = function (props) { - extend(this.prototype, props); - return this; -}; - -// @function mergeOptions(options: Object): this -// [Merges `options`](#class-options) into the defaults of the class. -Class.mergeOptions = function (options) { - extend(this.prototype.options, options); - return this; -}; - -// @function addInitHook(fn: Function): this -// Adds a [constructor hook](#class-constructor-hooks) to the class. -Class.addInitHook = function (fn) { // (Function) || (String, args...) - var args = Array.prototype.slice.call(arguments, 1); - - var init = typeof fn === 'function' ? fn : function () { - this[fn].apply(this, args); - }; - - this.prototype._initHooks = this.prototype._initHooks || []; - this.prototype._initHooks.push(init); - return this; -}; - -function checkDeprecatedMixinEvents(includes) { - if (typeof L === 'undefined' || !L || !L.Mixin) { return; } - - includes = isArray(includes) ? includes : [includes]; - - for (var i = 0; i < includes.length; i++) { - if (includes[i] === L.Mixin.Events) { - console.warn('Deprecated include of L.Mixin.Events: ' + - 'this property will be removed in future releases, ' + - 'please inherit from L.Evented instead.', new Error().stack); - } - } +// @class Class +// @aka L.Class + +// @section +// @uninheritable + +// Thanks to John Resig and Dean Edwards for inspiration! + +function Class() {} + +Class.extend = function (props) { + + // @function extend(props: Object): Function + // [Extends the current class](#class-inheritance) given the properties to be included. + // Returns a Javascript function that is a class constructor (to be called with `new`). + var NewClass = function () { + + // call the constructor + if (this.initialize) { + this.initialize.apply(this, arguments); + } + + // call all constructor hooks + this.callInitHooks(); + }; + + var parentProto = NewClass.__super__ = this.prototype; + + var proto = create(parentProto); + proto.constructor = NewClass; + + NewClass.prototype = proto; + + // inherit parent's statics + for (var i in this) { + if (Object.prototype.hasOwnProperty.call(this, i) && i !== 'prototype' && i !== '__super__') { + NewClass[i] = this[i]; + } + } + + // mix static properties into the class + if (props.statics) { + extend(NewClass, props.statics); + delete props.statics; + } + + // mix includes into the prototype + if (props.includes) { + checkDeprecatedMixinEvents(props.includes); + extend.apply(null, [proto].concat(props.includes)); + delete props.includes; + } + + // merge options + if (proto.options) { + props.options = extend(create(proto.options), props.options); + } + + // mix given properties into the prototype + extend(proto, props); + + proto._initHooks = []; + + // add method for calling all hooks + proto.callInitHooks = function () { + + if (this._initHooksCalled) { return; } + + if (parentProto.callInitHooks) { + parentProto.callInitHooks.call(this); + } + + this._initHooksCalled = true; + + for (var i = 0, len = proto._initHooks.length; i < len; i++) { + proto._initHooks[i].call(this); + } + }; + + return NewClass; +}; + + +// @function include(properties: Object): this +// [Includes a mixin](#class-includes) into the current class. +Class.include = function (props) { + extend(this.prototype, props); + return this; +}; + +// @function mergeOptions(options: Object): this +// [Merges `options`](#class-options) into the defaults of the class. +Class.mergeOptions = function (options) { + extend(this.prototype.options, options); + return this; +}; + +// @function addInitHook(fn: Function): this +// Adds a [constructor hook](#class-constructor-hooks) to the class. +Class.addInitHook = function (fn) { // (Function) || (String, args...) + var args = Array.prototype.slice.call(arguments, 1); + + var init = typeof fn === 'function' ? fn : function () { + this[fn].apply(this, args); + }; + + this.prototype._initHooks = this.prototype._initHooks || []; + this.prototype._initHooks.push(init); + return this; +}; + +function checkDeprecatedMixinEvents(includes) { + if (typeof L === 'undefined' || !L || !L.Mixin) { return; } + + includes = isArray(includes) ? includes : [includes]; + + for (var i = 0; i < includes.length; i++) { + if (includes[i] === L.Mixin.Events) { + console.warn('Deprecated include of L.Mixin.Events: ' + + 'this property will be removed in future releases, ' + + 'please inherit from L.Evented instead.', new Error().stack); + } + } } -/* - * @class Evented - * @aka L.Evented - * @inherits Class - * - * A set of methods shared between event-powered classes (like `Map` and `Marker`). Generally, events allow you to execute some function when something happens with an object (e.g. the user clicks on the map, causing the map to fire `'click'` event). - * - * @example - * - * ```js - * map.on('click', function(e) { - * alert(e.latlng); - * } ); - * ``` - * - * Leaflet deals with event listeners by reference, so if you want to add a listener and then remove it, define it as a function: - * - * ```js - * function onClick(e) { ... } - * - * map.on('click', onClick); - * map.off('click', onClick); - * ``` - */ - -var Events = { - /* @method on(type: String, fn: Function, context?: Object): this - * Adds a listener function (`fn`) to a particular event type of the object. You can optionally specify the context of the listener (object the this keyword will point to). You can also pass several space-separated types (e.g. `'click dblclick'`). - * - * @alternative - * @method on(eventMap: Object): this - * Adds a set of type/listener pairs, e.g. `{click: onClick, mousemove: onMouseMove}` - */ - on: function (types, fn, context) { - - // types can be a map of types/handlers - if (typeof types === 'object') { - for (var type in types) { - // we don't process space-separated events here for performance; - // it's a hot path since Layer uses the on(obj) syntax - this._on(type, types[type], fn); - } - - } else { - // types can be a string of space-separated words - types = splitWords(types); - - for (var i = 0, len = types.length; i < len; i++) { - this._on(types[i], fn, context); - } - } - - return this; - }, - - /* @method off(type: String, fn?: Function, context?: Object): this - * Removes a previously added listener function. If no function is specified, it will remove all the listeners of that particular event from the object. Note that if you passed a custom context to `on`, you must pass the same context to `off` in order to remove the listener. - * - * @alternative - * @method off(eventMap: Object): this - * Removes a set of type/listener pairs. - * - * @alternative - * @method off: this - * Removes all listeners to all events on the object. This includes implicitly attached events. - */ - off: function (types, fn, context) { - - if (!types) { - // clear all listeners if called without arguments - delete this._events; - - } else if (typeof types === 'object') { - for (var type in types) { - this._off(type, types[type], fn); - } - - } else { - types = splitWords(types); - - for (var i = 0, len = types.length; i < len; i++) { - this._off(types[i], fn, context); - } - } - - return this; - }, - - // attach listener (without syntactic sugar now) - _on: function (type, fn, context) { - this._events = this._events || {}; - - /* get/init listeners for type */ - var typeListeners = this._events[type]; - if (!typeListeners) { - typeListeners = []; - this._events[type] = typeListeners; - } - - if (context === this) { - // Less memory footprint. - context = undefined; - } - var newListener = {fn: fn, ctx: context}, - listeners = typeListeners; - - // check if fn already there - for (var i = 0, len = listeners.length; i < len; i++) { - if (listeners[i].fn === fn && listeners[i].ctx === context) { - return; - } - } - - listeners.push(newListener); - }, - - _off: function (type, fn, context) { - var listeners, - i, - len; - - if (!this._events) { return; } - - listeners = this._events[type]; - - if (!listeners) { - return; - } - - if (!fn) { - // Set all removed listeners to noop so they are not called if remove happens in fire - for (i = 0, len = listeners.length; i < len; i++) { - listeners[i].fn = falseFn; - } - // clear all listeners for a type if function isn't specified - delete this._events[type]; - return; - } - - if (context === this) { - context = undefined; - } - - if (listeners) { - - // find fn and remove it - for (i = 0, len = listeners.length; i < len; i++) { - var l = listeners[i]; - if (l.ctx !== context) { continue; } - if (l.fn === fn) { - - // set the removed listener to noop so that's not called if remove happens in fire - l.fn = falseFn; - - if (this._firingCount) { - /* copy array in case events are being fired */ - this._events[type] = listeners = listeners.slice(); - } - listeners.splice(i, 1); - - return; - } - } - } - }, - - // @method fire(type: String, data?: Object, propagate?: Boolean): this - // Fires an event of the specified type. You can optionally provide an data - // object — the first argument of the listener function will contain its - // properties. The event can optionally be propagated to event parents. - fire: function (type, data, propagate) { - if (!this.listens(type, propagate)) { return this; } - - var event = extend({}, data, { - type: type, - target: this, - sourceTarget: data && data.sourceTarget || this - }); - - if (this._events) { - var listeners = this._events[type]; - - if (listeners) { - this._firingCount = (this._firingCount + 1) || 1; - for (var i = 0, len = listeners.length; i < len; i++) { - var l = listeners[i]; - l.fn.call(l.ctx || this, event); - } - - this._firingCount--; - } - } - - if (propagate) { - // propagate the event to parents (set with addEventParent) - this._propagateEvent(event); - } - - return this; - }, - - // @method listens(type: String): Boolean - // Returns `true` if a particular event type has any listeners attached to it. - listens: function (type, propagate) { - var listeners = this._events && this._events[type]; - if (listeners && listeners.length) { return true; } - - if (propagate) { - // also check parents for listeners if event propagates - for (var id in this._eventParents) { - if (this._eventParents[id].listens(type, propagate)) { return true; } - } - } - return false; - }, - - // @method once(…): this - // Behaves as [`on(…)`](#evented-on), except the listener will only get fired once and then removed. - once: function (types, fn, context) { - - if (typeof types === 'object') { - for (var type in types) { - this.once(type, types[type], fn); - } - return this; - } - - var handler = bind(function () { - this - .off(types, fn, context) - .off(types, handler, context); - }, this); - - // add a listener that's executed once and removed after that - return this - .on(types, fn, context) - .on(types, handler, context); - }, - - // @method addEventParent(obj: Evented): this - // Adds an event parent - an `Evented` that will receive propagated events - addEventParent: function (obj) { - this._eventParents = this._eventParents || {}; - this._eventParents[stamp(obj)] = obj; - return this; - }, - - // @method removeEventParent(obj: Evented): this - // Removes an event parent, so it will stop receiving propagated events - removeEventParent: function (obj) { - if (this._eventParents) { - delete this._eventParents[stamp(obj)]; - } - return this; - }, - - _propagateEvent: function (e) { - for (var id in this._eventParents) { - this._eventParents[id].fire(e.type, extend({ - layer: e.target, - propagatedFrom: e.target - }, e), true); - } - } -}; - -// aliases; we should ditch those eventually - -// @method addEventListener(…): this -// Alias to [`on(…)`](#evented-on) -Events.addEventListener = Events.on; - -// @method removeEventListener(…): this -// Alias to [`off(…)`](#evented-off) - -// @method clearAllEventListeners(…): this -// Alias to [`off()`](#evented-off) -Events.removeEventListener = Events.clearAllEventListeners = Events.off; - -// @method addOneTimeEventListener(…): this -// Alias to [`once(…)`](#evented-once) -Events.addOneTimeEventListener = Events.once; - -// @method fireEvent(…): this -// Alias to [`fire(…)`](#evented-fire) -Events.fireEvent = Events.fire; - -// @method hasEventListeners(…): Boolean -// Alias to [`listens(…)`](#evented-listens) -Events.hasEventListeners = Events.listens; - +/* + * @class Evented + * @aka L.Evented + * @inherits Class + * + * A set of methods shared between event-powered classes (like `Map` and `Marker`). Generally, events allow you to execute some function when something happens with an object (e.g. the user clicks on the map, causing the map to fire `'click'` event). + * + * @example + * + * ```js + * map.on('click', function(e) { + * alert(e.latlng); + * } ); + * ``` + * + * Leaflet deals with event listeners by reference, so if you want to add a listener and then remove it, define it as a function: + * + * ```js + * function onClick(e) { ... } + * + * map.on('click', onClick); + * map.off('click', onClick); + * ``` + */ + +var Events = { + /* @method on(type: String, fn: Function, context?: Object): this + * Adds a listener function (`fn`) to a particular event type of the object. You can optionally specify the context of the listener (object the this keyword will point to). You can also pass several space-separated types (e.g. `'click dblclick'`). + * + * @alternative + * @method on(eventMap: Object): this + * Adds a set of type/listener pairs, e.g. `{click: onClick, mousemove: onMouseMove}` + */ + on: function (types, fn, context) { + + // types can be a map of types/handlers + if (typeof types === 'object') { + for (var type in types) { + // we don't process space-separated events here for performance; + // it's a hot path since Layer uses the on(obj) syntax + this._on(type, types[type], fn); + } + + } else { + // types can be a string of space-separated words + types = splitWords(types); + + for (var i = 0, len = types.length; i < len; i++) { + this._on(types[i], fn, context); + } + } + + return this; + }, + + /* @method off(type: String, fn?: Function, context?: Object): this + * Removes a previously added listener function. If no function is specified, it will remove all the listeners of that particular event from the object. Note that if you passed a custom context to `on`, you must pass the same context to `off` in order to remove the listener. + * + * @alternative + * @method off(eventMap: Object): this + * Removes a set of type/listener pairs. + * + * @alternative + * @method off: this + * Removes all listeners to all events on the object. This includes implicitly attached events. + */ + off: function (types, fn, context) { + + if (!types) { + // clear all listeners if called without arguments + delete this._events; + + } else if (typeof types === 'object') { + for (var type in types) { + this._off(type, types[type], fn); + } + + } else { + types = splitWords(types); + + for (var i = 0, len = types.length; i < len; i++) { + this._off(types[i], fn, context); + } + } + + return this; + }, + + // attach listener (without syntactic sugar now) + _on: function (type, fn, context) { + this._events = this._events || {}; + + /* get/init listeners for type */ + var typeListeners = this._events[type]; + if (!typeListeners) { + typeListeners = []; + this._events[type] = typeListeners; + } + + if (context === this) { + // Less memory footprint. + context = undefined; + } + var newListener = {fn: fn, ctx: context}, + listeners = typeListeners; + + // check if fn already there + for (var i = 0, len = listeners.length; i < len; i++) { + if (listeners[i].fn === fn && listeners[i].ctx === context) { + return; + } + } + + listeners.push(newListener); + }, + + _off: function (type, fn, context) { + var listeners, + i, + len; + + if (!this._events) { return; } + + listeners = this._events[type]; + + if (!listeners) { + return; + } + + if (!fn) { + // Set all removed listeners to noop so they are not called if remove happens in fire + for (i = 0, len = listeners.length; i < len; i++) { + listeners[i].fn = falseFn; + } + // clear all listeners for a type if function isn't specified + delete this._events[type]; + return; + } + + if (context === this) { + context = undefined; + } + + if (listeners) { + + // find fn and remove it + for (i = 0, len = listeners.length; i < len; i++) { + var l = listeners[i]; + if (l.ctx !== context) { continue; } + if (l.fn === fn) { + + // set the removed listener to noop so that's not called if remove happens in fire + l.fn = falseFn; + + if (this._firingCount) { + /* copy array in case events are being fired */ + this._events[type] = listeners = listeners.slice(); + } + listeners.splice(i, 1); + + return; + } + } + } + }, + + // @method fire(type: String, data?: Object, propagate?: Boolean): this + // Fires an event of the specified type. You can optionally provide an data + // object — the first argument of the listener function will contain its + // properties. The event can optionally be propagated to event parents. + fire: function (type, data, propagate) { + if (!this.listens(type, propagate)) { return this; } + + var event = extend({}, data, { + type: type, + target: this, + sourceTarget: data && data.sourceTarget || this + }); + + if (this._events) { + var listeners = this._events[type]; + + if (listeners) { + this._firingCount = (this._firingCount + 1) || 1; + for (var i = 0, len = listeners.length; i < len; i++) { + var l = listeners[i]; + l.fn.call(l.ctx || this, event); + } + + this._firingCount--; + } + } + + if (propagate) { + // propagate the event to parents (set with addEventParent) + this._propagateEvent(event); + } + + return this; + }, + + // @method listens(type: String): Boolean + // Returns `true` if a particular event type has any listeners attached to it. + listens: function (type, propagate) { + var listeners = this._events && this._events[type]; + if (listeners && listeners.length) { return true; } + + if (propagate) { + // also check parents for listeners if event propagates + for (var id in this._eventParents) { + if (this._eventParents[id].listens(type, propagate)) { return true; } + } + } + return false; + }, + + // @method once(…): this + // Behaves as [`on(…)`](#evented-on), except the listener will only get fired once and then removed. + once: function (types, fn, context) { + + if (typeof types === 'object') { + for (var type in types) { + this.once(type, types[type], fn); + } + return this; + } + + var handler = bind(function () { + this + .off(types, fn, context) + .off(types, handler, context); + }, this); + + // add a listener that's executed once and removed after that + return this + .on(types, fn, context) + .on(types, handler, context); + }, + + // @method addEventParent(obj: Evented): this + // Adds an event parent - an `Evented` that will receive propagated events + addEventParent: function (obj) { + this._eventParents = this._eventParents || {}; + this._eventParents[stamp(obj)] = obj; + return this; + }, + + // @method removeEventParent(obj: Evented): this + // Removes an event parent, so it will stop receiving propagated events + removeEventParent: function (obj) { + if (this._eventParents) { + delete this._eventParents[stamp(obj)]; + } + return this; + }, + + _propagateEvent: function (e) { + for (var id in this._eventParents) { + this._eventParents[id].fire(e.type, extend({ + layer: e.target, + propagatedFrom: e.target + }, e), true); + } + } +}; + +// aliases; we should ditch those eventually + +// @method addEventListener(…): this +// Alias to [`on(…)`](#evented-on) +Events.addEventListener = Events.on; + +// @method removeEventListener(…): this +// Alias to [`off(…)`](#evented-off) + +// @method clearAllEventListeners(…): this +// Alias to [`off()`](#evented-off) +Events.removeEventListener = Events.clearAllEventListeners = Events.off; + +// @method addOneTimeEventListener(…): this +// Alias to [`once(…)`](#evented-once) +Events.addOneTimeEventListener = Events.once; + +// @method fireEvent(…): this +// Alias to [`fire(…)`](#evented-fire) +Events.fireEvent = Events.fire; + +// @method hasEventListeners(…): Boolean +// Alias to [`listens(…)`](#evented-listens) +Events.hasEventListeners = Events.listens; + var Evented = Class.extend(Events); -/* - * @class Point - * @aka L.Point - * - * Represents a point with `x` and `y` coordinates in pixels. - * - * @example - * - * ```js - * var point = L.point(200, 300); - * ``` - * - * All Leaflet methods and options that accept `Point` objects also accept them in a simple Array form (unless noted otherwise), so these lines are equivalent: - * - * ```js - * map.panBy([200, 300]); - * map.panBy(L.point(200, 300)); - * ``` - * - * Note that `Point` does not inherit from Leaflet's `Class` object, - * which means new classes can't inherit from it, and new methods - * can't be added to it with the `include` function. - */ - -function Point(x, y, round) { - // @property x: Number; The `x` coordinate of the point - this.x = (round ? Math.round(x) : x); - // @property y: Number; The `y` coordinate of the point - this.y = (round ? Math.round(y) : y); +/* + * @class Point + * @aka L.Point + * + * Represents a point with `x` and `y` coordinates in pixels. + * + * @example + * + * ```js + * var point = L.point(200, 300); + * ``` + * + * All Leaflet methods and options that accept `Point` objects also accept them in a simple Array form (unless noted otherwise), so these lines are equivalent: + * + * ```js + * map.panBy([200, 300]); + * map.panBy(L.point(200, 300)); + * ``` + * + * Note that `Point` does not inherit from Leaflet's `Class` object, + * which means new classes can't inherit from it, and new methods + * can't be added to it with the `include` function. + */ + +function Point(x, y, round) { + // @property x: Number; The `x` coordinate of the point + this.x = (round ? Math.round(x) : x); + // @property y: Number; The `y` coordinate of the point + this.y = (round ? Math.round(y) : y); +} + +var trunc = Math.trunc || function (v) { + return v > 0 ? Math.floor(v) : Math.ceil(v); +}; + +Point.prototype = { + + // @method clone(): Point + // Returns a copy of the current point. + clone: function () { + return new Point(this.x, this.y); + }, + + // @method add(otherPoint: Point): Point + // Returns the result of addition of the current and the given points. + add: function (point) { + // non-destructive, returns a new point + return this.clone()._add(toPoint(point)); + }, + + _add: function (point) { + // destructive, used directly for performance in situations where it's safe to modify existing point + this.x += point.x; + this.y += point.y; + return this; + }, + + // @method subtract(otherPoint: Point): Point + // Returns the result of subtraction of the given point from the current. + subtract: function (point) { + return this.clone()._subtract(toPoint(point)); + }, + + _subtract: function (point) { + this.x -= point.x; + this.y -= point.y; + return this; + }, + + // @method divideBy(num: Number): Point + // Returns the result of division of the current point by the given number. + divideBy: function (num) { + return this.clone()._divideBy(num); + }, + + _divideBy: function (num) { + this.x /= num; + this.y /= num; + return this; + }, + + // @method multiplyBy(num: Number): Point + // Returns the result of multiplication of the current point by the given number. + multiplyBy: function (num) { + return this.clone()._multiplyBy(num); + }, + + _multiplyBy: function (num) { + this.x *= num; + this.y *= num; + return this; + }, + + // @method scaleBy(scale: Point): Point + // Multiply each coordinate of the current point by each coordinate of + // `scale`. In linear algebra terms, multiply the point by the + // [scaling matrix](https://en.wikipedia.org/wiki/Scaling_%28geometry%29#Matrix_representation) + // defined by `scale`. + scaleBy: function (point) { + return new Point(this.x * point.x, this.y * point.y); + }, + + // @method unscaleBy(scale: Point): Point + // Inverse of `scaleBy`. Divide each coordinate of the current point by + // each coordinate of `scale`. + unscaleBy: function (point) { + return new Point(this.x / point.x, this.y / point.y); + }, + + // @method round(): Point + // Returns a copy of the current point with rounded coordinates. + round: function () { + return this.clone()._round(); + }, + + _round: function () { + this.x = Math.round(this.x); + this.y = Math.round(this.y); + return this; + }, + + // @method floor(): Point + // Returns a copy of the current point with floored coordinates (rounded down). + floor: function () { + return this.clone()._floor(); + }, + + _floor: function () { + this.x = Math.floor(this.x); + this.y = Math.floor(this.y); + return this; + }, + + // @method ceil(): Point + // Returns a copy of the current point with ceiled coordinates (rounded up). + ceil: function () { + return this.clone()._ceil(); + }, + + _ceil: function () { + this.x = Math.ceil(this.x); + this.y = Math.ceil(this.y); + return this; + }, + + // @method trunc(): Point + // Returns a copy of the current point with truncated coordinates (rounded towards zero). + trunc: function () { + return this.clone()._trunc(); + }, + + _trunc: function () { + this.x = trunc(this.x); + this.y = trunc(this.y); + return this; + }, + + // @method distanceTo(otherPoint: Point): Number + // Returns the cartesian distance between the current and the given points. + distanceTo: function (point) { + point = toPoint(point); + + var x = point.x - this.x, + y = point.y - this.y; + + return Math.sqrt(x * x + y * y); + }, + + // @method equals(otherPoint: Point): Boolean + // Returns `true` if the given point has the same coordinates. + equals: function (point) { + point = toPoint(point); + + return point.x === this.x && + point.y === this.y; + }, + + // @method contains(otherPoint: Point): Boolean + // Returns `true` if both coordinates of the given point are less than the corresponding current point coordinates (in absolute values). + contains: function (point) { + point = toPoint(point); + + return Math.abs(point.x) <= Math.abs(this.x) && + Math.abs(point.y) <= Math.abs(this.y); + }, + + // @method toString(): String + // Returns a string representation of the point for debugging purposes. + toString: function () { + return 'Point(' + + formatNum(this.x) + ', ' + + formatNum(this.y) + ')'; + } +}; + +// @factory L.point(x: Number, y: Number, round?: Boolean) +// Creates a Point object with the given `x` and `y` coordinates. If optional `round` is set to true, rounds the `x` and `y` values. + +// @alternative +// @factory L.point(coords: Number[]) +// Expects an array of the form `[x, y]` instead. + +// @alternative +// @factory L.point(coords: Object) +// Expects a plain object of the form `{x: Number, y: Number}` instead. +function toPoint(x, y, round) { + if (x instanceof Point) { + return x; + } + if (isArray(x)) { + return new Point(x[0], x[1]); + } + if (x === undefined || x === null) { + return x; + } + if (typeof x === 'object' && 'x' in x && 'y' in x) { + return new Point(x.x, x.y); + } + return new Point(x, y, round); } -var trunc = Math.trunc || function (v) { - return v > 0 ? Math.floor(v) : Math.ceil(v); -}; - -Point.prototype = { - - // @method clone(): Point - // Returns a copy of the current point. - clone: function () { - return new Point(this.x, this.y); - }, - - // @method add(otherPoint: Point): Point - // Returns the result of addition of the current and the given points. - add: function (point) { - // non-destructive, returns a new point - return this.clone()._add(toPoint(point)); - }, - - _add: function (point) { - // destructive, used directly for performance in situations where it's safe to modify existing point - this.x += point.x; - this.y += point.y; - return this; - }, - - // @method subtract(otherPoint: Point): Point - // Returns the result of subtraction of the given point from the current. - subtract: function (point) { - return this.clone()._subtract(toPoint(point)); - }, - - _subtract: function (point) { - this.x -= point.x; - this.y -= point.y; - return this; - }, - - // @method divideBy(num: Number): Point - // Returns the result of division of the current point by the given number. - divideBy: function (num) { - return this.clone()._divideBy(num); - }, - - _divideBy: function (num) { - this.x /= num; - this.y /= num; - return this; - }, - - // @method multiplyBy(num: Number): Point - // Returns the result of multiplication of the current point by the given number. - multiplyBy: function (num) { - return this.clone()._multiplyBy(num); - }, - - _multiplyBy: function (num) { - this.x *= num; - this.y *= num; - return this; - }, - - // @method scaleBy(scale: Point): Point - // Multiply each coordinate of the current point by each coordinate of - // `scale`. In linear algebra terms, multiply the point by the - // [scaling matrix](https://en.wikipedia.org/wiki/Scaling_%28geometry%29#Matrix_representation) - // defined by `scale`. - scaleBy: function (point) { - return new Point(this.x * point.x, this.y * point.y); - }, - - // @method unscaleBy(scale: Point): Point - // Inverse of `scaleBy`. Divide each coordinate of the current point by - // each coordinate of `scale`. - unscaleBy: function (point) { - return new Point(this.x / point.x, this.y / point.y); - }, - - // @method round(): Point - // Returns a copy of the current point with rounded coordinates. - round: function () { - return this.clone()._round(); - }, - - _round: function () { - this.x = Math.round(this.x); - this.y = Math.round(this.y); - return this; - }, - - // @method floor(): Point - // Returns a copy of the current point with floored coordinates (rounded down). - floor: function () { - return this.clone()._floor(); - }, - - _floor: function () { - this.x = Math.floor(this.x); - this.y = Math.floor(this.y); - return this; - }, - - // @method ceil(): Point - // Returns a copy of the current point with ceiled coordinates (rounded up). - ceil: function () { - return this.clone()._ceil(); - }, - - _ceil: function () { - this.x = Math.ceil(this.x); - this.y = Math.ceil(this.y); - return this; - }, - - // @method trunc(): Point - // Returns a copy of the current point with truncated coordinates (rounded towards zero). - trunc: function () { - return this.clone()._trunc(); - }, - - _trunc: function () { - this.x = trunc(this.x); - this.y = trunc(this.y); - return this; - }, - - // @method distanceTo(otherPoint: Point): Number - // Returns the cartesian distance between the current and the given points. - distanceTo: function (point) { - point = toPoint(point); - - var x = point.x - this.x, - y = point.y - this.y; - - return Math.sqrt(x * x + y * y); - }, - - // @method equals(otherPoint: Point): Boolean - // Returns `true` if the given point has the same coordinates. - equals: function (point) { - point = toPoint(point); - - return point.x === this.x && - point.y === this.y; - }, - - // @method contains(otherPoint: Point): Boolean - // Returns `true` if both coordinates of the given point are less than the corresponding current point coordinates (in absolute values). - contains: function (point) { - point = toPoint(point); - - return Math.abs(point.x) <= Math.abs(this.x) && - Math.abs(point.y) <= Math.abs(this.y); - }, - - // @method toString(): String - // Returns a string representation of the point for debugging purposes. - toString: function () { - return 'Point(' + - formatNum(this.x) + ', ' + - formatNum(this.y) + ')'; - } -}; - -// @factory L.point(x: Number, y: Number, round?: Boolean) -// Creates a Point object with the given `x` and `y` coordinates. If optional `round` is set to true, rounds the `x` and `y` values. - -// @alternative -// @factory L.point(coords: Number[]) -// Expects an array of the form `[x, y]` instead. - -// @alternative -// @factory L.point(coords: Object) -// Expects a plain object of the form `{x: Number, y: Number}` instead. -function toPoint(x, y, round) { - if (x instanceof Point) { - return x; - } - if (isArray(x)) { - return new Point(x[0], x[1]); - } - if (x === undefined || x === null) { - return x; - } - if (typeof x === 'object' && 'x' in x && 'y' in x) { - return new Point(x.x, x.y); - } - return new Point(x, y, round); +/* + * @class Bounds + * @aka L.Bounds + * + * Represents a rectangular area in pixel coordinates. + * + * @example + * + * ```js + * var p1 = L.point(10, 10), + * p2 = L.point(40, 60), + * bounds = L.bounds(p1, p2); + * ``` + * + * All Leaflet methods that accept `Bounds` objects also accept them in a simple Array form (unless noted otherwise), so the bounds example above can be passed like this: + * + * ```js + * otherBounds.intersects([[10, 10], [40, 60]]); + * ``` + * + * Note that `Bounds` does not inherit from Leaflet's `Class` object, + * which means new classes can't inherit from it, and new methods + * can't be added to it with the `include` function. + */ + +function Bounds(a, b) { + if (!a) { return; } + + var points = b ? [a, b] : a; + + for (var i = 0, len = points.length; i < len; i++) { + this.extend(points[i]); + } +} + +Bounds.prototype = { + // @method extend(point: Point): this + // Extends the bounds to contain the given point. + extend: function (point) { // (Point) + point = toPoint(point); + + // @property min: Point + // The top left corner of the rectangle. + // @property max: Point + // The bottom right corner of the rectangle. + if (!this.min && !this.max) { + this.min = point.clone(); + this.max = point.clone(); + } else { + this.min.x = Math.min(point.x, this.min.x); + this.max.x = Math.max(point.x, this.max.x); + this.min.y = Math.min(point.y, this.min.y); + this.max.y = Math.max(point.y, this.max.y); + } + return this; + }, + + // @method getCenter(round?: Boolean): Point + // Returns the center point of the bounds. + getCenter: function (round) { + return new Point( + (this.min.x + this.max.x) / 2, + (this.min.y + this.max.y) / 2, round); + }, + + // @method getBottomLeft(): Point + // Returns the bottom-left point of the bounds. + getBottomLeft: function () { + return new Point(this.min.x, this.max.y); + }, + + // @method getTopRight(): Point + // Returns the top-right point of the bounds. + getTopRight: function () { // -> Point + return new Point(this.max.x, this.min.y); + }, + + // @method getTopLeft(): Point + // Returns the top-left point of the bounds (i.e. [`this.min`](#bounds-min)). + getTopLeft: function () { + return this.min; // left, top + }, + + // @method getBottomRight(): Point + // Returns the bottom-right point of the bounds (i.e. [`this.max`](#bounds-max)). + getBottomRight: function () { + return this.max; // right, bottom + }, + + // @method getSize(): Point + // Returns the size of the given bounds + getSize: function () { + return this.max.subtract(this.min); + }, + + // @method contains(otherBounds: Bounds): Boolean + // Returns `true` if the rectangle contains the given one. + // @alternative + // @method contains(point: Point): Boolean + // Returns `true` if the rectangle contains the given point. + contains: function (obj) { + var min, max; + + if (typeof obj[0] === 'number' || obj instanceof Point) { + obj = toPoint(obj); + } else { + obj = toBounds(obj); + } + + if (obj instanceof Bounds) { + min = obj.min; + max = obj.max; + } else { + min = max = obj; + } + + return (min.x >= this.min.x) && + (max.x <= this.max.x) && + (min.y >= this.min.y) && + (max.y <= this.max.y); + }, + + // @method intersects(otherBounds: Bounds): Boolean + // Returns `true` if the rectangle intersects the given bounds. Two bounds + // intersect if they have at least one point in common. + intersects: function (bounds) { // (Bounds) -> Boolean + bounds = toBounds(bounds); + + var min = this.min, + max = this.max, + min2 = bounds.min, + max2 = bounds.max, + xIntersects = (max2.x >= min.x) && (min2.x <= max.x), + yIntersects = (max2.y >= min.y) && (min2.y <= max.y); + + return xIntersects && yIntersects; + }, + + // @method overlaps(otherBounds: Bounds): Boolean + // Returns `true` if the rectangle overlaps the given bounds. Two bounds + // overlap if their intersection is an area. + overlaps: function (bounds) { // (Bounds) -> Boolean + bounds = toBounds(bounds); + + var min = this.min, + max = this.max, + min2 = bounds.min, + max2 = bounds.max, + xOverlaps = (max2.x > min.x) && (min2.x < max.x), + yOverlaps = (max2.y > min.y) && (min2.y < max.y); + + return xOverlaps && yOverlaps; + }, + + isValid: function () { + return !!(this.min && this.max); + } +}; + + +// @factory L.bounds(corner1: Point, corner2: Point) +// Creates a Bounds object from two corners coordinate pairs. +// @alternative +// @factory L.bounds(points: Point[]) +// Creates a Bounds object from the given array of points. +function toBounds(a, b) { + if (!a || a instanceof Bounds) { + return a; + } + return new Bounds(a, b); } -/* - * @class Bounds - * @aka L.Bounds - * - * Represents a rectangular area in pixel coordinates. - * - * @example - * - * ```js - * var p1 = L.point(10, 10), - * p2 = L.point(40, 60), - * bounds = L.bounds(p1, p2); - * ``` - * - * All Leaflet methods that accept `Bounds` objects also accept them in a simple Array form (unless noted otherwise), so the bounds example above can be passed like this: - * - * ```js - * otherBounds.intersects([[10, 10], [40, 60]]); - * ``` - * - * Note that `Bounds` does not inherit from Leaflet's `Class` object, - * which means new classes can't inherit from it, and new methods - * can't be added to it with the `include` function. - */ - -function Bounds(a, b) { - if (!a) { return; } - - var points = b ? [a, b] : a; - - for (var i = 0, len = points.length; i < len; i++) { - this.extend(points[i]); - } +/* + * @class LatLngBounds + * @aka L.LatLngBounds + * + * Represents a rectangular geographical area on a map. + * + * @example + * + * ```js + * var corner1 = L.latLng(40.712, -74.227), + * corner2 = L.latLng(40.774, -74.125), + * bounds = L.latLngBounds(corner1, corner2); + * ``` + * + * All Leaflet methods that accept LatLngBounds objects also accept them in a simple Array form (unless noted otherwise), so the bounds example above can be passed like this: + * + * ```js + * map.fitBounds([ + * [40.712, -74.227], + * [40.774, -74.125] + * ]); + * ``` + * + * Caution: if the area crosses the antimeridian (often confused with the International Date Line), you must specify corners _outside_ the [-180, 180] degrees longitude range. + * + * Note that `LatLngBounds` does not inherit from Leaflet's `Class` object, + * which means new classes can't inherit from it, and new methods + * can't be added to it with the `include` function. + */ + +function LatLngBounds(corner1, corner2) { // (LatLng, LatLng) or (LatLng[]) + if (!corner1) { return; } + + var latlngs = corner2 ? [corner1, corner2] : corner1; + + for (var i = 0, len = latlngs.length; i < len; i++) { + this.extend(latlngs[i]); + } +} + +LatLngBounds.prototype = { + + // @method extend(latlng: LatLng): this + // Extend the bounds to contain the given point + + // @alternative + // @method extend(otherBounds: LatLngBounds): this + // Extend the bounds to contain the given bounds + extend: function (obj) { + var sw = this._southWest, + ne = this._northEast, + sw2, ne2; + + if (obj instanceof LatLng) { + sw2 = obj; + ne2 = obj; + + } else if (obj instanceof LatLngBounds) { + sw2 = obj._southWest; + ne2 = obj._northEast; + + if (!sw2 || !ne2) { return this; } + + } else { + return obj ? this.extend(toLatLng(obj) || toLatLngBounds(obj)) : this; + } + + if (!sw && !ne) { + this._southWest = new LatLng(sw2.lat, sw2.lng); + this._northEast = new LatLng(ne2.lat, ne2.lng); + } else { + sw.lat = Math.min(sw2.lat, sw.lat); + sw.lng = Math.min(sw2.lng, sw.lng); + ne.lat = Math.max(ne2.lat, ne.lat); + ne.lng = Math.max(ne2.lng, ne.lng); + } + + return this; + }, + + // @method pad(bufferRatio: Number): LatLngBounds + // Returns bounds created by extending or retracting the current bounds by a given ratio in each direction. + // For example, a ratio of 0.5 extends the bounds by 50% in each direction. + // Negative values will retract the bounds. + pad: function (bufferRatio) { + var sw = this._southWest, + ne = this._northEast, + heightBuffer = Math.abs(sw.lat - ne.lat) * bufferRatio, + widthBuffer = Math.abs(sw.lng - ne.lng) * bufferRatio; + + return new LatLngBounds( + new LatLng(sw.lat - heightBuffer, sw.lng - widthBuffer), + new LatLng(ne.lat + heightBuffer, ne.lng + widthBuffer)); + }, + + // @method getCenter(): LatLng + // Returns the center point of the bounds. + getCenter: function () { + return new LatLng( + (this._southWest.lat + this._northEast.lat) / 2, + (this._southWest.lng + this._northEast.lng) / 2); + }, + + // @method getSouthWest(): LatLng + // Returns the south-west point of the bounds. + getSouthWest: function () { + return this._southWest; + }, + + // @method getNorthEast(): LatLng + // Returns the north-east point of the bounds. + getNorthEast: function () { + return this._northEast; + }, + + // @method getNorthWest(): LatLng + // Returns the north-west point of the bounds. + getNorthWest: function () { + return new LatLng(this.getNorth(), this.getWest()); + }, + + // @method getSouthEast(): LatLng + // Returns the south-east point of the bounds. + getSouthEast: function () { + return new LatLng(this.getSouth(), this.getEast()); + }, + + // @method getWest(): Number + // Returns the west longitude of the bounds + getWest: function () { + return this._southWest.lng; + }, + + // @method getSouth(): Number + // Returns the south latitude of the bounds + getSouth: function () { + return this._southWest.lat; + }, + + // @method getEast(): Number + // Returns the east longitude of the bounds + getEast: function () { + return this._northEast.lng; + }, + + // @method getNorth(): Number + // Returns the north latitude of the bounds + getNorth: function () { + return this._northEast.lat; + }, + + // @method contains(otherBounds: LatLngBounds): Boolean + // Returns `true` if the rectangle contains the given one. + + // @alternative + // @method contains (latlng: LatLng): Boolean + // Returns `true` if the rectangle contains the given point. + contains: function (obj) { // (LatLngBounds) or (LatLng) -> Boolean + if (typeof obj[0] === 'number' || obj instanceof LatLng || 'lat' in obj) { + obj = toLatLng(obj); + } else { + obj = toLatLngBounds(obj); + } + + var sw = this._southWest, + ne = this._northEast, + sw2, ne2; + + if (obj instanceof LatLngBounds) { + sw2 = obj.getSouthWest(); + ne2 = obj.getNorthEast(); + } else { + sw2 = ne2 = obj; + } + + return (sw2.lat >= sw.lat) && (ne2.lat <= ne.lat) && + (sw2.lng >= sw.lng) && (ne2.lng <= ne.lng); + }, + + // @method intersects(otherBounds: LatLngBounds): Boolean + // Returns `true` if the rectangle intersects the given bounds. Two bounds intersect if they have at least one point in common. + intersects: function (bounds) { + bounds = toLatLngBounds(bounds); + + var sw = this._southWest, + ne = this._northEast, + sw2 = bounds.getSouthWest(), + ne2 = bounds.getNorthEast(), + + latIntersects = (ne2.lat >= sw.lat) && (sw2.lat <= ne.lat), + lngIntersects = (ne2.lng >= sw.lng) && (sw2.lng <= ne.lng); + + return latIntersects && lngIntersects; + }, + + // @method overlaps(otherBounds: LatLngBounds): Boolean + // Returns `true` if the rectangle overlaps the given bounds. Two bounds overlap if their intersection is an area. + overlaps: function (bounds) { + bounds = toLatLngBounds(bounds); + + var sw = this._southWest, + ne = this._northEast, + sw2 = bounds.getSouthWest(), + ne2 = bounds.getNorthEast(), + + latOverlaps = (ne2.lat > sw.lat) && (sw2.lat < ne.lat), + lngOverlaps = (ne2.lng > sw.lng) && (sw2.lng < ne.lng); + + return latOverlaps && lngOverlaps; + }, + + // @method toBBoxString(): String + // Returns a string with bounding box coordinates in a 'southwest_lng,southwest_lat,northeast_lng,northeast_lat' format. Useful for sending requests to web services that return geo data. + toBBoxString: function () { + return [this.getWest(), this.getSouth(), this.getEast(), this.getNorth()].join(','); + }, + + // @method equals(otherBounds: LatLngBounds, maxMargin?: Number): Boolean + // Returns `true` if the rectangle is equivalent (within a small margin of error) to the given bounds. The margin of error can be overridden by setting `maxMargin` to a small number. + equals: function (bounds, maxMargin) { + if (!bounds) { return false; } + + bounds = toLatLngBounds(bounds); + + return this._southWest.equals(bounds.getSouthWest(), maxMargin) && + this._northEast.equals(bounds.getNorthEast(), maxMargin); + }, + + // @method isValid(): Boolean + // Returns `true` if the bounds are properly initialized. + isValid: function () { + return !!(this._southWest && this._northEast); + } +}; + +// TODO International date line? + +// @factory L.latLngBounds(corner1: LatLng, corner2: LatLng) +// Creates a `LatLngBounds` object by defining two diagonally opposite corners of the rectangle. + +// @alternative +// @factory L.latLngBounds(latlngs: LatLng[]) +// Creates a `LatLngBounds` object defined by the geographical points it contains. Very useful for zooming the map to fit a particular set of locations with [`fitBounds`](#map-fitbounds). +function toLatLngBounds(a, b) { + if (a instanceof LatLngBounds) { + return a; + } + return new LatLngBounds(a, b); } -Bounds.prototype = { - // @method extend(point: Point): this - // Extends the bounds to contain the given point. - extend: function (point) { // (Point) - point = toPoint(point); - - // @property min: Point - // The top left corner of the rectangle. - // @property max: Point - // The bottom right corner of the rectangle. - if (!this.min && !this.max) { - this.min = point.clone(); - this.max = point.clone(); - } else { - this.min.x = Math.min(point.x, this.min.x); - this.max.x = Math.max(point.x, this.max.x); - this.min.y = Math.min(point.y, this.min.y); - this.max.y = Math.max(point.y, this.max.y); - } - return this; - }, - - // @method getCenter(round?: Boolean): Point - // Returns the center point of the bounds. - getCenter: function (round) { - return new Point( - (this.min.x + this.max.x) / 2, - (this.min.y + this.max.y) / 2, round); - }, - - // @method getBottomLeft(): Point - // Returns the bottom-left point of the bounds. - getBottomLeft: function () { - return new Point(this.min.x, this.max.y); - }, - - // @method getTopRight(): Point - // Returns the top-right point of the bounds. - getTopRight: function () { // -> Point - return new Point(this.max.x, this.min.y); - }, - - // @method getTopLeft(): Point - // Returns the top-left point of the bounds (i.e. [`this.min`](#bounds-min)). - getTopLeft: function () { - return this.min; // left, top - }, - - // @method getBottomRight(): Point - // Returns the bottom-right point of the bounds (i.e. [`this.max`](#bounds-max)). - getBottomRight: function () { - return this.max; // right, bottom - }, - - // @method getSize(): Point - // Returns the size of the given bounds - getSize: function () { - return this.max.subtract(this.min); - }, - - // @method contains(otherBounds: Bounds): Boolean - // Returns `true` if the rectangle contains the given one. - // @alternative - // @method contains(point: Point): Boolean - // Returns `true` if the rectangle contains the given point. - contains: function (obj) { - var min, max; - - if (typeof obj[0] === 'number' || obj instanceof Point) { - obj = toPoint(obj); - } else { - obj = toBounds(obj); - } - - if (obj instanceof Bounds) { - min = obj.min; - max = obj.max; - } else { - min = max = obj; - } - - return (min.x >= this.min.x) && - (max.x <= this.max.x) && - (min.y >= this.min.y) && - (max.y <= this.max.y); - }, - - // @method intersects(otherBounds: Bounds): Boolean - // Returns `true` if the rectangle intersects the given bounds. Two bounds - // intersect if they have at least one point in common. - intersects: function (bounds) { // (Bounds) -> Boolean - bounds = toBounds(bounds); - - var min = this.min, - max = this.max, - min2 = bounds.min, - max2 = bounds.max, - xIntersects = (max2.x >= min.x) && (min2.x <= max.x), - yIntersects = (max2.y >= min.y) && (min2.y <= max.y); - - return xIntersects && yIntersects; - }, - - // @method overlaps(otherBounds: Bounds): Boolean - // Returns `true` if the rectangle overlaps the given bounds. Two bounds - // overlap if their intersection is an area. - overlaps: function (bounds) { // (Bounds) -> Boolean - bounds = toBounds(bounds); - - var min = this.min, - max = this.max, - min2 = bounds.min, - max2 = bounds.max, - xOverlaps = (max2.x > min.x) && (min2.x < max.x), - yOverlaps = (max2.y > min.y) && (min2.y < max.y); - - return xOverlaps && yOverlaps; - }, - - isValid: function () { - return !!(this.min && this.max); - } -}; - - -// @factory L.bounds(corner1: Point, corner2: Point) -// Creates a Bounds object from two corners coordinate pairs. -// @alternative -// @factory L.bounds(points: Point[]) -// Creates a Bounds object from the given array of points. -function toBounds(a, b) { - if (!a || a instanceof Bounds) { - return a; - } - return new Bounds(a, b); +/* @class LatLng + * @aka L.LatLng + * + * Represents a geographical point with a certain latitude and longitude. + * + * @example + * + * ``` + * var latlng = L.latLng(50.5, 30.5); + * ``` + * + * All Leaflet methods that accept LatLng objects also accept them in a simple Array form and simple object form (unless noted otherwise), so these lines are equivalent: + * + * ``` + * map.panTo([50, 30]); + * map.panTo({lon: 30, lat: 50}); + * map.panTo({lat: 50, lng: 30}); + * map.panTo(L.latLng(50, 30)); + * ``` + * + * Note that `LatLng` does not inherit from Leaflet's `Class` object, + * which means new classes can't inherit from it, and new methods + * can't be added to it with the `include` function. + */ + +function LatLng(lat, lng, alt) { + if (isNaN(lat) || isNaN(lng)) { + throw new Error('Invalid LatLng object: (' + lat + ', ' + lng + ')'); + } + + // @property lat: Number + // Latitude in degrees + this.lat = +lat; + + // @property lng: Number + // Longitude in degrees + this.lng = +lng; + + // @property alt: Number + // Altitude in meters (optional) + if (alt !== undefined) { + this.alt = +alt; + } +} + +LatLng.prototype = { + // @method equals(otherLatLng: LatLng, maxMargin?: Number): Boolean + // Returns `true` if the given `LatLng` point is at the same position (within a small margin of error). The margin of error can be overridden by setting `maxMargin` to a small number. + equals: function (obj, maxMargin) { + if (!obj) { return false; } + + obj = toLatLng(obj); + + var margin = Math.max( + Math.abs(this.lat - obj.lat), + Math.abs(this.lng - obj.lng)); + + return margin <= (maxMargin === undefined ? 1.0E-9 : maxMargin); + }, + + // @method toString(): String + // Returns a string representation of the point (for debugging purposes). + toString: function (precision) { + return 'LatLng(' + + formatNum(this.lat, precision) + ', ' + + formatNum(this.lng, precision) + ')'; + }, + + // @method distanceTo(otherLatLng: LatLng): Number + // Returns the distance (in meters) to the given `LatLng` calculated using the [Spherical Law of Cosines](https://en.wikipedia.org/wiki/Spherical_law_of_cosines). + distanceTo: function (other) { + return Earth.distance(this, toLatLng(other)); + }, + + // @method wrap(): LatLng + // Returns a new `LatLng` object with the longitude wrapped so it's always between -180 and +180 degrees. + wrap: function () { + return Earth.wrapLatLng(this); + }, + + // @method toBounds(sizeInMeters: Number): LatLngBounds + // Returns a new `LatLngBounds` object in which each boundary is `sizeInMeters/2` meters apart from the `LatLng`. + toBounds: function (sizeInMeters) { + var latAccuracy = 180 * sizeInMeters / 40075017, + lngAccuracy = latAccuracy / Math.cos((Math.PI / 180) * this.lat); + + return toLatLngBounds( + [this.lat - latAccuracy, this.lng - lngAccuracy], + [this.lat + latAccuracy, this.lng + lngAccuracy]); + }, + + clone: function () { + return new LatLng(this.lat, this.lng, this.alt); + } +}; + + + +// @factory L.latLng(latitude: Number, longitude: Number, altitude?: Number): LatLng +// Creates an object representing a geographical point with the given latitude and longitude (and optionally altitude). + +// @alternative +// @factory L.latLng(coords: Array): LatLng +// Expects an array of the form `[Number, Number]` or `[Number, Number, Number]` instead. + +// @alternative +// @factory L.latLng(coords: Object): LatLng +// Expects an plain object of the form `{lat: Number, lng: Number}` or `{lat: Number, lng: Number, alt: Number}` instead. + +function toLatLng(a, b, c) { + if (a instanceof LatLng) { + return a; + } + if (isArray(a) && typeof a[0] !== 'object') { + if (a.length === 3) { + return new LatLng(a[0], a[1], a[2]); + } + if (a.length === 2) { + return new LatLng(a[0], a[1]); + } + return null; + } + if (a === undefined || a === null) { + return a; + } + if (typeof a === 'object' && 'lat' in a) { + return new LatLng(a.lat, 'lng' in a ? a.lng : a.lon, a.alt); + } + if (b === undefined) { + return null; + } + return new LatLng(a, b, c); } -/* - * @class LatLngBounds - * @aka L.LatLngBounds - * - * Represents a rectangular geographical area on a map. - * - * @example - * - * ```js - * var corner1 = L.latLng(40.712, -74.227), - * corner2 = L.latLng(40.774, -74.125), - * bounds = L.latLngBounds(corner1, corner2); - * ``` - * - * All Leaflet methods that accept LatLngBounds objects also accept them in a simple Array form (unless noted otherwise), so the bounds example above can be passed like this: - * - * ```js - * map.fitBounds([ - * [40.712, -74.227], - * [40.774, -74.125] - * ]); - * ``` - * - * Caution: if the area crosses the antimeridian (often confused with the International Date Line), you must specify corners _outside_ the [-180, 180] degrees longitude range. - * - * Note that `LatLngBounds` does not inherit from Leaflet's `Class` object, - * which means new classes can't inherit from it, and new methods - * can't be added to it with the `include` function. - */ - -function LatLngBounds(corner1, corner2) { // (LatLng, LatLng) or (LatLng[]) - if (!corner1) { return; } - - var latlngs = corner2 ? [corner1, corner2] : corner1; - - for (var i = 0, len = latlngs.length; i < len; i++) { - this.extend(latlngs[i]); - } -} - -LatLngBounds.prototype = { - - // @method extend(latlng: LatLng): this - // Extend the bounds to contain the given point - - // @alternative - // @method extend(otherBounds: LatLngBounds): this - // Extend the bounds to contain the given bounds - extend: function (obj) { - var sw = this._southWest, - ne = this._northEast, - sw2, ne2; - - if (obj instanceof LatLng) { - sw2 = obj; - ne2 = obj; - - } else if (obj instanceof LatLngBounds) { - sw2 = obj._southWest; - ne2 = obj._northEast; - - if (!sw2 || !ne2) { return this; } - - } else { - return obj ? this.extend(toLatLng(obj) || toLatLngBounds(obj)) : this; - } - - if (!sw && !ne) { - this._southWest = new LatLng(sw2.lat, sw2.lng); - this._northEast = new LatLng(ne2.lat, ne2.lng); - } else { - sw.lat = Math.min(sw2.lat, sw.lat); - sw.lng = Math.min(sw2.lng, sw.lng); - ne.lat = Math.max(ne2.lat, ne.lat); - ne.lng = Math.max(ne2.lng, ne.lng); - } - - return this; - }, - - // @method pad(bufferRatio: Number): LatLngBounds - // Returns bounds created by extending or retracting the current bounds by a given ratio in each direction. - // For example, a ratio of 0.5 extends the bounds by 50% in each direction. - // Negative values will retract the bounds. - pad: function (bufferRatio) { - var sw = this._southWest, - ne = this._northEast, - heightBuffer = Math.abs(sw.lat - ne.lat) * bufferRatio, - widthBuffer = Math.abs(sw.lng - ne.lng) * bufferRatio; - - return new LatLngBounds( - new LatLng(sw.lat - heightBuffer, sw.lng - widthBuffer), - new LatLng(ne.lat + heightBuffer, ne.lng + widthBuffer)); - }, - - // @method getCenter(): LatLng - // Returns the center point of the bounds. - getCenter: function () { - return new LatLng( - (this._southWest.lat + this._northEast.lat) / 2, - (this._southWest.lng + this._northEast.lng) / 2); - }, - - // @method getSouthWest(): LatLng - // Returns the south-west point of the bounds. - getSouthWest: function () { - return this._southWest; - }, - - // @method getNorthEast(): LatLng - // Returns the north-east point of the bounds. - getNorthEast: function () { - return this._northEast; - }, - - // @method getNorthWest(): LatLng - // Returns the north-west point of the bounds. - getNorthWest: function () { - return new LatLng(this.getNorth(), this.getWest()); - }, - - // @method getSouthEast(): LatLng - // Returns the south-east point of the bounds. - getSouthEast: function () { - return new LatLng(this.getSouth(), this.getEast()); - }, - - // @method getWest(): Number - // Returns the west longitude of the bounds - getWest: function () { - return this._southWest.lng; - }, - - // @method getSouth(): Number - // Returns the south latitude of the bounds - getSouth: function () { - return this._southWest.lat; - }, - - // @method getEast(): Number - // Returns the east longitude of the bounds - getEast: function () { - return this._northEast.lng; - }, - - // @method getNorth(): Number - // Returns the north latitude of the bounds - getNorth: function () { - return this._northEast.lat; - }, - - // @method contains(otherBounds: LatLngBounds): Boolean - // Returns `true` if the rectangle contains the given one. - - // @alternative - // @method contains (latlng: LatLng): Boolean - // Returns `true` if the rectangle contains the given point. - contains: function (obj) { // (LatLngBounds) or (LatLng) -> Boolean - if (typeof obj[0] === 'number' || obj instanceof LatLng || 'lat' in obj) { - obj = toLatLng(obj); - } else { - obj = toLatLngBounds(obj); - } - - var sw = this._southWest, - ne = this._northEast, - sw2, ne2; - - if (obj instanceof LatLngBounds) { - sw2 = obj.getSouthWest(); - ne2 = obj.getNorthEast(); - } else { - sw2 = ne2 = obj; - } - - return (sw2.lat >= sw.lat) && (ne2.lat <= ne.lat) && - (sw2.lng >= sw.lng) && (ne2.lng <= ne.lng); - }, - - // @method intersects(otherBounds: LatLngBounds): Boolean - // Returns `true` if the rectangle intersects the given bounds. Two bounds intersect if they have at least one point in common. - intersects: function (bounds) { - bounds = toLatLngBounds(bounds); - - var sw = this._southWest, - ne = this._northEast, - sw2 = bounds.getSouthWest(), - ne2 = bounds.getNorthEast(), - - latIntersects = (ne2.lat >= sw.lat) && (sw2.lat <= ne.lat), - lngIntersects = (ne2.lng >= sw.lng) && (sw2.lng <= ne.lng); - - return latIntersects && lngIntersects; - }, - - // @method overlaps(otherBounds: LatLngBounds): Boolean - // Returns `true` if the rectangle overlaps the given bounds. Two bounds overlap if their intersection is an area. - overlaps: function (bounds) { - bounds = toLatLngBounds(bounds); - - var sw = this._southWest, - ne = this._northEast, - sw2 = bounds.getSouthWest(), - ne2 = bounds.getNorthEast(), - - latOverlaps = (ne2.lat > sw.lat) && (sw2.lat < ne.lat), - lngOverlaps = (ne2.lng > sw.lng) && (sw2.lng < ne.lng); - - return latOverlaps && lngOverlaps; - }, - - // @method toBBoxString(): String - // Returns a string with bounding box coordinates in a 'southwest_lng,southwest_lat,northeast_lng,northeast_lat' format. Useful for sending requests to web services that return geo data. - toBBoxString: function () { - return [this.getWest(), this.getSouth(), this.getEast(), this.getNorth()].join(','); - }, - - // @method equals(otherBounds: LatLngBounds, maxMargin?: Number): Boolean - // Returns `true` if the rectangle is equivalent (within a small margin of error) to the given bounds. The margin of error can be overridden by setting `maxMargin` to a small number. - equals: function (bounds, maxMargin) { - if (!bounds) { return false; } - - bounds = toLatLngBounds(bounds); - - return this._southWest.equals(bounds.getSouthWest(), maxMargin) && - this._northEast.equals(bounds.getNorthEast(), maxMargin); - }, - - // @method isValid(): Boolean - // Returns `true` if the bounds are properly initialized. - isValid: function () { - return !!(this._southWest && this._northEast); - } -}; - -// TODO International date line? - -// @factory L.latLngBounds(corner1: LatLng, corner2: LatLng) -// Creates a `LatLngBounds` object by defining two diagonally opposite corners of the rectangle. - -// @alternative -// @factory L.latLngBounds(latlngs: LatLng[]) -// Creates a `LatLngBounds` object defined by the geographical points it contains. Very useful for zooming the map to fit a particular set of locations with [`fitBounds`](#map-fitbounds). -function toLatLngBounds(a, b) { - if (a instanceof LatLngBounds) { - return a; - } - return new LatLngBounds(a, b); -} - -/* @class LatLng - * @aka L.LatLng - * - * Represents a geographical point with a certain latitude and longitude. - * - * @example - * - * ``` - * var latlng = L.latLng(50.5, 30.5); - * ``` - * - * All Leaflet methods that accept LatLng objects also accept them in a simple Array form and simple object form (unless noted otherwise), so these lines are equivalent: - * - * ``` - * map.panTo([50, 30]); - * map.panTo({lon: 30, lat: 50}); - * map.panTo({lat: 50, lng: 30}); - * map.panTo(L.latLng(50, 30)); - * ``` - * - * Note that `LatLng` does not inherit from Leaflet's `Class` object, - * which means new classes can't inherit from it, and new methods - * can't be added to it with the `include` function. - */ - -function LatLng(lat, lng, alt) { - if (isNaN(lat) || isNaN(lng)) { - throw new Error('Invalid LatLng object: (' + lat + ', ' + lng + ')'); - } - - // @property lat: Number - // Latitude in degrees - this.lat = +lat; - - // @property lng: Number - // Longitude in degrees - this.lng = +lng; - - // @property alt: Number - // Altitude in meters (optional) - if (alt !== undefined) { - this.alt = +alt; - } -} - -LatLng.prototype = { - // @method equals(otherLatLng: LatLng, maxMargin?: Number): Boolean - // Returns `true` if the given `LatLng` point is at the same position (within a small margin of error). The margin of error can be overridden by setting `maxMargin` to a small number. - equals: function (obj, maxMargin) { - if (!obj) { return false; } - - obj = toLatLng(obj); - - var margin = Math.max( - Math.abs(this.lat - obj.lat), - Math.abs(this.lng - obj.lng)); - - return margin <= (maxMargin === undefined ? 1.0E-9 : maxMargin); - }, - - // @method toString(): String - // Returns a string representation of the point (for debugging purposes). - toString: function (precision) { - return 'LatLng(' + - formatNum(this.lat, precision) + ', ' + - formatNum(this.lng, precision) + ')'; - }, - - // @method distanceTo(otherLatLng: LatLng): Number - // Returns the distance (in meters) to the given `LatLng` calculated using the [Spherical Law of Cosines](https://en.wikipedia.org/wiki/Spherical_law_of_cosines). - distanceTo: function (other) { - return Earth.distance(this, toLatLng(other)); - }, - - // @method wrap(): LatLng - // Returns a new `LatLng` object with the longitude wrapped so it's always between -180 and +180 degrees. - wrap: function () { - return Earth.wrapLatLng(this); - }, - - // @method toBounds(sizeInMeters: Number): LatLngBounds - // Returns a new `LatLngBounds` object in which each boundary is `sizeInMeters/2` meters apart from the `LatLng`. - toBounds: function (sizeInMeters) { - var latAccuracy = 180 * sizeInMeters / 40075017, - lngAccuracy = latAccuracy / Math.cos((Math.PI / 180) * this.lat); - - return toLatLngBounds( - [this.lat - latAccuracy, this.lng - lngAccuracy], - [this.lat + latAccuracy, this.lng + lngAccuracy]); - }, - - clone: function () { - return new LatLng(this.lat, this.lng, this.alt); - } -}; - - - -// @factory L.latLng(latitude: Number, longitude: Number, altitude?: Number): LatLng -// Creates an object representing a geographical point with the given latitude and longitude (and optionally altitude). - -// @alternative -// @factory L.latLng(coords: Array): LatLng -// Expects an array of the form `[Number, Number]` or `[Number, Number, Number]` instead. - -// @alternative -// @factory L.latLng(coords: Object): LatLng -// Expects an plain object of the form `{lat: Number, lng: Number}` or `{lat: Number, lng: Number, alt: Number}` instead. - -function toLatLng(a, b, c) { - if (a instanceof LatLng) { - return a; - } - if (isArray(a) && typeof a[0] !== 'object') { - if (a.length === 3) { - return new LatLng(a[0], a[1], a[2]); - } - if (a.length === 2) { - return new LatLng(a[0], a[1]); - } - return null; - } - if (a === undefined || a === null) { - return a; - } - if (typeof a === 'object' && 'lat' in a) { - return new LatLng(a.lat, 'lng' in a ? a.lng : a.lon, a.alt); - } - if (b === undefined) { - return null; - } - return new LatLng(a, b, c); -} - -/* - * @namespace CRS - * @crs L.CRS.Base - * Object that defines coordinate reference systems for projecting - * geographical points into pixel (screen) coordinates and back (and to - * coordinates in other units for [WMS](https://en.wikipedia.org/wiki/Web_Map_Service) services). See - * [spatial reference system](http://en.wikipedia.org/wiki/Coordinate_reference_system). - * - * Leaflet defines the most usual CRSs by default. If you want to use a - * CRS not defined by default, take a look at the - * [Proj4Leaflet](https://github.com/kartena/Proj4Leaflet) plugin. - * - * Note that the CRS instances do not inherit from Leaflet's `Class` object, - * and can't be instantiated. Also, new classes can't inherit from them, - * and methods can't be added to them with the `include` function. - */ - -var CRS = { - // @method latLngToPoint(latlng: LatLng, zoom: Number): Point - // Projects geographical coordinates into pixel coordinates for a given zoom. - latLngToPoint: function (latlng, zoom) { - var projectedPoint = this.projection.project(latlng), - scale = this.scale(zoom); - - return this.transformation._transform(projectedPoint, scale); - }, - - // @method pointToLatLng(point: Point, zoom: Number): LatLng - // The inverse of `latLngToPoint`. Projects pixel coordinates on a given - // zoom into geographical coordinates. - pointToLatLng: function (point, zoom) { - var scale = this.scale(zoom), - untransformedPoint = this.transformation.untransform(point, scale); - - return this.projection.unproject(untransformedPoint); - }, - - // @method project(latlng: LatLng): Point - // Projects geographical coordinates into coordinates in units accepted for - // this CRS (e.g. meters for EPSG:3857, for passing it to WMS services). - project: function (latlng) { - return this.projection.project(latlng); - }, - - // @method unproject(point: Point): LatLng - // Given a projected coordinate returns the corresponding LatLng. - // The inverse of `project`. - unproject: function (point) { - return this.projection.unproject(point); - }, - - // @method scale(zoom: Number): Number - // Returns the scale used when transforming projected coordinates into - // pixel coordinates for a particular zoom. For example, it returns - // `256 * 2^zoom` for Mercator-based CRS. - scale: function (zoom) { - return 256 * Math.pow(2, zoom); - }, - - // @method zoom(scale: Number): Number - // Inverse of `scale()`, returns the zoom level corresponding to a scale - // factor of `scale`. - zoom: function (scale) { - return Math.log(scale / 256) / Math.LN2; - }, - - // @method getProjectedBounds(zoom: Number): Bounds - // Returns the projection's bounds scaled and transformed for the provided `zoom`. - getProjectedBounds: function (zoom) { - if (this.infinite) { return null; } - - var b = this.projection.bounds, - s = this.scale(zoom), - min = this.transformation.transform(b.min, s), - max = this.transformation.transform(b.max, s); - - return new Bounds(min, max); - }, - - // @method distance(latlng1: LatLng, latlng2: LatLng): Number - // Returns the distance between two geographical coordinates. - - // @property code: String - // Standard code name of the CRS passed into WMS services (e.g. `'EPSG:3857'`) - // - // @property wrapLng: Number[] - // An array of two numbers defining whether the longitude (horizontal) coordinate - // axis wraps around a given range and how. Defaults to `[-180, 180]` in most - // geographical CRSs. If `undefined`, the longitude axis does not wrap around. - // - // @property wrapLat: Number[] - // Like `wrapLng`, but for the latitude (vertical) axis. - - // wrapLng: [min, max], - // wrapLat: [min, max], - - // @property infinite: Boolean - // If true, the coordinate space will be unbounded (infinite in both axes) - infinite: false, - - // @method wrapLatLng(latlng: LatLng): LatLng - // Returns a `LatLng` where lat and lng has been wrapped according to the - // CRS's `wrapLat` and `wrapLng` properties, if they are outside the CRS's bounds. - wrapLatLng: function (latlng) { - var lng = this.wrapLng ? wrapNum(latlng.lng, this.wrapLng, true) : latlng.lng, - lat = this.wrapLat ? wrapNum(latlng.lat, this.wrapLat, true) : latlng.lat, - alt = latlng.alt; - - return new LatLng(lat, lng, alt); - }, - - // @method wrapLatLngBounds(bounds: LatLngBounds): LatLngBounds - // Returns a `LatLngBounds` with the same size as the given one, ensuring - // that its center is within the CRS's bounds. - // Only accepts actual `L.LatLngBounds` instances, not arrays. - wrapLatLngBounds: function (bounds) { - var center = bounds.getCenter(), - newCenter = this.wrapLatLng(center), - latShift = center.lat - newCenter.lat, - lngShift = center.lng - newCenter.lng; - - if (latShift === 0 && lngShift === 0) { - return bounds; - } - - var sw = bounds.getSouthWest(), - ne = bounds.getNorthEast(), - newSw = new LatLng(sw.lat - latShift, sw.lng - lngShift), - newNe = new LatLng(ne.lat - latShift, ne.lng - lngShift); - - return new LatLngBounds(newSw, newNe); - } +/* + * @namespace CRS + * @crs L.CRS.Base + * Object that defines coordinate reference systems for projecting + * geographical points into pixel (screen) coordinates and back (and to + * coordinates in other units for [WMS](https://en.wikipedia.org/wiki/Web_Map_Service) services). See + * [spatial reference system](http://en.wikipedia.org/wiki/Coordinate_reference_system). + * + * Leaflet defines the most usual CRSs by default. If you want to use a + * CRS not defined by default, take a look at the + * [Proj4Leaflet](https://github.com/kartena/Proj4Leaflet) plugin. + * + * Note that the CRS instances do not inherit from Leaflet's `Class` object, + * and can't be instantiated. Also, new classes can't inherit from them, + * and methods can't be added to them with the `include` function. + */ + +var CRS = { + // @method latLngToPoint(latlng: LatLng, zoom: Number): Point + // Projects geographical coordinates into pixel coordinates for a given zoom. + latLngToPoint: function (latlng, zoom) { + var projectedPoint = this.projection.project(latlng), + scale = this.scale(zoom); + + return this.transformation._transform(projectedPoint, scale); + }, + + // @method pointToLatLng(point: Point, zoom: Number): LatLng + // The inverse of `latLngToPoint`. Projects pixel coordinates on a given + // zoom into geographical coordinates. + pointToLatLng: function (point, zoom) { + var scale = this.scale(zoom), + untransformedPoint = this.transformation.untransform(point, scale); + + return this.projection.unproject(untransformedPoint); + }, + + // @method project(latlng: LatLng): Point + // Projects geographical coordinates into coordinates in units accepted for + // this CRS (e.g. meters for EPSG:3857, for passing it to WMS services). + project: function (latlng) { + return this.projection.project(latlng); + }, + + // @method unproject(point: Point): LatLng + // Given a projected coordinate returns the corresponding LatLng. + // The inverse of `project`. + unproject: function (point) { + return this.projection.unproject(point); + }, + + // @method scale(zoom: Number): Number + // Returns the scale used when transforming projected coordinates into + // pixel coordinates for a particular zoom. For example, it returns + // `256 * 2^zoom` for Mercator-based CRS. + scale: function (zoom) { + return 256 * Math.pow(2, zoom); + }, + + // @method zoom(scale: Number): Number + // Inverse of `scale()`, returns the zoom level corresponding to a scale + // factor of `scale`. + zoom: function (scale) { + return Math.log(scale / 256) / Math.LN2; + }, + + // @method getProjectedBounds(zoom: Number): Bounds + // Returns the projection's bounds scaled and transformed for the provided `zoom`. + getProjectedBounds: function (zoom) { + if (this.infinite) { return null; } + + var b = this.projection.bounds, + s = this.scale(zoom), + min = this.transformation.transform(b.min, s), + max = this.transformation.transform(b.max, s); + + return new Bounds(min, max); + }, + + // @method distance(latlng1: LatLng, latlng2: LatLng): Number + // Returns the distance between two geographical coordinates. + + // @property code: String + // Standard code name of the CRS passed into WMS services (e.g. `'EPSG:3857'`) + // + // @property wrapLng: Number[] + // An array of two numbers defining whether the longitude (horizontal) coordinate + // axis wraps around a given range and how. Defaults to `[-180, 180]` in most + // geographical CRSs. If `undefined`, the longitude axis does not wrap around. + // + // @property wrapLat: Number[] + // Like `wrapLng`, but for the latitude (vertical) axis. + + // wrapLng: [min, max], + // wrapLat: [min, max], + + // @property infinite: Boolean + // If true, the coordinate space will be unbounded (infinite in both axes) + infinite: false, + + // @method wrapLatLng(latlng: LatLng): LatLng + // Returns a `LatLng` where lat and lng has been wrapped according to the + // CRS's `wrapLat` and `wrapLng` properties, if they are outside the CRS's bounds. + wrapLatLng: function (latlng) { + var lng = this.wrapLng ? wrapNum(latlng.lng, this.wrapLng, true) : latlng.lng, + lat = this.wrapLat ? wrapNum(latlng.lat, this.wrapLat, true) : latlng.lat, + alt = latlng.alt; + + return new LatLng(lat, lng, alt); + }, + + // @method wrapLatLngBounds(bounds: LatLngBounds): LatLngBounds + // Returns a `LatLngBounds` with the same size as the given one, ensuring + // that its center is within the CRS's bounds. + // Only accepts actual `L.LatLngBounds` instances, not arrays. + wrapLatLngBounds: function (bounds) { + var center = bounds.getCenter(), + newCenter = this.wrapLatLng(center), + latShift = center.lat - newCenter.lat, + lngShift = center.lng - newCenter.lng; + + if (latShift === 0 && lngShift === 0) { + return bounds; + } + + var sw = bounds.getSouthWest(), + ne = bounds.getNorthEast(), + newSw = new LatLng(sw.lat - latShift, sw.lng - lngShift), + newNe = new LatLng(ne.lat - latShift, ne.lng - lngShift); + + return new LatLngBounds(newSw, newNe); + } }; /* @@ -1628,145 +1628,145 @@ var Earth = extend({}, CRS, { } }); -/* - * @namespace Projection - * @projection L.Projection.SphericalMercator - * - * Spherical Mercator projection — the most common projection for online maps, - * used by almost all free and commercial tile providers. Assumes that Earth is - * a sphere. Used by the `EPSG:3857` CRS. - */ - -var earthRadius = 6378137; - -var SphericalMercator = { - - R: earthRadius, - MAX_LATITUDE: 85.0511287798, - - project: function (latlng) { - var d = Math.PI / 180, - max = this.MAX_LATITUDE, - lat = Math.max(Math.min(max, latlng.lat), -max), - sin = Math.sin(lat * d); - - return new Point( - this.R * latlng.lng * d, - this.R * Math.log((1 + sin) / (1 - sin)) / 2); - }, - - unproject: function (point) { - var d = 180 / Math.PI; - - return new LatLng( - (2 * Math.atan(Math.exp(point.y / this.R)) - (Math.PI / 2)) * d, - point.x * d / this.R); - }, - - bounds: (function () { - var d = earthRadius * Math.PI; - return new Bounds([-d, -d], [d, d]); - })() +/* + * @namespace Projection + * @projection L.Projection.SphericalMercator + * + * Spherical Mercator projection — the most common projection for online maps, + * used by almost all free and commercial tile providers. Assumes that Earth is + * a sphere. Used by the `EPSG:3857` CRS. + */ + +var earthRadius = 6378137; + +var SphericalMercator = { + + R: earthRadius, + MAX_LATITUDE: 85.0511287798, + + project: function (latlng) { + var d = Math.PI / 180, + max = this.MAX_LATITUDE, + lat = Math.max(Math.min(max, latlng.lat), -max), + sin = Math.sin(lat * d); + + return new Point( + this.R * latlng.lng * d, + this.R * Math.log((1 + sin) / (1 - sin)) / 2); + }, + + unproject: function (point) { + var d = 180 / Math.PI; + + return new LatLng( + (2 * Math.atan(Math.exp(point.y / this.R)) - (Math.PI / 2)) * d, + point.x * d / this.R); + }, + + bounds: (function () { + var d = earthRadius * Math.PI; + return new Bounds([-d, -d], [d, d]); + })() }; -/* - * @class Transformation - * @aka L.Transformation - * - * Represents an affine transformation: a set of coefficients `a`, `b`, `c`, `d` - * for transforming a point of a form `(x, y)` into `(a*x + b, c*y + d)` and doing - * the reverse. Used by Leaflet in its projections code. - * - * @example - * - * ```js - * var transformation = L.transformation(2, 5, -1, 10), - * p = L.point(1, 2), - * p2 = transformation.transform(p), // L.point(7, 8) - * p3 = transformation.untransform(p2); // L.point(1, 2) - * ``` - */ - - -// factory new L.Transformation(a: Number, b: Number, c: Number, d: Number) -// Creates a `Transformation` object with the given coefficients. -function Transformation(a, b, c, d) { - if (isArray(a)) { - // use array properties - this._a = a[0]; - this._b = a[1]; - this._c = a[2]; - this._d = a[3]; - return; - } - this._a = a; - this._b = b; - this._c = c; - this._d = d; +/* + * @class Transformation + * @aka L.Transformation + * + * Represents an affine transformation: a set of coefficients `a`, `b`, `c`, `d` + * for transforming a point of a form `(x, y)` into `(a*x + b, c*y + d)` and doing + * the reverse. Used by Leaflet in its projections code. + * + * @example + * + * ```js + * var transformation = L.transformation(2, 5, -1, 10), + * p = L.point(1, 2), + * p2 = transformation.transform(p), // L.point(7, 8) + * p3 = transformation.untransform(p2); // L.point(1, 2) + * ``` + */ + + +// factory new L.Transformation(a: Number, b: Number, c: Number, d: Number) +// Creates a `Transformation` object with the given coefficients. +function Transformation(a, b, c, d) { + if (isArray(a)) { + // use array properties + this._a = a[0]; + this._b = a[1]; + this._c = a[2]; + this._d = a[3]; + return; + } + this._a = a; + this._b = b; + this._c = c; + this._d = d; +} + +Transformation.prototype = { + // @method transform(point: Point, scale?: Number): Point + // Returns a transformed point, optionally multiplied by the given scale. + // Only accepts actual `L.Point` instances, not arrays. + transform: function (point, scale) { // (Point, Number) -> Point + return this._transform(point.clone(), scale); + }, + + // destructive transform (faster) + _transform: function (point, scale) { + scale = scale || 1; + point.x = scale * (this._a * point.x + this._b); + point.y = scale * (this._c * point.y + this._d); + return point; + }, + + // @method untransform(point: Point, scale?: Number): Point + // Returns the reverse transformation of the given point, optionally divided + // by the given scale. Only accepts actual `L.Point` instances, not arrays. + untransform: function (point, scale) { + scale = scale || 1; + return new Point( + (point.x / scale - this._b) / this._a, + (point.y / scale - this._d) / this._c); + } +}; + +// factory L.transformation(a: Number, b: Number, c: Number, d: Number) + +// @factory L.transformation(a: Number, b: Number, c: Number, d: Number) +// Instantiates a Transformation object with the given coefficients. + +// @alternative +// @factory L.transformation(coefficients: Array): Transformation +// Expects an coefficients array of the form +// `[a: Number, b: Number, c: Number, d: Number]`. + +function toTransformation(a, b, c, d) { + return new Transformation(a, b, c, d); } -Transformation.prototype = { - // @method transform(point: Point, scale?: Number): Point - // Returns a transformed point, optionally multiplied by the given scale. - // Only accepts actual `L.Point` instances, not arrays. - transform: function (point, scale) { // (Point, Number) -> Point - return this._transform(point.clone(), scale); - }, - - // destructive transform (faster) - _transform: function (point, scale) { - scale = scale || 1; - point.x = scale * (this._a * point.x + this._b); - point.y = scale * (this._c * point.y + this._d); - return point; - }, - - // @method untransform(point: Point, scale?: Number): Point - // Returns the reverse transformation of the given point, optionally divided - // by the given scale. Only accepts actual `L.Point` instances, not arrays. - untransform: function (point, scale) { - scale = scale || 1; - return new Point( - (point.x / scale - this._b) / this._a, - (point.y / scale - this._d) / this._c); - } -}; - -// factory L.transformation(a: Number, b: Number, c: Number, d: Number) - -// @factory L.transformation(a: Number, b: Number, c: Number, d: Number) -// Instantiates a Transformation object with the given coefficients. - -// @alternative -// @factory L.transformation(coefficients: Array): Transformation -// Expects an coefficients array of the form -// `[a: Number, b: Number, c: Number, d: Number]`. - -function toTransformation(a, b, c, d) { - return new Transformation(a, b, c, d); -} - -/* - * @namespace CRS - * @crs L.CRS.EPSG3857 - * - * The most common CRS for online maps, used by almost all free and commercial - * tile providers. Uses Spherical Mercator projection. Set in by default in - * Map's `crs` option. - */ - -var EPSG3857 = extend({}, Earth, { - code: 'EPSG:3857', - projection: SphericalMercator, - - transformation: (function () { - var scale = 0.5 / (Math.PI * SphericalMercator.R); - return toTransformation(scale, 0.5, -scale, 0.5); - }()) -}); - -var EPSG900913 = extend({}, EPSG3857, { - code: 'EPSG:900913' +/* + * @namespace CRS + * @crs L.CRS.EPSG3857 + * + * The most common CRS for online maps, used by almost all free and commercial + * tile providers. Uses Spherical Mercator projection. Set in by default in + * Map's `crs` option. + */ + +var EPSG3857 = extend({}, Earth, { + code: 'EPSG:3857', + projection: SphericalMercator, + + transformation: (function () { + var scale = 0.5 / (Math.PI * SphericalMercator.R); + return toTransformation(scale, 0.5, -scale, 0.5); + }()) +}); + +var EPSG900913 = extend({}, EPSG3857, { + code: 'EPSG:900913' }); // @namespace SVG; @section @@ -1803,167 +1803,167 @@ function pointsToPath(rings, closed) { return str || 'M0 0'; } -/* - * @namespace Browser - * @aka L.Browser - * - * A namespace with static properties for browser/feature detection used by Leaflet internally. - * - * @example - * - * ```js - * if (L.Browser.ielt9) { - * alert('Upgrade your browser, dude!'); - * } - * ``` - */ - -var style$1 = document.documentElement.style; - -// @property ie: Boolean; `true` for all Internet Explorer versions (not Edge). -var ie = 'ActiveXObject' in window; - -// @property ielt9: Boolean; `true` for Internet Explorer versions less than 9. -var ielt9 = ie && !document.addEventListener; - -// @property edge: Boolean; `true` for the Edge web browser. -var edge = 'msLaunchUri' in navigator && !('documentMode' in document); - -// @property webkit: Boolean; -// `true` for webkit-based browsers like Chrome and Safari (including mobile versions). -var webkit = userAgentContains('webkit'); - -// @property android: Boolean -// `true` for any browser running on an Android platform. -var android = userAgentContains('android'); - -// @property android23: Boolean; `true` for browsers running on Android 2 or Android 3. -var android23 = userAgentContains('android 2') || userAgentContains('android 3'); - -/* See https://stackoverflow.com/a/17961266 for details on detecting stock Android */ -var webkitVer = parseInt(/WebKit\/([0-9]+)|$/.exec(navigator.userAgent)[1], 10); // also matches AppleWebKit -// @property androidStock: Boolean; `true` for the Android stock browser (i.e. not Chrome) -var androidStock = android && userAgentContains('Google') && webkitVer < 537 && !('AudioNode' in window); - -// @property opera: Boolean; `true` for the Opera browser -var opera = !!window.opera; - -// @property chrome: Boolean; `true` for the Chrome browser. -var chrome = !edge && userAgentContains('chrome'); - -// @property gecko: Boolean; `true` for gecko-based browsers like Firefox. -var gecko = userAgentContains('gecko') && !webkit && !opera && !ie; - -// @property safari: Boolean; `true` for the Safari browser. -var safari = !chrome && userAgentContains('safari'); - -var phantom = userAgentContains('phantom'); - -// @property opera12: Boolean -// `true` for the Opera browser supporting CSS transforms (version 12 or later). -var opera12 = 'OTransition' in style$1; - -// @property win: Boolean; `true` when the browser is running in a Windows platform -var win = navigator.platform.indexOf('Win') === 0; - -// @property ie3d: Boolean; `true` for all Internet Explorer versions supporting CSS transforms. -var ie3d = ie && ('transition' in style$1); - -// @property webkit3d: Boolean; `true` for webkit-based browsers supporting CSS transforms. -var webkit3d = ('WebKitCSSMatrix' in window) && ('m11' in new window.WebKitCSSMatrix()) && !android23; - -// @property gecko3d: Boolean; `true` for gecko-based browsers supporting CSS transforms. -var gecko3d = 'MozPerspective' in style$1; - -// @property any3d: Boolean -// `true` for all browsers supporting CSS transforms. -var any3d = !window.L_DISABLE_3D && (ie3d || webkit3d || gecko3d) && !opera12 && !phantom; - -// @property mobile: Boolean; `true` for all browsers running in a mobile device. -var mobile = typeof orientation !== 'undefined' || userAgentContains('mobile'); - -// @property mobileWebkit: Boolean; `true` for all webkit-based browsers in a mobile device. -var mobileWebkit = mobile && webkit; - -// @property mobileWebkit3d: Boolean -// `true` for all webkit-based browsers in a mobile device supporting CSS transforms. -var mobileWebkit3d = mobile && webkit3d; - -// @property msPointer: Boolean -// `true` for browsers implementing the Microsoft touch events model (notably IE10). -var msPointer = !window.PointerEvent && window.MSPointerEvent; - -// @property pointer: Boolean -// `true` for all browsers supporting [pointer events](https://msdn.microsoft.com/en-us/library/dn433244%28v=vs.85%29.aspx). -var pointer = !!(window.PointerEvent || msPointer); - -// @property touch: Boolean -// `true` for all browsers supporting [touch events](https://developer.mozilla.org/docs/Web/API/Touch_events). -// This does not necessarily mean that the browser is running in a computer with -// a touchscreen, it only means that the browser is capable of understanding -// touch events. -var touch = !window.L_NO_TOUCH && (pointer || 'ontouchstart' in window || - (window.DocumentTouch && document instanceof window.DocumentTouch)); - -// @property mobileOpera: Boolean; `true` for the Opera browser in a mobile device. -var mobileOpera = mobile && opera; - -// @property mobileGecko: Boolean -// `true` for gecko-based browsers running in a mobile device. -var mobileGecko = mobile && gecko; - -// @property retina: Boolean -// `true` for browsers on a high-resolution "retina" screen or on any screen when browser's display zoom is more than 100%. -var retina = (window.devicePixelRatio || (window.screen.deviceXDPI / window.screen.logicalXDPI)) > 1; - -// @property passiveEvents: Boolean -// `true` for browsers that support passive events. -var passiveEvents = (function () { - var supportsPassiveOption = false; - try { - var opts = Object.defineProperty({}, 'passive', { - get: function () { // eslint-disable-line getter-return - supportsPassiveOption = true; - } - }); - window.addEventListener('testPassiveEventSupport', falseFn, opts); - window.removeEventListener('testPassiveEventSupport', falseFn, opts); - } catch (e) { - // Errors can safely be ignored since this is only a browser support test. - } - return supportsPassiveOption; -}()); - -// @property canvas: Boolean -// `true` when the browser supports [``](https://developer.mozilla.org/docs/Web/API/Canvas_API). -var canvas = (function () { - return !!document.createElement('canvas').getContext; -}()); - -// @property svg: Boolean -// `true` when the browser supports [SVG](https://developer.mozilla.org/docs/Web/SVG). -var svg = !!(document.createElementNS && svgCreate('svg').createSVGRect); - -// @property vml: Boolean -// `true` if the browser supports [VML](https://en.wikipedia.org/wiki/Vector_Markup_Language). -var vml = !svg && (function () { - try { - var div = document.createElement('div'); - div.innerHTML = ''; - - var shape = div.firstChild; - shape.style.behavior = 'url(#default#VML)'; - - return shape && (typeof shape.adj === 'object'); - - } catch (e) { - return false; - } -}()); - - -function userAgentContains(str) { - return navigator.userAgent.toLowerCase().indexOf(str) >= 0; +/* + * @namespace Browser + * @aka L.Browser + * + * A namespace with static properties for browser/feature detection used by Leaflet internally. + * + * @example + * + * ```js + * if (L.Browser.ielt9) { + * alert('Upgrade your browser, dude!'); + * } + * ``` + */ + +var style$1 = document.documentElement.style; + +// @property ie: Boolean; `true` for all Internet Explorer versions (not Edge). +var ie = 'ActiveXObject' in window; + +// @property ielt9: Boolean; `true` for Internet Explorer versions less than 9. +var ielt9 = ie && !document.addEventListener; + +// @property edge: Boolean; `true` for the Edge web browser. +var edge = 'msLaunchUri' in navigator && !('documentMode' in document); + +// @property webkit: Boolean; +// `true` for webkit-based browsers like Chrome and Safari (including mobile versions). +var webkit = userAgentContains('webkit'); + +// @property android: Boolean +// `true` for any browser running on an Android platform. +var android = userAgentContains('android'); + +// @property android23: Boolean; `true` for browsers running on Android 2 or Android 3. +var android23 = userAgentContains('android 2') || userAgentContains('android 3'); + +/* See https://stackoverflow.com/a/17961266 for details on detecting stock Android */ +var webkitVer = parseInt(/WebKit\/([0-9]+)|$/.exec(navigator.userAgent)[1], 10); // also matches AppleWebKit +// @property androidStock: Boolean; `true` for the Android stock browser (i.e. not Chrome) +var androidStock = android && userAgentContains('Google') && webkitVer < 537 && !('AudioNode' in window); + +// @property opera: Boolean; `true` for the Opera browser +var opera = !!window.opera; + +// @property chrome: Boolean; `true` for the Chrome browser. +var chrome = !edge && userAgentContains('chrome'); + +// @property gecko: Boolean; `true` for gecko-based browsers like Firefox. +var gecko = userAgentContains('gecko') && !webkit && !opera && !ie; + +// @property safari: Boolean; `true` for the Safari browser. +var safari = !chrome && userAgentContains('safari'); + +var phantom = userAgentContains('phantom'); + +// @property opera12: Boolean +// `true` for the Opera browser supporting CSS transforms (version 12 or later). +var opera12 = 'OTransition' in style$1; + +// @property win: Boolean; `true` when the browser is running in a Windows platform +var win = navigator.platform.indexOf('Win') === 0; + +// @property ie3d: Boolean; `true` for all Internet Explorer versions supporting CSS transforms. +var ie3d = ie && ('transition' in style$1); + +// @property webkit3d: Boolean; `true` for webkit-based browsers supporting CSS transforms. +var webkit3d = ('WebKitCSSMatrix' in window) && ('m11' in new window.WebKitCSSMatrix()) && !android23; + +// @property gecko3d: Boolean; `true` for gecko-based browsers supporting CSS transforms. +var gecko3d = 'MozPerspective' in style$1; + +// @property any3d: Boolean +// `true` for all browsers supporting CSS transforms. +var any3d = !window.L_DISABLE_3D && (ie3d || webkit3d || gecko3d) && !opera12 && !phantom; + +// @property mobile: Boolean; `true` for all browsers running in a mobile device. +var mobile = typeof orientation !== 'undefined' || userAgentContains('mobile'); + +// @property mobileWebkit: Boolean; `true` for all webkit-based browsers in a mobile device. +var mobileWebkit = mobile && webkit; + +// @property mobileWebkit3d: Boolean +// `true` for all webkit-based browsers in a mobile device supporting CSS transforms. +var mobileWebkit3d = mobile && webkit3d; + +// @property msPointer: Boolean +// `true` for browsers implementing the Microsoft touch events model (notably IE10). +var msPointer = !window.PointerEvent && window.MSPointerEvent; + +// @property pointer: Boolean +// `true` for all browsers supporting [pointer events](https://msdn.microsoft.com/en-us/library/dn433244%28v=vs.85%29.aspx). +var pointer = !!(window.PointerEvent || msPointer); + +// @property touch: Boolean +// `true` for all browsers supporting [touch events](https://developer.mozilla.org/docs/Web/API/Touch_events). +// This does not necessarily mean that the browser is running in a computer with +// a touchscreen, it only means that the browser is capable of understanding +// touch events. +var touch = !window.L_NO_TOUCH && (pointer || 'ontouchstart' in window || + (window.DocumentTouch && document instanceof window.DocumentTouch)); + +// @property mobileOpera: Boolean; `true` for the Opera browser in a mobile device. +var mobileOpera = mobile && opera; + +// @property mobileGecko: Boolean +// `true` for gecko-based browsers running in a mobile device. +var mobileGecko = mobile && gecko; + +// @property retina: Boolean +// `true` for browsers on a high-resolution "retina" screen or on any screen when browser's display zoom is more than 100%. +var retina = (window.devicePixelRatio || (window.screen.deviceXDPI / window.screen.logicalXDPI)) > 1; + +// @property passiveEvents: Boolean +// `true` for browsers that support passive events. +var passiveEvents = (function () { + var supportsPassiveOption = false; + try { + var opts = Object.defineProperty({}, 'passive', { + get: function () { // eslint-disable-line getter-return + supportsPassiveOption = true; + } + }); + window.addEventListener('testPassiveEventSupport', falseFn, opts); + window.removeEventListener('testPassiveEventSupport', falseFn, opts); + } catch (e) { + // Errors can safely be ignored since this is only a browser support test. + } + return supportsPassiveOption; +}()); + +// @property canvas: Boolean +// `true` when the browser supports [``](https://developer.mozilla.org/docs/Web/API/Canvas_API). +var canvas = (function () { + return !!document.createElement('canvas').getContext; +}()); + +// @property svg: Boolean +// `true` when the browser supports [SVG](https://developer.mozilla.org/docs/Web/SVG). +var svg = !!(document.createElementNS && svgCreate('svg').createSVGRect); + +// @property vml: Boolean +// `true` if the browser supports [VML](https://en.wikipedia.org/wiki/Vector_Markup_Language). +var vml = !svg && (function () { + try { + var div = document.createElement('div'); + div.innerHTML = ''; + + var shape = div.firstChild; + shape.style.behavior = 'url(#default#VML)'; + + return shape && (typeof shape.adj === 'object'); + + } catch (e) { + return false; + } +}()); + + +function userAgentContains(str) { + return navigator.userAgent.toLowerCase().indexOf(str) >= 0; } var Browser = ({ @@ -2120,429 +2120,429 @@ function _addPointerEnd(obj, handler, id) { obj.addEventListener(POINTER_CANCEL, onUp, false); } -/* - * Extends the event handling code with double tap support for mobile browsers. - */ - -var _touchstart = msPointer ? 'MSPointerDown' : pointer ? 'pointerdown' : 'touchstart'; -var _touchend = msPointer ? 'MSPointerUp' : pointer ? 'pointerup' : 'touchend'; -var _pre = '_leaflet_'; - -// inspired by Zepto touch code by Thomas Fuchs -function addDoubleTapListener(obj, handler, id) { - var last, touch$$1, - doubleTap = false, - delay = 250; - - function onTouchStart(e) { - - if (pointer) { - if (!e.isPrimary) { return; } - if (e.pointerType === 'mouse') { return; } // mouse fires native dblclick - } else if (e.touches.length > 1) { - return; - } - - var now = Date.now(), - delta = now - (last || now); - - touch$$1 = e.touches ? e.touches[0] : e; - doubleTap = (delta > 0 && delta <= delay); - last = now; - } - - function onTouchEnd(e) { - if (doubleTap && !touch$$1.cancelBubble) { - if (pointer) { - if (e.pointerType === 'mouse') { return; } - // work around .type being readonly with MSPointer* events - var newTouch = {}, - prop, i; - - for (i in touch$$1) { - prop = touch$$1[i]; - newTouch[i] = prop && prop.bind ? prop.bind(touch$$1) : prop; - } - touch$$1 = newTouch; - } - touch$$1.type = 'dblclick'; - touch$$1.button = 0; - handler(touch$$1); - last = null; - } - } - - obj[_pre + _touchstart + id] = onTouchStart; - obj[_pre + _touchend + id] = onTouchEnd; - obj[_pre + 'dblclick' + id] = handler; - - obj.addEventListener(_touchstart, onTouchStart, passiveEvents ? {passive: false} : false); - obj.addEventListener(_touchend, onTouchEnd, passiveEvents ? {passive: false} : false); - - // On some platforms (notably, chrome<55 on win10 + touchscreen + mouse), - // the browser doesn't fire touchend/pointerup events but does fire - // native dblclicks. See #4127. - // Edge 14 also fires native dblclicks, but only for pointerType mouse, see #5180. - obj.addEventListener('dblclick', handler, false); - - return this; +/* + * Extends the event handling code with double tap support for mobile browsers. + */ + +var _touchstart = msPointer ? 'MSPointerDown' : pointer ? 'pointerdown' : 'touchstart'; +var _touchend = msPointer ? 'MSPointerUp' : pointer ? 'pointerup' : 'touchend'; +var _pre = '_leaflet_'; + +// inspired by Zepto touch code by Thomas Fuchs +function addDoubleTapListener(obj, handler, id) { + var last, touch$$1, + doubleTap = false, + delay = 250; + + function onTouchStart(e) { + + if (pointer) { + if (!e.isPrimary) { return; } + if (e.pointerType === 'mouse') { return; } // mouse fires native dblclick + } else if (e.touches.length > 1) { + return; + } + + var now = Date.now(), + delta = now - (last || now); + + touch$$1 = e.touches ? e.touches[0] : e; + doubleTap = (delta > 0 && delta <= delay); + last = now; + } + + function onTouchEnd(e) { + if (doubleTap && !touch$$1.cancelBubble) { + if (pointer) { + if (e.pointerType === 'mouse') { return; } + // work around .type being readonly with MSPointer* events + var newTouch = {}, + prop, i; + + for (i in touch$$1) { + prop = touch$$1[i]; + newTouch[i] = prop && prop.bind ? prop.bind(touch$$1) : prop; + } + touch$$1 = newTouch; + } + touch$$1.type = 'dblclick'; + touch$$1.button = 0; + handler(touch$$1); + last = null; + } + } + + obj[_pre + _touchstart + id] = onTouchStart; + obj[_pre + _touchend + id] = onTouchEnd; + obj[_pre + 'dblclick' + id] = handler; + + obj.addEventListener(_touchstart, onTouchStart, passiveEvents ? {passive: false} : false); + obj.addEventListener(_touchend, onTouchEnd, passiveEvents ? {passive: false} : false); + + // On some platforms (notably, chrome<55 on win10 + touchscreen + mouse), + // the browser doesn't fire touchend/pointerup events but does fire + // native dblclicks. See #4127. + // Edge 14 also fires native dblclicks, but only for pointerType mouse, see #5180. + obj.addEventListener('dblclick', handler, false); + + return this; +} + +function removeDoubleTapListener(obj, id) { + var touchstart = obj[_pre + _touchstart + id], + touchend = obj[_pre + _touchend + id], + dblclick = obj[_pre + 'dblclick' + id]; + + obj.removeEventListener(_touchstart, touchstart, passiveEvents ? {passive: false} : false); + obj.removeEventListener(_touchend, touchend, passiveEvents ? {passive: false} : false); + obj.removeEventListener('dblclick', dblclick, false); + + return this; } -function removeDoubleTapListener(obj, id) { - var touchstart = obj[_pre + _touchstart + id], - touchend = obj[_pre + _touchend + id], - dblclick = obj[_pre + 'dblclick' + id]; - - obj.removeEventListener(_touchstart, touchstart, passiveEvents ? {passive: false} : false); - obj.removeEventListener(_touchend, touchend, passiveEvents ? {passive: false} : false); - obj.removeEventListener('dblclick', dblclick, false); - - return this; -} - -/* - * @namespace DomUtil - * - * Utility functions to work with the [DOM](https://developer.mozilla.org/docs/Web/API/Document_Object_Model) - * tree, used by Leaflet internally. - * - * Most functions expecting or returning a `HTMLElement` also work for - * SVG elements. The only difference is that classes refer to CSS classes - * in HTML and SVG classes in SVG. - */ - - -// @property TRANSFORM: String -// Vendor-prefixed transform style name (e.g. `'webkitTransform'` for WebKit). -var TRANSFORM = testProp( - ['transform', 'webkitTransform', 'OTransform', 'MozTransform', 'msTransform']); - -// webkitTransition comes first because some browser versions that drop vendor prefix don't do -// the same for the transitionend event, in particular the Android 4.1 stock browser - -// @property TRANSITION: String -// Vendor-prefixed transition style name. -var TRANSITION = testProp( - ['webkitTransition', 'transition', 'OTransition', 'MozTransition', 'msTransition']); - -// @property TRANSITION_END: String -// Vendor-prefixed transitionend event name. -var TRANSITION_END = - TRANSITION === 'webkitTransition' || TRANSITION === 'OTransition' ? TRANSITION + 'End' : 'transitionend'; - - -// @function get(id: String|HTMLElement): HTMLElement -// Returns an element given its DOM id, or returns the element itself -// if it was passed directly. -function get(id) { - return typeof id === 'string' ? document.getElementById(id) : id; -} - -// @function getStyle(el: HTMLElement, styleAttrib: String): String -// Returns the value for a certain style attribute on an element, -// including computed values or values set through CSS. -function getStyle(el, style) { - var value = el.style[style] || (el.currentStyle && el.currentStyle[style]); - - if ((!value || value === 'auto') && document.defaultView) { - var css = document.defaultView.getComputedStyle(el, null); - value = css ? css[style] : null; - } - return value === 'auto' ? null : value; -} - -// @function create(tagName: String, className?: String, container?: HTMLElement): HTMLElement -// Creates an HTML element with `tagName`, sets its class to `className`, and optionally appends it to `container` element. -function create$1(tagName, className, container) { - var el = document.createElement(tagName); - el.className = className || ''; - - if (container) { - container.appendChild(el); - } - return el; -} - -// @function remove(el: HTMLElement) -// Removes `el` from its parent element -function remove(el) { - var parent = el.parentNode; - if (parent) { - parent.removeChild(el); - } -} - -// @function empty(el: HTMLElement) -// Removes all of `el`'s children elements from `el` -function empty(el) { - while (el.firstChild) { - el.removeChild(el.firstChild); - } -} - -// @function toFront(el: HTMLElement) -// Makes `el` the last child of its parent, so it renders in front of the other children. -function toFront(el) { - var parent = el.parentNode; - if (parent && parent.lastChild !== el) { - parent.appendChild(el); - } -} - -// @function toBack(el: HTMLElement) -// Makes `el` the first child of its parent, so it renders behind the other children. -function toBack(el) { - var parent = el.parentNode; - if (parent && parent.firstChild !== el) { - parent.insertBefore(el, parent.firstChild); - } -} - -// @function hasClass(el: HTMLElement, name: String): Boolean -// Returns `true` if the element's class attribute contains `name`. -function hasClass(el, name) { - if (el.classList !== undefined) { - return el.classList.contains(name); - } - var className = getClass(el); - return className.length > 0 && new RegExp('(^|\\s)' + name + '(\\s|$)').test(className); -} - -// @function addClass(el: HTMLElement, name: String) -// Adds `name` to the element's class attribute. -function addClass(el, name) { - if (el.classList !== undefined) { - var classes = splitWords(name); - for (var i = 0, len = classes.length; i < len; i++) { - el.classList.add(classes[i]); - } - } else if (!hasClass(el, name)) { - var className = getClass(el); - setClass(el, (className ? className + ' ' : '') + name); - } -} - -// @function removeClass(el: HTMLElement, name: String) -// Removes `name` from the element's class attribute. -function removeClass(el, name) { - if (el.classList !== undefined) { - el.classList.remove(name); - } else { - setClass(el, trim((' ' + getClass(el) + ' ').replace(' ' + name + ' ', ' '))); - } -} - -// @function setClass(el: HTMLElement, name: String) -// Sets the element's class. -function setClass(el, name) { - if (el.className.baseVal === undefined) { - el.className = name; - } else { - // in case of SVG element - el.className.baseVal = name; - } -} - -// @function getClass(el: HTMLElement): String -// Returns the element's class. -function getClass(el) { - // Check if the element is an SVGElementInstance and use the correspondingElement instead - // (Required for linked SVG elements in IE11.) - if (el.correspondingElement) { - el = el.correspondingElement; - } - return el.className.baseVal === undefined ? el.className : el.className.baseVal; -} - -// @function setOpacity(el: HTMLElement, opacity: Number) -// Set the opacity of an element (including old IE support). -// `opacity` must be a number from `0` to `1`. -function setOpacity(el, value) { - if ('opacity' in el.style) { - el.style.opacity = value; - } else if ('filter' in el.style) { - _setOpacityIE(el, value); - } -} - -function _setOpacityIE(el, value) { - var filter = false, - filterName = 'DXImageTransform.Microsoft.Alpha'; - - // filters collection throws an error if we try to retrieve a filter that doesn't exist - try { - filter = el.filters.item(filterName); - } catch (e) { - // don't set opacity to 1 if we haven't already set an opacity, - // it isn't needed and breaks transparent pngs. - if (value === 1) { return; } - } - - value = Math.round(value * 100); - - if (filter) { - filter.Enabled = (value !== 100); - filter.Opacity = value; - } else { - el.style.filter += ' progid:' + filterName + '(opacity=' + value + ')'; - } -} - -// @function testProp(props: String[]): String|false -// Goes through the array of style names and returns the first name -// that is a valid style name for an element. If no such name is found, -// it returns false. Useful for vendor-prefixed styles like `transform`. -function testProp(props) { - var style = document.documentElement.style; - - for (var i = 0; i < props.length; i++) { - if (props[i] in style) { - return props[i]; - } - } - return false; -} - -// @function setTransform(el: HTMLElement, offset: Point, scale?: Number) -// Resets the 3D CSS transform of `el` so it is translated by `offset` pixels -// and optionally scaled by `scale`. Does not have an effect if the -// browser doesn't support 3D CSS transforms. -function setTransform(el, offset, scale) { - var pos = offset || new Point(0, 0); - - el.style[TRANSFORM] = - (ie3d ? - 'translate(' + pos.x + 'px,' + pos.y + 'px)' : - 'translate3d(' + pos.x + 'px,' + pos.y + 'px,0)') + - (scale ? ' scale(' + scale + ')' : ''); -} - -// @function setPosition(el: HTMLElement, position: Point) -// Sets the position of `el` to coordinates specified by `position`, -// using CSS translate or top/left positioning depending on the browser -// (used by Leaflet internally to position its layers). -function setPosition(el, point) { - - /*eslint-disable */ - el._leaflet_pos = point; - /* eslint-enable */ - - if (any3d) { - setTransform(el, point); - } else { - el.style.left = point.x + 'px'; - el.style.top = point.y + 'px'; - } -} - -// @function getPosition(el: HTMLElement): Point -// Returns the coordinates of an element previously positioned with setPosition. -function getPosition(el) { - // this method is only used for elements previously positioned using setPosition, - // so it's safe to cache the position for performance - - return el._leaflet_pos || new Point(0, 0); -} - -// @function disableTextSelection() -// Prevents the user from generating `selectstart` DOM events, usually generated -// when the user drags the mouse through a page with text. Used internally -// by Leaflet to override the behaviour of any click-and-drag interaction on -// the map. Affects drag interactions on the whole document. - -// @function enableTextSelection() -// Cancels the effects of a previous [`L.DomUtil.disableTextSelection`](#domutil-disabletextselection). -var disableTextSelection; -var enableTextSelection; -var _userSelect; -if ('onselectstart' in document) { - disableTextSelection = function () { - on(window, 'selectstart', preventDefault); - }; - enableTextSelection = function () { - off(window, 'selectstart', preventDefault); - }; -} else { - var userSelectProperty = testProp( - ['userSelect', 'WebkitUserSelect', 'OUserSelect', 'MozUserSelect', 'msUserSelect']); - - disableTextSelection = function () { - if (userSelectProperty) { - var style = document.documentElement.style; - _userSelect = style[userSelectProperty]; - style[userSelectProperty] = 'none'; - } - }; - enableTextSelection = function () { - if (userSelectProperty) { - document.documentElement.style[userSelectProperty] = _userSelect; - _userSelect = undefined; - } - }; -} - -// @function disableImageDrag() -// As [`L.DomUtil.disableTextSelection`](#domutil-disabletextselection), but -// for `dragstart` DOM events, usually generated when the user drags an image. -function disableImageDrag() { - on(window, 'dragstart', preventDefault); -} - -// @function enableImageDrag() -// Cancels the effects of a previous [`L.DomUtil.disableImageDrag`](#domutil-disabletextselection). -function enableImageDrag() { - off(window, 'dragstart', preventDefault); -} - -var _outlineElement, _outlineStyle; -// @function preventOutline(el: HTMLElement) -// Makes the [outline](https://developer.mozilla.org/docs/Web/CSS/outline) -// of the element `el` invisible. Used internally by Leaflet to prevent -// focusable elements from displaying an outline when the user performs a -// drag interaction on them. -function preventOutline(element) { - while (element.tabIndex === -1) { - element = element.parentNode; - } - if (!element.style) { return; } - restoreOutline(); - _outlineElement = element; - _outlineStyle = element.style.outline; - element.style.outline = 'none'; - on(window, 'keydown', restoreOutline); -} - -// @function restoreOutline() -// Cancels the effects of a previous [`L.DomUtil.preventOutline`](). -function restoreOutline() { - if (!_outlineElement) { return; } - _outlineElement.style.outline = _outlineStyle; - _outlineElement = undefined; - _outlineStyle = undefined; - off(window, 'keydown', restoreOutline); -} - -// @function getSizedParentNode(el: HTMLElement): HTMLElement -// Finds the closest parent node which size (width and height) is not null. -function getSizedParentNode(element) { - do { - element = element.parentNode; - } while ((!element.offsetWidth || !element.offsetHeight) && element !== document.body); - return element; -} - -// @function getScale(el: HTMLElement): Object -// Computes the CSS scale currently applied on the element. -// Returns an object with `x` and `y` members as horizontal and vertical scales respectively, -// and `boundingClientRect` as the result of [`getBoundingClientRect()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect). -function getScale(element) { - var rect = element.getBoundingClientRect(); // Read-only in old browsers. - - return { - x: rect.width / element.offsetWidth || 1, - y: rect.height / element.offsetHeight || 1, - boundingClientRect: rect - }; +/* + * @namespace DomUtil + * + * Utility functions to work with the [DOM](https://developer.mozilla.org/docs/Web/API/Document_Object_Model) + * tree, used by Leaflet internally. + * + * Most functions expecting or returning a `HTMLElement` also work for + * SVG elements. The only difference is that classes refer to CSS classes + * in HTML and SVG classes in SVG. + */ + + +// @property TRANSFORM: String +// Vendor-prefixed transform style name (e.g. `'webkitTransform'` for WebKit). +var TRANSFORM = testProp( + ['transform', 'webkitTransform', 'OTransform', 'MozTransform', 'msTransform']); + +// webkitTransition comes first because some browser versions that drop vendor prefix don't do +// the same for the transitionend event, in particular the Android 4.1 stock browser + +// @property TRANSITION: String +// Vendor-prefixed transition style name. +var TRANSITION = testProp( + ['webkitTransition', 'transition', 'OTransition', 'MozTransition', 'msTransition']); + +// @property TRANSITION_END: String +// Vendor-prefixed transitionend event name. +var TRANSITION_END = + TRANSITION === 'webkitTransition' || TRANSITION === 'OTransition' ? TRANSITION + 'End' : 'transitionend'; + + +// @function get(id: String|HTMLElement): HTMLElement +// Returns an element given its DOM id, or returns the element itself +// if it was passed directly. +function get(id) { + return typeof id === 'string' ? document.getElementById(id) : id; +} + +// @function getStyle(el: HTMLElement, styleAttrib: String): String +// Returns the value for a certain style attribute on an element, +// including computed values or values set through CSS. +function getStyle(el, style) { + var value = el.style[style] || (el.currentStyle && el.currentStyle[style]); + + if ((!value || value === 'auto') && document.defaultView) { + var css = document.defaultView.getComputedStyle(el, null); + value = css ? css[style] : null; + } + return value === 'auto' ? null : value; +} + +// @function create(tagName: String, className?: String, container?: HTMLElement): HTMLElement +// Creates an HTML element with `tagName`, sets its class to `className`, and optionally appends it to `container` element. +function create$1(tagName, className, container) { + var el = document.createElement(tagName); + el.className = className || ''; + + if (container) { + container.appendChild(el); + } + return el; +} + +// @function remove(el: HTMLElement) +// Removes `el` from its parent element +function remove(el) { + var parent = el.parentNode; + if (parent) { + parent.removeChild(el); + } +} + +// @function empty(el: HTMLElement) +// Removes all of `el`'s children elements from `el` +function empty(el) { + while (el.firstChild) { + el.removeChild(el.firstChild); + } +} + +// @function toFront(el: HTMLElement) +// Makes `el` the last child of its parent, so it renders in front of the other children. +function toFront(el) { + var parent = el.parentNode; + if (parent && parent.lastChild !== el) { + parent.appendChild(el); + } +} + +// @function toBack(el: HTMLElement) +// Makes `el` the first child of its parent, so it renders behind the other children. +function toBack(el) { + var parent = el.parentNode; + if (parent && parent.firstChild !== el) { + parent.insertBefore(el, parent.firstChild); + } +} + +// @function hasClass(el: HTMLElement, name: String): Boolean +// Returns `true` if the element's class attribute contains `name`. +function hasClass(el, name) { + if (el.classList !== undefined) { + return el.classList.contains(name); + } + var className = getClass(el); + return className.length > 0 && new RegExp('(^|\\s)' + name + '(\\s|$)').test(className); +} + +// @function addClass(el: HTMLElement, name: String) +// Adds `name` to the element's class attribute. +function addClass(el, name) { + if (el.classList !== undefined) { + var classes = splitWords(name); + for (var i = 0, len = classes.length; i < len; i++) { + el.classList.add(classes[i]); + } + } else if (!hasClass(el, name)) { + var className = getClass(el); + setClass(el, (className ? className + ' ' : '') + name); + } +} + +// @function removeClass(el: HTMLElement, name: String) +// Removes `name` from the element's class attribute. +function removeClass(el, name) { + if (el.classList !== undefined) { + el.classList.remove(name); + } else { + setClass(el, trim((' ' + getClass(el) + ' ').replace(' ' + name + ' ', ' '))); + } +} + +// @function setClass(el: HTMLElement, name: String) +// Sets the element's class. +function setClass(el, name) { + if (el.className.baseVal === undefined) { + el.className = name; + } else { + // in case of SVG element + el.className.baseVal = name; + } +} + +// @function getClass(el: HTMLElement): String +// Returns the element's class. +function getClass(el) { + // Check if the element is an SVGElementInstance and use the correspondingElement instead + // (Required for linked SVG elements in IE11.) + if (el.correspondingElement) { + el = el.correspondingElement; + } + return el.className.baseVal === undefined ? el.className : el.className.baseVal; +} + +// @function setOpacity(el: HTMLElement, opacity: Number) +// Set the opacity of an element (including old IE support). +// `opacity` must be a number from `0` to `1`. +function setOpacity(el, value) { + if ('opacity' in el.style) { + el.style.opacity = value; + } else if ('filter' in el.style) { + _setOpacityIE(el, value); + } +} + +function _setOpacityIE(el, value) { + var filter = false, + filterName = 'DXImageTransform.Microsoft.Alpha'; + + // filters collection throws an error if we try to retrieve a filter that doesn't exist + try { + filter = el.filters.item(filterName); + } catch (e) { + // don't set opacity to 1 if we haven't already set an opacity, + // it isn't needed and breaks transparent pngs. + if (value === 1) { return; } + } + + value = Math.round(value * 100); + + if (filter) { + filter.Enabled = (value !== 100); + filter.Opacity = value; + } else { + el.style.filter += ' progid:' + filterName + '(opacity=' + value + ')'; + } +} + +// @function testProp(props: String[]): String|false +// Goes through the array of style names and returns the first name +// that is a valid style name for an element. If no such name is found, +// it returns false. Useful for vendor-prefixed styles like `transform`. +function testProp(props) { + var style = document.documentElement.style; + + for (var i = 0; i < props.length; i++) { + if (props[i] in style) { + return props[i]; + } + } + return false; +} + +// @function setTransform(el: HTMLElement, offset: Point, scale?: Number) +// Resets the 3D CSS transform of `el` so it is translated by `offset` pixels +// and optionally scaled by `scale`. Does not have an effect if the +// browser doesn't support 3D CSS transforms. +function setTransform(el, offset, scale) { + var pos = offset || new Point(0, 0); + + el.style[TRANSFORM] = + (ie3d ? + 'translate(' + pos.x + 'px,' + pos.y + 'px)' : + 'translate3d(' + pos.x + 'px,' + pos.y + 'px,0)') + + (scale ? ' scale(' + scale + ')' : ''); +} + +// @function setPosition(el: HTMLElement, position: Point) +// Sets the position of `el` to coordinates specified by `position`, +// using CSS translate or top/left positioning depending on the browser +// (used by Leaflet internally to position its layers). +function setPosition(el, point) { + + /*eslint-disable */ + el._leaflet_pos = point; + /* eslint-enable */ + + if (any3d) { + setTransform(el, point); + } else { + el.style.left = point.x + 'px'; + el.style.top = point.y + 'px'; + } +} + +// @function getPosition(el: HTMLElement): Point +// Returns the coordinates of an element previously positioned with setPosition. +function getPosition(el) { + // this method is only used for elements previously positioned using setPosition, + // so it's safe to cache the position for performance + + return el._leaflet_pos || new Point(0, 0); +} + +// @function disableTextSelection() +// Prevents the user from generating `selectstart` DOM events, usually generated +// when the user drags the mouse through a page with text. Used internally +// by Leaflet to override the behaviour of any click-and-drag interaction on +// the map. Affects drag interactions on the whole document. + +// @function enableTextSelection() +// Cancels the effects of a previous [`L.DomUtil.disableTextSelection`](#domutil-disabletextselection). +var disableTextSelection; +var enableTextSelection; +var _userSelect; +if ('onselectstart' in document) { + disableTextSelection = function () { + on(window, 'selectstart', preventDefault); + }; + enableTextSelection = function () { + off(window, 'selectstart', preventDefault); + }; +} else { + var userSelectProperty = testProp( + ['userSelect', 'WebkitUserSelect', 'OUserSelect', 'MozUserSelect', 'msUserSelect']); + + disableTextSelection = function () { + if (userSelectProperty) { + var style = document.documentElement.style; + _userSelect = style[userSelectProperty]; + style[userSelectProperty] = 'none'; + } + }; + enableTextSelection = function () { + if (userSelectProperty) { + document.documentElement.style[userSelectProperty] = _userSelect; + _userSelect = undefined; + } + }; +} + +// @function disableImageDrag() +// As [`L.DomUtil.disableTextSelection`](#domutil-disabletextselection), but +// for `dragstart` DOM events, usually generated when the user drags an image. +function disableImageDrag() { + on(window, 'dragstart', preventDefault); +} + +// @function enableImageDrag() +// Cancels the effects of a previous [`L.DomUtil.disableImageDrag`](#domutil-disabletextselection). +function enableImageDrag() { + off(window, 'dragstart', preventDefault); +} + +var _outlineElement, _outlineStyle; +// @function preventOutline(el: HTMLElement) +// Makes the [outline](https://developer.mozilla.org/docs/Web/CSS/outline) +// of the element `el` invisible. Used internally by Leaflet to prevent +// focusable elements from displaying an outline when the user performs a +// drag interaction on them. +function preventOutline(element) { + while (element.tabIndex === -1) { + element = element.parentNode; + } + if (!element.style) { return; } + restoreOutline(); + _outlineElement = element; + _outlineStyle = element.style.outline; + element.style.outline = 'none'; + on(window, 'keydown', restoreOutline); +} + +// @function restoreOutline() +// Cancels the effects of a previous [`L.DomUtil.preventOutline`](). +function restoreOutline() { + if (!_outlineElement) { return; } + _outlineElement.style.outline = _outlineStyle; + _outlineElement = undefined; + _outlineStyle = undefined; + off(window, 'keydown', restoreOutline); +} + +// @function getSizedParentNode(el: HTMLElement): HTMLElement +// Finds the closest parent node which size (width and height) is not null. +function getSizedParentNode(element) { + do { + element = element.parentNode; + } while ((!element.offsetWidth || !element.offsetHeight) && element !== document.body); + return element; +} + +// @function getScale(el: HTMLElement): Object +// Computes the CSS scale currently applied on the element. +// Returns an object with `x` and `y` members as horizontal and vertical scales respectively, +// and `boundingClientRect` as the result of [`getBoundingClientRect()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect). +function getScale(element) { + var rect = element.getBoundingClientRect(); // Read-only in old browsers. + + return { + x: rect.width / element.offsetWidth || 1, + y: rect.height / element.offsetHeight || 1, + boundingClientRect: rect + }; } var DomUtil = ({ @@ -2576,282 +2576,282 @@ var DomUtil = ({ getScale: getScale }); -/* - * @namespace DomEvent - * Utility functions to work with the [DOM events](https://developer.mozilla.org/docs/Web/API/Event), used by Leaflet internally. - */ - -// Inspired by John Resig, Dean Edwards and YUI addEvent implementations. - -// @function on(el: HTMLElement, types: String, fn: Function, context?: Object): this -// Adds a listener function (`fn`) to a particular DOM event type of the -// element `el`. You can optionally specify the context of the listener -// (object the `this` keyword will point to). You can also pass several -// space-separated types (e.g. `'click dblclick'`). - -// @alternative -// @function on(el: HTMLElement, eventMap: Object, context?: Object): this -// Adds a set of type/listener pairs, e.g. `{click: onClick, mousemove: onMouseMove}` -function on(obj, types, fn, context) { - - if (typeof types === 'object') { - for (var type in types) { - addOne(obj, type, types[type], fn); - } - } else { - types = splitWords(types); - - for (var i = 0, len = types.length; i < len; i++) { - addOne(obj, types[i], fn, context); - } - } - - return this; -} - -var eventsKey = '_leaflet_events'; - -// @function off(el: HTMLElement, types: String, fn: Function, context?: Object): this -// Removes a previously added listener function. -// Note that if you passed a custom context to on, you must pass the same -// context to `off` in order to remove the listener. - -// @alternative -// @function off(el: HTMLElement, eventMap: Object, context?: Object): this -// Removes a set of type/listener pairs, e.g. `{click: onClick, mousemove: onMouseMove}` -function off(obj, types, fn, context) { - - if (typeof types === 'object') { - for (var type in types) { - removeOne(obj, type, types[type], fn); - } - } else if (types) { - types = splitWords(types); - - for (var i = 0, len = types.length; i < len; i++) { - removeOne(obj, types[i], fn, context); - } - } else { - for (var j in obj[eventsKey]) { - removeOne(obj, j, obj[eventsKey][j]); - } - delete obj[eventsKey]; - } - - return this; -} - -function browserFiresNativeDblClick() { - // See https://github.com/w3c/pointerevents/issues/171 - if (pointer) { - return !(edge || safari); - } -} - -var mouseSubst = { - mouseenter: 'mouseover', - mouseleave: 'mouseout', - wheel: !('onwheel' in window) && 'mousewheel' -}; - -function addOne(obj, type, fn, context) { - var id = type + stamp(fn) + (context ? '_' + stamp(context) : ''); - - if (obj[eventsKey] && obj[eventsKey][id]) { return this; } - - var handler = function (e) { - return fn.call(context || obj, e || window.event); - }; - - var originalHandler = handler; - - if (pointer && type.indexOf('touch') === 0) { - // Needs DomEvent.Pointer.js - addPointerListener(obj, type, handler, id); - - } else if (touch && (type === 'dblclick') && !browserFiresNativeDblClick()) { - addDoubleTapListener(obj, handler, id); - - } else if ('addEventListener' in obj) { - - if (type === 'touchstart' || type === 'touchmove' || type === 'wheel' || type === 'mousewheel') { - obj.addEventListener(mouseSubst[type] || type, handler, passiveEvents ? {passive: false} : false); - - } else if (type === 'mouseenter' || type === 'mouseleave') { - handler = function (e) { - e = e || window.event; - if (isExternalTarget(obj, e)) { - originalHandler(e); - } - }; - obj.addEventListener(mouseSubst[type], handler, false); - - } else { - obj.addEventListener(type, originalHandler, false); - } - - } else if ('attachEvent' in obj) { - obj.attachEvent('on' + type, handler); - } - - obj[eventsKey] = obj[eventsKey] || {}; - obj[eventsKey][id] = handler; -} - -function removeOne(obj, type, fn, context) { - - var id = type + stamp(fn) + (context ? '_' + stamp(context) : ''), - handler = obj[eventsKey] && obj[eventsKey][id]; - - if (!handler) { return this; } - - if (pointer && type.indexOf('touch') === 0) { - removePointerListener(obj, type, id); - - } else if (touch && (type === 'dblclick') && !browserFiresNativeDblClick()) { - removeDoubleTapListener(obj, id); - - } else if ('removeEventListener' in obj) { - - obj.removeEventListener(mouseSubst[type] || type, handler, false); - - } else if ('detachEvent' in obj) { - obj.detachEvent('on' + type, handler); - } - - obj[eventsKey][id] = null; -} - -// @function stopPropagation(ev: DOMEvent): this -// Stop the given event from propagation to parent elements. Used inside the listener functions: -// ```js -// L.DomEvent.on(div, 'click', function (ev) { -// L.DomEvent.stopPropagation(ev); -// }); -// ``` -function stopPropagation(e) { - - if (e.stopPropagation) { - e.stopPropagation(); - } else if (e.originalEvent) { // In case of Leaflet event. - e.originalEvent._stopped = true; - } else { - e.cancelBubble = true; - } - skipped(e); - - return this; -} - -// @function disableScrollPropagation(el: HTMLElement): this -// Adds `stopPropagation` to the element's `'wheel'` events (plus browser variants). -function disableScrollPropagation(el) { - addOne(el, 'wheel', stopPropagation); - return this; -} - -// @function disableClickPropagation(el: HTMLElement): this -// Adds `stopPropagation` to the element's `'click'`, `'doubleclick'`, -// `'mousedown'` and `'touchstart'` events (plus browser variants). -function disableClickPropagation(el) { - on(el, 'mousedown touchstart dblclick', stopPropagation); - addOne(el, 'click', fakeStop); - return this; -} - -// @function preventDefault(ev: DOMEvent): this -// Prevents the default action of the DOM Event `ev` from happening (such as -// following a link in the href of the a element, or doing a POST request -// with page reload when a `
      ` is submitted). -// Use it inside listener functions. -function preventDefault(e) { - if (e.preventDefault) { - e.preventDefault(); - } else { - e.returnValue = false; - } - return this; -} - -// @function stop(ev: DOMEvent): this -// Does `stopPropagation` and `preventDefault` at the same time. -function stop(e) { - preventDefault(e); - stopPropagation(e); - return this; -} - -// @function getMousePosition(ev: DOMEvent, container?: HTMLElement): Point -// Gets normalized mouse position from a DOM event relative to the -// `container` (border excluded) or to the whole page if not specified. -function getMousePosition(e, container) { - if (!container) { - return new Point(e.clientX, e.clientY); - } - - var scale = getScale(container), - offset = scale.boundingClientRect; // left and top values are in page scale (like the event clientX/Y) - - return new Point( - // offset.left/top values are in page scale (like clientX/Y), - // whereas clientLeft/Top (border width) values are the original values (before CSS scale applies). - (e.clientX - offset.left) / scale.x - container.clientLeft, - (e.clientY - offset.top) / scale.y - container.clientTop - ); -} - -// Chrome on Win scrolls double the pixels as in other platforms (see #4538), -// and Firefox scrolls device pixels, not CSS pixels -var wheelPxFactor = - (win && chrome) ? 2 * window.devicePixelRatio : - gecko ? window.devicePixelRatio : 1; - -// @function getWheelDelta(ev: DOMEvent): Number -// Gets normalized wheel delta from a wheel DOM event, in vertical -// pixels scrolled (negative if scrolling down). -// Events from pointing devices without precise scrolling are mapped to -// a best guess of 60 pixels. -function getWheelDelta(e) { - return (edge) ? e.wheelDeltaY / 2 : // Don't trust window-geometry-based delta - (e.deltaY && e.deltaMode === 0) ? -e.deltaY / wheelPxFactor : // Pixels - (e.deltaY && e.deltaMode === 1) ? -e.deltaY * 20 : // Lines - (e.deltaY && e.deltaMode === 2) ? -e.deltaY * 60 : // Pages - (e.deltaX || e.deltaZ) ? 0 : // Skip horizontal/depth wheel events - e.wheelDelta ? (e.wheelDeltaY || e.wheelDelta) / 2 : // Legacy IE pixels - (e.detail && Math.abs(e.detail) < 32765) ? -e.detail * 20 : // Legacy Moz lines - e.detail ? e.detail / -32765 * 60 : // Legacy Moz pages - 0; -} - -var skipEvents = {}; - -function fakeStop(e) { - // fakes stopPropagation by setting a special event flag, checked/reset with skipped(e) - skipEvents[e.type] = true; -} - -function skipped(e) { - var events = skipEvents[e.type]; - // reset when checking, as it's only used in map container and propagates outside of the map - skipEvents[e.type] = false; - return events; -} - -// check if element really left/entered the event target (for mouseenter/mouseleave) -function isExternalTarget(el, e) { - - var related = e.relatedTarget; - - if (!related) { return true; } - - try { - while (related && (related !== el)) { - related = related.parentNode; - } - } catch (err) { - return false; - } - return (related !== el); +/* + * @namespace DomEvent + * Utility functions to work with the [DOM events](https://developer.mozilla.org/docs/Web/API/Event), used by Leaflet internally. + */ + +// Inspired by John Resig, Dean Edwards and YUI addEvent implementations. + +// @function on(el: HTMLElement, types: String, fn: Function, context?: Object): this +// Adds a listener function (`fn`) to a particular DOM event type of the +// element `el`. You can optionally specify the context of the listener +// (object the `this` keyword will point to). You can also pass several +// space-separated types (e.g. `'click dblclick'`). + +// @alternative +// @function on(el: HTMLElement, eventMap: Object, context?: Object): this +// Adds a set of type/listener pairs, e.g. `{click: onClick, mousemove: onMouseMove}` +function on(obj, types, fn, context) { + + if (typeof types === 'object') { + for (var type in types) { + addOne(obj, type, types[type], fn); + } + } else { + types = splitWords(types); + + for (var i = 0, len = types.length; i < len; i++) { + addOne(obj, types[i], fn, context); + } + } + + return this; +} + +var eventsKey = '_leaflet_events'; + +// @function off(el: HTMLElement, types: String, fn: Function, context?: Object): this +// Removes a previously added listener function. +// Note that if you passed a custom context to on, you must pass the same +// context to `off` in order to remove the listener. + +// @alternative +// @function off(el: HTMLElement, eventMap: Object, context?: Object): this +// Removes a set of type/listener pairs, e.g. `{click: onClick, mousemove: onMouseMove}` +function off(obj, types, fn, context) { + + if (typeof types === 'object') { + for (var type in types) { + removeOne(obj, type, types[type], fn); + } + } else if (types) { + types = splitWords(types); + + for (var i = 0, len = types.length; i < len; i++) { + removeOne(obj, types[i], fn, context); + } + } else { + for (var j in obj[eventsKey]) { + removeOne(obj, j, obj[eventsKey][j]); + } + delete obj[eventsKey]; + } + + return this; +} + +function browserFiresNativeDblClick() { + // See https://github.com/w3c/pointerevents/issues/171 + if (pointer) { + return !(edge || safari); + } +} + +var mouseSubst = { + mouseenter: 'mouseover', + mouseleave: 'mouseout', + wheel: !('onwheel' in window) && 'mousewheel' +}; + +function addOne(obj, type, fn, context) { + var id = type + stamp(fn) + (context ? '_' + stamp(context) : ''); + + if (obj[eventsKey] && obj[eventsKey][id]) { return this; } + + var handler = function (e) { + return fn.call(context || obj, e || window.event); + }; + + var originalHandler = handler; + + if (pointer && type.indexOf('touch') === 0) { + // Needs DomEvent.Pointer.js + addPointerListener(obj, type, handler, id); + + } else if (touch && (type === 'dblclick') && !browserFiresNativeDblClick()) { + addDoubleTapListener(obj, handler, id); + + } else if ('addEventListener' in obj) { + + if (type === 'touchstart' || type === 'touchmove' || type === 'wheel' || type === 'mousewheel') { + obj.addEventListener(mouseSubst[type] || type, handler, passiveEvents ? {passive: false} : false); + + } else if (type === 'mouseenter' || type === 'mouseleave') { + handler = function (e) { + e = e || window.event; + if (isExternalTarget(obj, e)) { + originalHandler(e); + } + }; + obj.addEventListener(mouseSubst[type], handler, false); + + } else { + obj.addEventListener(type, originalHandler, false); + } + + } else if ('attachEvent' in obj) { + obj.attachEvent('on' + type, handler); + } + + obj[eventsKey] = obj[eventsKey] || {}; + obj[eventsKey][id] = handler; +} + +function removeOne(obj, type, fn, context) { + + var id = type + stamp(fn) + (context ? '_' + stamp(context) : ''), + handler = obj[eventsKey] && obj[eventsKey][id]; + + if (!handler) { return this; } + + if (pointer && type.indexOf('touch') === 0) { + removePointerListener(obj, type, id); + + } else if (touch && (type === 'dblclick') && !browserFiresNativeDblClick()) { + removeDoubleTapListener(obj, id); + + } else if ('removeEventListener' in obj) { + + obj.removeEventListener(mouseSubst[type] || type, handler, false); + + } else if ('detachEvent' in obj) { + obj.detachEvent('on' + type, handler); + } + + obj[eventsKey][id] = null; +} + +// @function stopPropagation(ev: DOMEvent): this +// Stop the given event from propagation to parent elements. Used inside the listener functions: +// ```js +// L.DomEvent.on(div, 'click', function (ev) { +// L.DomEvent.stopPropagation(ev); +// }); +// ``` +function stopPropagation(e) { + + if (e.stopPropagation) { + e.stopPropagation(); + } else if (e.originalEvent) { // In case of Leaflet event. + e.originalEvent._stopped = true; + } else { + e.cancelBubble = true; + } + skipped(e); + + return this; +} + +// @function disableScrollPropagation(el: HTMLElement): this +// Adds `stopPropagation` to the element's `'wheel'` events (plus browser variants). +function disableScrollPropagation(el) { + addOne(el, 'wheel', stopPropagation); + return this; +} + +// @function disableClickPropagation(el: HTMLElement): this +// Adds `stopPropagation` to the element's `'click'`, `'doubleclick'`, +// `'mousedown'` and `'touchstart'` events (plus browser variants). +function disableClickPropagation(el) { + on(el, 'mousedown touchstart dblclick', stopPropagation); + addOne(el, 'click', fakeStop); + return this; +} + +// @function preventDefault(ev: DOMEvent): this +// Prevents the default action of the DOM Event `ev` from happening (such as +// following a link in the href of the a element, or doing a POST request +// with page reload when a `` is submitted). +// Use it inside listener functions. +function preventDefault(e) { + if (e.preventDefault) { + e.preventDefault(); + } else { + e.returnValue = false; + } + return this; +} + +// @function stop(ev: DOMEvent): this +// Does `stopPropagation` and `preventDefault` at the same time. +function stop(e) { + preventDefault(e); + stopPropagation(e); + return this; +} + +// @function getMousePosition(ev: DOMEvent, container?: HTMLElement): Point +// Gets normalized mouse position from a DOM event relative to the +// `container` (border excluded) or to the whole page if not specified. +function getMousePosition(e, container) { + if (!container) { + return new Point(e.clientX, e.clientY); + } + + var scale = getScale(container), + offset = scale.boundingClientRect; // left and top values are in page scale (like the event clientX/Y) + + return new Point( + // offset.left/top values are in page scale (like clientX/Y), + // whereas clientLeft/Top (border width) values are the original values (before CSS scale applies). + (e.clientX - offset.left) / scale.x - container.clientLeft, + (e.clientY - offset.top) / scale.y - container.clientTop + ); +} + +// Chrome on Win scrolls double the pixels as in other platforms (see #4538), +// and Firefox scrolls device pixels, not CSS pixels +var wheelPxFactor = + (win && chrome) ? 2 * window.devicePixelRatio : + gecko ? window.devicePixelRatio : 1; + +// @function getWheelDelta(ev: DOMEvent): Number +// Gets normalized wheel delta from a wheel DOM event, in vertical +// pixels scrolled (negative if scrolling down). +// Events from pointing devices without precise scrolling are mapped to +// a best guess of 60 pixels. +function getWheelDelta(e) { + return (edge) ? e.wheelDeltaY / 2 : // Don't trust window-geometry-based delta + (e.deltaY && e.deltaMode === 0) ? -e.deltaY / wheelPxFactor : // Pixels + (e.deltaY && e.deltaMode === 1) ? -e.deltaY * 20 : // Lines + (e.deltaY && e.deltaMode === 2) ? -e.deltaY * 60 : // Pages + (e.deltaX || e.deltaZ) ? 0 : // Skip horizontal/depth wheel events + e.wheelDelta ? (e.wheelDeltaY || e.wheelDelta) / 2 : // Legacy IE pixels + (e.detail && Math.abs(e.detail) < 32765) ? -e.detail * 20 : // Legacy Moz lines + e.detail ? e.detail / -32765 * 60 : // Legacy Moz pages + 0; +} + +var skipEvents = {}; + +function fakeStop(e) { + // fakes stopPropagation by setting a special event flag, checked/reset with skipped(e) + skipEvents[e.type] = true; +} + +function skipped(e) { + var events = skipEvents[e.type]; + // reset when checking, as it's only used in map container and propagates outside of the map + skipEvents[e.type] = false; + return events; +} + +// check if element really left/entered the event target (for mouseenter/mouseleave) +function isExternalTarget(el, e) { + + var related = e.relatedTarget; + + if (!related) { return true; } + + try { + while (related && (related !== el)) { + related = related.parentNode; + } + } catch (err) { + return false; + } + return (related !== el); } var DomEvent = ({ @@ -2967,2453 +2967,2453 @@ var PosAnimation = Evented.extend({ } }); -/* - * @class Map - * @aka L.Map - * @inherits Evented - * - * The central class of the API — it is used to create a map on a page and manipulate it. - * - * @example - * - * ```js - * // initialize the map on the "map" div with a given center and zoom - * var map = L.map('map', { - * center: [51.505, -0.09], - * zoom: 13 - * }); - * ``` - * - */ - -var Map = Evented.extend({ - - options: { - // @section Map State Options - // @option crs: CRS = L.CRS.EPSG3857 - // The [Coordinate Reference System](#crs) to use. Don't change this if you're not - // sure what it means. - crs: EPSG3857, - - // @option center: LatLng = undefined - // Initial geographic center of the map - center: undefined, - - // @option zoom: Number = undefined - // Initial map zoom level - zoom: undefined, - - // @option minZoom: Number = * - // Minimum zoom level of the map. - // If not specified and at least one `GridLayer` or `TileLayer` is in the map, - // the lowest of their `minZoom` options will be used instead. - minZoom: undefined, - - // @option maxZoom: Number = * - // Maximum zoom level of the map. - // If not specified and at least one `GridLayer` or `TileLayer` is in the map, - // the highest of their `maxZoom` options will be used instead. - maxZoom: undefined, - - // @option layers: Layer[] = [] - // Array of layers that will be added to the map initially - layers: [], - - // @option maxBounds: LatLngBounds = null - // When this option is set, the map restricts the view to the given - // geographical bounds, bouncing the user back if the user tries to pan - // outside the view. To set the restriction dynamically, use - // [`setMaxBounds`](#map-setmaxbounds) method. - maxBounds: undefined, - - // @option renderer: Renderer = * - // The default method for drawing vector layers on the map. `L.SVG` - // or `L.Canvas` by default depending on browser support. - renderer: undefined, - - - // @section Animation Options - // @option zoomAnimation: Boolean = true - // Whether the map zoom animation is enabled. By default it's enabled - // in all browsers that support CSS3 Transitions except Android. - zoomAnimation: true, - - // @option zoomAnimationThreshold: Number = 4 - // Won't animate zoom if the zoom difference exceeds this value. - zoomAnimationThreshold: 4, - - // @option fadeAnimation: Boolean = true - // Whether the tile fade animation is enabled. By default it's enabled - // in all browsers that support CSS3 Transitions except Android. - fadeAnimation: true, - - // @option markerZoomAnimation: Boolean = true - // Whether markers animate their zoom with the zoom animation, if disabled - // they will disappear for the length of the animation. By default it's - // enabled in all browsers that support CSS3 Transitions except Android. - markerZoomAnimation: true, - - // @option transform3DLimit: Number = 2^23 - // Defines the maximum size of a CSS translation transform. The default - // value should not be changed unless a web browser positions layers in - // the wrong place after doing a large `panBy`. - transform3DLimit: 8388608, // Precision limit of a 32-bit float - - // @section Interaction Options - // @option zoomSnap: Number = 1 - // Forces the map's zoom level to always be a multiple of this, particularly - // right after a [`fitBounds()`](#map-fitbounds) or a pinch-zoom. - // By default, the zoom level snaps to the nearest integer; lower values - // (e.g. `0.5` or `0.1`) allow for greater granularity. A value of `0` - // means the zoom level will not be snapped after `fitBounds` or a pinch-zoom. - zoomSnap: 1, - - // @option zoomDelta: Number = 1 - // Controls how much the map's zoom level will change after a - // [`zoomIn()`](#map-zoomin), [`zoomOut()`](#map-zoomout), pressing `+` - // or `-` on the keyboard, or using the [zoom controls](#control-zoom). - // Values smaller than `1` (e.g. `0.5`) allow for greater granularity. - zoomDelta: 1, - - // @option trackResize: Boolean = true - // Whether the map automatically handles browser window resize to update itself. - trackResize: true - }, - - initialize: function (id, options) { // (HTMLElement or String, Object) - options = setOptions(this, options); - - // Make sure to assign internal flags at the beginning, - // to avoid inconsistent state in some edge cases. - this._handlers = []; - this._layers = {}; - this._zoomBoundLayers = {}; - this._sizeChanged = true; - - this._initContainer(id); - this._initLayout(); - - // hack for https://github.com/Leaflet/Leaflet/issues/1980 - this._onResize = bind(this._onResize, this); - - this._initEvents(); - - if (options.maxBounds) { - this.setMaxBounds(options.maxBounds); - } - - if (options.zoom !== undefined) { - this._zoom = this._limitZoom(options.zoom); - } - - if (options.center && options.zoom !== undefined) { - this.setView(toLatLng(options.center), options.zoom, {reset: true}); - } - - this.callInitHooks(); - - // don't animate on browsers without hardware-accelerated transitions or old Android/Opera - this._zoomAnimated = TRANSITION && any3d && !mobileOpera && - this.options.zoomAnimation; - - // zoom transitions run with the same duration for all layers, so if one of transitionend events - // happens after starting zoom animation (propagating to the map pane), we know that it ended globally - if (this._zoomAnimated) { - this._createAnimProxy(); - on(this._proxy, TRANSITION_END, this._catchTransitionEnd, this); - } - - this._addLayers(this.options.layers); - }, - - - // @section Methods for modifying map state - - // @method setView(center: LatLng, zoom: Number, options?: Zoom/pan options): this - // Sets the view of the map (geographical center and zoom) with the given - // animation options. - setView: function (center, zoom, options) { - - zoom = zoom === undefined ? this._zoom : this._limitZoom(zoom); - center = this._limitCenter(toLatLng(center), zoom, this.options.maxBounds); - options = options || {}; - - this._stop(); - - if (this._loaded && !options.reset && options !== true) { - - if (options.animate !== undefined) { - options.zoom = extend({animate: options.animate}, options.zoom); - options.pan = extend({animate: options.animate, duration: options.duration}, options.pan); - } - - // try animating pan or zoom - var moved = (this._zoom !== zoom) ? - this._tryAnimatedZoom && this._tryAnimatedZoom(center, zoom, options.zoom) : - this._tryAnimatedPan(center, options.pan); - - if (moved) { - // prevent resize handler call, the view will refresh after animation anyway - clearTimeout(this._sizeTimer); - return this; - } - } - - // animation didn't start, just reset the map view - this._resetView(center, zoom); - - return this; - }, - - // @method setZoom(zoom: Number, options?: Zoom/pan options): this - // Sets the zoom of the map. - setZoom: function (zoom, options) { - if (!this._loaded) { - this._zoom = zoom; - return this; - } - return this.setView(this.getCenter(), zoom, {zoom: options}); - }, - - // @method zoomIn(delta?: Number, options?: Zoom options): this - // Increases the zoom of the map by `delta` ([`zoomDelta`](#map-zoomdelta) by default). - zoomIn: function (delta, options) { - delta = delta || (any3d ? this.options.zoomDelta : 1); - return this.setZoom(this._zoom + delta, options); - }, - - // @method zoomOut(delta?: Number, options?: Zoom options): this - // Decreases the zoom of the map by `delta` ([`zoomDelta`](#map-zoomdelta) by default). - zoomOut: function (delta, options) { - delta = delta || (any3d ? this.options.zoomDelta : 1); - return this.setZoom(this._zoom - delta, options); - }, - - // @method setZoomAround(latlng: LatLng, zoom: Number, options: Zoom options): this - // Zooms the map while keeping a specified geographical point on the map - // stationary (e.g. used internally for scroll zoom and double-click zoom). - // @alternative - // @method setZoomAround(offset: Point, zoom: Number, options: Zoom options): this - // Zooms the map while keeping a specified pixel on the map (relative to the top-left corner) stationary. - setZoomAround: function (latlng, zoom, options) { - var scale = this.getZoomScale(zoom), - viewHalf = this.getSize().divideBy(2), - containerPoint = latlng instanceof Point ? latlng : this.latLngToContainerPoint(latlng), - - centerOffset = containerPoint.subtract(viewHalf).multiplyBy(1 - 1 / scale), - newCenter = this.containerPointToLatLng(viewHalf.add(centerOffset)); - - return this.setView(newCenter, zoom, {zoom: options}); - }, - - _getBoundsCenterZoom: function (bounds, options) { - - options = options || {}; - bounds = bounds.getBounds ? bounds.getBounds() : toLatLngBounds(bounds); - - var paddingTL = toPoint(options.paddingTopLeft || options.padding || [0, 0]), - paddingBR = toPoint(options.paddingBottomRight || options.padding || [0, 0]), - - zoom = this.getBoundsZoom(bounds, false, paddingTL.add(paddingBR)); - - zoom = (typeof options.maxZoom === 'number') ? Math.min(options.maxZoom, zoom) : zoom; - - if (zoom === Infinity) { - return { - center: bounds.getCenter(), - zoom: zoom - }; - } - - var paddingOffset = paddingBR.subtract(paddingTL).divideBy(2), - - swPoint = this.project(bounds.getSouthWest(), zoom), - nePoint = this.project(bounds.getNorthEast(), zoom), - center = this.unproject(swPoint.add(nePoint).divideBy(2).add(paddingOffset), zoom); - - return { - center: center, - zoom: zoom - }; - }, - - // @method fitBounds(bounds: LatLngBounds, options?: fitBounds options): this - // Sets a map view that contains the given geographical bounds with the - // maximum zoom level possible. - fitBounds: function (bounds, options) { - - bounds = toLatLngBounds(bounds); - - if (!bounds.isValid()) { - throw new Error('Bounds are not valid.'); - } - - var target = this._getBoundsCenterZoom(bounds, options); - return this.setView(target.center, target.zoom, options); - }, - - // @method fitWorld(options?: fitBounds options): this - // Sets a map view that mostly contains the whole world with the maximum - // zoom level possible. - fitWorld: function (options) { - return this.fitBounds([[-90, -180], [90, 180]], options); - }, - - // @method panTo(latlng: LatLng, options?: Pan options): this - // Pans the map to a given center. - panTo: function (center, options) { // (LatLng) - return this.setView(center, this._zoom, {pan: options}); - }, - - // @method panBy(offset: Point, options?: Pan options): this - // Pans the map by a given number of pixels (animated). - panBy: function (offset, options) { - offset = toPoint(offset).round(); - options = options || {}; - - if (!offset.x && !offset.y) { - return this.fire('moveend'); - } - // If we pan too far, Chrome gets issues with tiles - // and makes them disappear or appear in the wrong place (slightly offset) #2602 - if (options.animate !== true && !this.getSize().contains(offset)) { - this._resetView(this.unproject(this.project(this.getCenter()).add(offset)), this.getZoom()); - return this; - } - - if (!this._panAnim) { - this._panAnim = new PosAnimation(); - - this._panAnim.on({ - 'step': this._onPanTransitionStep, - 'end': this._onPanTransitionEnd - }, this); - } - - // don't fire movestart if animating inertia - if (!options.noMoveStart) { - this.fire('movestart'); - } - - // animate pan unless animate: false specified - if (options.animate !== false) { - addClass(this._mapPane, 'leaflet-pan-anim'); - - var newPos = this._getMapPanePos().subtract(offset).round(); - this._panAnim.run(this._mapPane, newPos, options.duration || 0.25, options.easeLinearity); - } else { - this._rawPanBy(offset); - this.fire('move').fire('moveend'); - } - - return this; - }, - - // @method flyTo(latlng: LatLng, zoom?: Number, options?: Zoom/pan options): this - // Sets the view of the map (geographical center and zoom) performing a smooth - // pan-zoom animation. - flyTo: function (targetCenter, targetZoom, options) { - - options = options || {}; - if (options.animate === false || !any3d) { - return this.setView(targetCenter, targetZoom, options); - } - - this._stop(); - - var from = this.project(this.getCenter()), - to = this.project(targetCenter), - size = this.getSize(), - startZoom = this._zoom; - - targetCenter = toLatLng(targetCenter); - targetZoom = targetZoom === undefined ? startZoom : targetZoom; - - var w0 = Math.max(size.x, size.y), - w1 = w0 * this.getZoomScale(startZoom, targetZoom), - u1 = (to.distanceTo(from)) || 1, - rho = 1.42, - rho2 = rho * rho; - - function r(i) { - var s1 = i ? -1 : 1, - s2 = i ? w1 : w0, - t1 = w1 * w1 - w0 * w0 + s1 * rho2 * rho2 * u1 * u1, - b1 = 2 * s2 * rho2 * u1, - b = t1 / b1, - sq = Math.sqrt(b * b + 1) - b; - - // workaround for floating point precision bug when sq = 0, log = -Infinite, - // thus triggering an infinite loop in flyTo - var log = sq < 0.000000001 ? -18 : Math.log(sq); - - return log; - } - - function sinh(n) { return (Math.exp(n) - Math.exp(-n)) / 2; } - function cosh(n) { return (Math.exp(n) + Math.exp(-n)) / 2; } - function tanh(n) { return sinh(n) / cosh(n); } - - var r0 = r(0); - - function w(s) { return w0 * (cosh(r0) / cosh(r0 + rho * s)); } - function u(s) { return w0 * (cosh(r0) * tanh(r0 + rho * s) - sinh(r0)) / rho2; } - - function easeOut(t) { return 1 - Math.pow(1 - t, 1.5); } - - var start = Date.now(), - S = (r(1) - r0) / rho, - duration = options.duration ? 1000 * options.duration : 1000 * S * 0.8; - - function frame() { - var t = (Date.now() - start) / duration, - s = easeOut(t) * S; - - if (t <= 1) { - this._flyToFrame = requestAnimFrame(frame, this); - - this._move( - this.unproject(from.add(to.subtract(from).multiplyBy(u(s) / u1)), startZoom), - this.getScaleZoom(w0 / w(s), startZoom), - {flyTo: true}); - - } else { - this - ._move(targetCenter, targetZoom) - ._moveEnd(true); - } - } - - this._moveStart(true, options.noMoveStart); - - frame.call(this); - return this; - }, - - // @method flyToBounds(bounds: LatLngBounds, options?: fitBounds options): this - // Sets the view of the map with a smooth animation like [`flyTo`](#map-flyto), - // but takes a bounds parameter like [`fitBounds`](#map-fitbounds). - flyToBounds: function (bounds, options) { - var target = this._getBoundsCenterZoom(bounds, options); - return this.flyTo(target.center, target.zoom, options); - }, - - // @method setMaxBounds(bounds: LatLngBounds): this - // Restricts the map view to the given bounds (see the [maxBounds](#map-maxbounds) option). - setMaxBounds: function (bounds) { - bounds = toLatLngBounds(bounds); - - if (!bounds.isValid()) { - this.options.maxBounds = null; - return this.off('moveend', this._panInsideMaxBounds); - } else if (this.options.maxBounds) { - this.off('moveend', this._panInsideMaxBounds); - } - - this.options.maxBounds = bounds; - - if (this._loaded) { - this._panInsideMaxBounds(); - } - - return this.on('moveend', this._panInsideMaxBounds); - }, - - // @method setMinZoom(zoom: Number): this - // Sets the lower limit for the available zoom levels (see the [minZoom](#map-minzoom) option). - setMinZoom: function (zoom) { - var oldZoom = this.options.minZoom; - this.options.minZoom = zoom; - - if (this._loaded && oldZoom !== zoom) { - this.fire('zoomlevelschange'); - - if (this.getZoom() < this.options.minZoom) { - return this.setZoom(zoom); - } - } - - return this; - }, - - // @method setMaxZoom(zoom: Number): this - // Sets the upper limit for the available zoom levels (see the [maxZoom](#map-maxzoom) option). - setMaxZoom: function (zoom) { - var oldZoom = this.options.maxZoom; - this.options.maxZoom = zoom; - - if (this._loaded && oldZoom !== zoom) { - this.fire('zoomlevelschange'); - - if (this.getZoom() > this.options.maxZoom) { - return this.setZoom(zoom); - } - } - - return this; - }, - - // @method panInsideBounds(bounds: LatLngBounds, options?: Pan options): this - // Pans the map to the closest view that would lie inside the given bounds (if it's not already), controlling the animation using the options specific, if any. - panInsideBounds: function (bounds, options) { - this._enforcingBounds = true; - var center = this.getCenter(), - newCenter = this._limitCenter(center, this._zoom, toLatLngBounds(bounds)); - - if (!center.equals(newCenter)) { - this.panTo(newCenter, options); - } - - this._enforcingBounds = false; - return this; - }, - - // @method panInside(latlng: LatLng, options?: options): this - // Pans the map the minimum amount to make the `latlng` visible. Use - // `padding`, `paddingTopLeft` and `paddingTopRight` options to fit - // the display to more restricted bounds, like [`fitBounds`](#map-fitbounds). - // If `latlng` is already within the (optionally padded) display bounds, - // the map will not be panned. - panInside: function (latlng, options) { - options = options || {}; - - var paddingTL = toPoint(options.paddingTopLeft || options.padding || [0, 0]), - paddingBR = toPoint(options.paddingBottomRight || options.padding || [0, 0]), - center = this.getCenter(), - pixelCenter = this.project(center), - pixelPoint = this.project(latlng), - pixelBounds = this.getPixelBounds(), - halfPixelBounds = pixelBounds.getSize().divideBy(2), - paddedBounds = toBounds([pixelBounds.min.add(paddingTL), pixelBounds.max.subtract(paddingBR)]); - - if (!paddedBounds.contains(pixelPoint)) { - this._enforcingBounds = true; - var diff = pixelCenter.subtract(pixelPoint), - newCenter = toPoint(pixelPoint.x + diff.x, pixelPoint.y + diff.y); - - if (pixelPoint.x < paddedBounds.min.x || pixelPoint.x > paddedBounds.max.x) { - newCenter.x = pixelCenter.x - diff.x; - if (diff.x > 0) { - newCenter.x += halfPixelBounds.x - paddingTL.x; - } else { - newCenter.x -= halfPixelBounds.x - paddingBR.x; - } - } - if (pixelPoint.y < paddedBounds.min.y || pixelPoint.y > paddedBounds.max.y) { - newCenter.y = pixelCenter.y - diff.y; - if (diff.y > 0) { - newCenter.y += halfPixelBounds.y - paddingTL.y; - } else { - newCenter.y -= halfPixelBounds.y - paddingBR.y; - } - } - this.panTo(this.unproject(newCenter), options); - this._enforcingBounds = false; - } - return this; - }, - - // @method invalidateSize(options: Zoom/pan options): this - // Checks if the map container size changed and updates the map if so — - // call it after you've changed the map size dynamically, also animating - // pan by default. If `options.pan` is `false`, panning will not occur. - // If `options.debounceMoveend` is `true`, it will delay `moveend` event so - // that it doesn't happen often even if the method is called many - // times in a row. - - // @alternative - // @method invalidateSize(animate: Boolean): this - // Checks if the map container size changed and updates the map if so — - // call it after you've changed the map size dynamically, also animating - // pan by default. - invalidateSize: function (options) { - if (!this._loaded) { return this; } - - options = extend({ - animate: false, - pan: true - }, options === true ? {animate: true} : options); - - var oldSize = this.getSize(); - this._sizeChanged = true; - this._lastCenter = null; - - var newSize = this.getSize(), - oldCenter = oldSize.divideBy(2).round(), - newCenter = newSize.divideBy(2).round(), - offset = oldCenter.subtract(newCenter); - - if (!offset.x && !offset.y) { return this; } - - if (options.animate && options.pan) { - this.panBy(offset); - - } else { - if (options.pan) { - this._rawPanBy(offset); - } - - this.fire('move'); - - if (options.debounceMoveend) { - clearTimeout(this._sizeTimer); - this._sizeTimer = setTimeout(bind(this.fire, this, 'moveend'), 200); - } else { - this.fire('moveend'); - } - } - - // @section Map state change events - // @event resize: ResizeEvent - // Fired when the map is resized. - return this.fire('resize', { - oldSize: oldSize, - newSize: newSize - }); - }, - - // @section Methods for modifying map state - // @method stop(): this - // Stops the currently running `panTo` or `flyTo` animation, if any. - stop: function () { - this.setZoom(this._limitZoom(this._zoom)); - if (!this.options.zoomSnap) { - this.fire('viewreset'); - } - return this._stop(); - }, - - // @section Geolocation methods - // @method locate(options?: Locate options): this - // Tries to locate the user using the Geolocation API, firing a [`locationfound`](#map-locationfound) - // event with location data on success or a [`locationerror`](#map-locationerror) event on failure, - // and optionally sets the map view to the user's location with respect to - // detection accuracy (or to the world view if geolocation failed). - // Note that, if your page doesn't use HTTPS, this method will fail in - // modern browsers ([Chrome 50 and newer](https://sites.google.com/a/chromium.org/dev/Home/chromium-security/deprecating-powerful-features-on-insecure-origins)) - // See `Locate options` for more details. - locate: function (options) { - - options = this._locateOptions = extend({ - timeout: 10000, - watch: false - // setView: false - // maxZoom: - // maximumAge: 0 - // enableHighAccuracy: false - }, options); - - if (!('geolocation' in navigator)) { - this._handleGeolocationError({ - code: 0, - message: 'Geolocation not supported.' - }); - return this; - } - - var onResponse = bind(this._handleGeolocationResponse, this), - onError = bind(this._handleGeolocationError, this); - - if (options.watch) { - this._locationWatchId = - navigator.geolocation.watchPosition(onResponse, onError, options); - } else { - navigator.geolocation.getCurrentPosition(onResponse, onError, options); - } - return this; - }, - - // @method stopLocate(): this - // Stops watching location previously initiated by `map.locate({watch: true})` - // and aborts resetting the map view if map.locate was called with - // `{setView: true}`. - stopLocate: function () { - if (navigator.geolocation && navigator.geolocation.clearWatch) { - navigator.geolocation.clearWatch(this._locationWatchId); - } - if (this._locateOptions) { - this._locateOptions.setView = false; - } - return this; - }, - - _handleGeolocationError: function (error) { - var c = error.code, - message = error.message || - (c === 1 ? 'permission denied' : - (c === 2 ? 'position unavailable' : 'timeout')); - - if (this._locateOptions.setView && !this._loaded) { - this.fitWorld(); - } - - // @section Location events - // @event locationerror: ErrorEvent - // Fired when geolocation (using the [`locate`](#map-locate) method) failed. - this.fire('locationerror', { - code: c, - message: 'Geolocation error: ' + message + '.' - }); - }, - - _handleGeolocationResponse: function (pos) { - var lat = pos.coords.latitude, - lng = pos.coords.longitude, - latlng = new LatLng(lat, lng), - bounds = latlng.toBounds(pos.coords.accuracy * 2), - options = this._locateOptions; - - if (options.setView) { - var zoom = this.getBoundsZoom(bounds); - this.setView(latlng, options.maxZoom ? Math.min(zoom, options.maxZoom) : zoom); - } - - var data = { - latlng: latlng, - bounds: bounds, - timestamp: pos.timestamp - }; - - for (var i in pos.coords) { - if (typeof pos.coords[i] === 'number') { - data[i] = pos.coords[i]; - } - } - - // @event locationfound: LocationEvent - // Fired when geolocation (using the [`locate`](#map-locate) method) - // went successfully. - this.fire('locationfound', data); - }, - - // TODO Appropriate docs section? - // @section Other Methods - // @method addHandler(name: String, HandlerClass: Function): this - // Adds a new `Handler` to the map, given its name and constructor function. - addHandler: function (name, HandlerClass) { - if (!HandlerClass) { return this; } - - var handler = this[name] = new HandlerClass(this); - - this._handlers.push(handler); - - if (this.options[name]) { - handler.enable(); - } - - return this; - }, - - // @method remove(): this - // Destroys the map and clears all related event listeners. - remove: function () { - - this._initEvents(true); - this.off('moveend', this._panInsideMaxBounds); - - if (this._containerId !== this._container._leaflet_id) { - throw new Error('Map container is being reused by another instance'); - } - - try { - // throws error in IE6-8 - delete this._container._leaflet_id; - delete this._containerId; - } catch (e) { - /*eslint-disable */ - this._container._leaflet_id = undefined; - /* eslint-enable */ - this._containerId = undefined; - } - - if (this._locationWatchId !== undefined) { - this.stopLocate(); - } - - this._stop(); - - remove(this._mapPane); - - if (this._clearControlPos) { - this._clearControlPos(); - } - if (this._resizeRequest) { - cancelAnimFrame(this._resizeRequest); - this._resizeRequest = null; - } - - this._clearHandlers(); - - if (this._loaded) { - // @section Map state change events - // @event unload: Event - // Fired when the map is destroyed with [remove](#map-remove) method. - this.fire('unload'); - } - - var i; - for (i in this._layers) { - this._layers[i].remove(); - } - for (i in this._panes) { - remove(this._panes[i]); - } - - this._layers = []; - this._panes = []; - delete this._mapPane; - delete this._renderer; - - return this; - }, - - // @section Other Methods - // @method createPane(name: String, container?: HTMLElement): HTMLElement - // Creates a new [map pane](#map-pane) with the given name if it doesn't exist already, - // then returns it. The pane is created as a child of `container`, or - // as a child of the main map pane if not set. - createPane: function (name, container) { - var className = 'leaflet-pane' + (name ? ' leaflet-' + name.replace('Pane', '') + '-pane' : ''), - pane = create$1('div', className, container || this._mapPane); - - if (name) { - this._panes[name] = pane; - } - return pane; - }, - - // @section Methods for Getting Map State - - // @method getCenter(): LatLng - // Returns the geographical center of the map view - getCenter: function () { - this._checkIfLoaded(); - - if (this._lastCenter && !this._moved()) { - return this._lastCenter; - } - return this.layerPointToLatLng(this._getCenterLayerPoint()); - }, - - // @method getZoom(): Number - // Returns the current zoom level of the map view - getZoom: function () { - return this._zoom; - }, - - // @method getBounds(): LatLngBounds - // Returns the geographical bounds visible in the current map view - getBounds: function () { - var bounds = this.getPixelBounds(), - sw = this.unproject(bounds.getBottomLeft()), - ne = this.unproject(bounds.getTopRight()); - - return new LatLngBounds(sw, ne); - }, - - // @method getMinZoom(): Number - // Returns the minimum zoom level of the map (if set in the `minZoom` option of the map or of any layers), or `0` by default. - getMinZoom: function () { - return this.options.minZoom === undefined ? this._layersMinZoom || 0 : this.options.minZoom; - }, - - // @method getMaxZoom(): Number - // Returns the maximum zoom level of the map (if set in the `maxZoom` option of the map or of any layers). - getMaxZoom: function () { - return this.options.maxZoom === undefined ? - (this._layersMaxZoom === undefined ? Infinity : this._layersMaxZoom) : - this.options.maxZoom; - }, - - // @method getBoundsZoom(bounds: LatLngBounds, inside?: Boolean, padding?: Point): Number - // Returns the maximum zoom level on which the given bounds fit to the map - // view in its entirety. If `inside` (optional) is set to `true`, the method - // instead returns the minimum zoom level on which the map view fits into - // the given bounds in its entirety. - getBoundsZoom: function (bounds, inside, padding) { // (LatLngBounds[, Boolean, Point]) -> Number - bounds = toLatLngBounds(bounds); - padding = toPoint(padding || [0, 0]); - - var zoom = this.getZoom() || 0, - min = this.getMinZoom(), - max = this.getMaxZoom(), - nw = bounds.getNorthWest(), - se = bounds.getSouthEast(), - size = this.getSize().subtract(padding), - boundsSize = toBounds(this.project(se, zoom), this.project(nw, zoom)).getSize(), - snap = any3d ? this.options.zoomSnap : 1, - scalex = size.x / boundsSize.x, - scaley = size.y / boundsSize.y, - scale = inside ? Math.max(scalex, scaley) : Math.min(scalex, scaley); - - zoom = this.getScaleZoom(scale, zoom); - - if (snap) { - zoom = Math.round(zoom / (snap / 100)) * (snap / 100); // don't jump if within 1% of a snap level - zoom = inside ? Math.ceil(zoom / snap) * snap : Math.floor(zoom / snap) * snap; - } - - return Math.max(min, Math.min(max, zoom)); - }, - - // @method getSize(): Point - // Returns the current size of the map container (in pixels). - getSize: function () { - if (!this._size || this._sizeChanged) { - this._size = new Point( - this._container.clientWidth || 0, - this._container.clientHeight || 0); - - this._sizeChanged = false; - } - return this._size.clone(); - }, - - // @method getPixelBounds(): Bounds - // Returns the bounds of the current map view in projected pixel - // coordinates (sometimes useful in layer and overlay implementations). - getPixelBounds: function (center, zoom) { - var topLeftPoint = this._getTopLeftPoint(center, zoom); - return new Bounds(topLeftPoint, topLeftPoint.add(this.getSize())); - }, - - // TODO: Check semantics - isn't the pixel origin the 0,0 coord relative to - // the map pane? "left point of the map layer" can be confusing, specially - // since there can be negative offsets. - // @method getPixelOrigin(): Point - // Returns the projected pixel coordinates of the top left point of - // the map layer (useful in custom layer and overlay implementations). - getPixelOrigin: function () { - this._checkIfLoaded(); - return this._pixelOrigin; - }, - - // @method getPixelWorldBounds(zoom?: Number): Bounds - // Returns the world's bounds in pixel coordinates for zoom level `zoom`. - // If `zoom` is omitted, the map's current zoom level is used. - getPixelWorldBounds: function (zoom) { - return this.options.crs.getProjectedBounds(zoom === undefined ? this.getZoom() : zoom); - }, - - // @section Other Methods - - // @method getPane(pane: String|HTMLElement): HTMLElement - // Returns a [map pane](#map-pane), given its name or its HTML element (its identity). - getPane: function (pane) { - return typeof pane === 'string' ? this._panes[pane] : pane; - }, - - // @method getPanes(): Object - // Returns a plain object containing the names of all [panes](#map-pane) as keys and - // the panes as values. - getPanes: function () { - return this._panes; - }, - - // @method getContainer: HTMLElement - // Returns the HTML element that contains the map. - getContainer: function () { - return this._container; - }, - - - // @section Conversion Methods - - // @method getZoomScale(toZoom: Number, fromZoom: Number): Number - // Returns the scale factor to be applied to a map transition from zoom level - // `fromZoom` to `toZoom`. Used internally to help with zoom animations. - getZoomScale: function (toZoom, fromZoom) { - // TODO replace with universal implementation after refactoring projections - var crs = this.options.crs; - fromZoom = fromZoom === undefined ? this._zoom : fromZoom; - return crs.scale(toZoom) / crs.scale(fromZoom); - }, - - // @method getScaleZoom(scale: Number, fromZoom: Number): Number - // Returns the zoom level that the map would end up at, if it is at `fromZoom` - // level and everything is scaled by a factor of `scale`. Inverse of - // [`getZoomScale`](#map-getZoomScale). - getScaleZoom: function (scale, fromZoom) { - var crs = this.options.crs; - fromZoom = fromZoom === undefined ? this._zoom : fromZoom; - var zoom = crs.zoom(scale * crs.scale(fromZoom)); - return isNaN(zoom) ? Infinity : zoom; - }, - - // @method project(latlng: LatLng, zoom: Number): Point - // Projects a geographical coordinate `LatLng` according to the projection - // of the map's CRS, then scales it according to `zoom` and the CRS's - // `Transformation`. The result is pixel coordinate relative to - // the CRS origin. - project: function (latlng, zoom) { - zoom = zoom === undefined ? this._zoom : zoom; - return this.options.crs.latLngToPoint(toLatLng(latlng), zoom); - }, - - // @method unproject(point: Point, zoom: Number): LatLng - // Inverse of [`project`](#map-project). - unproject: function (point, zoom) { - zoom = zoom === undefined ? this._zoom : zoom; - return this.options.crs.pointToLatLng(toPoint(point), zoom); - }, - - // @method layerPointToLatLng(point: Point): LatLng - // Given a pixel coordinate relative to the [origin pixel](#map-getpixelorigin), - // returns the corresponding geographical coordinate (for the current zoom level). - layerPointToLatLng: function (point) { - var projectedPoint = toPoint(point).add(this.getPixelOrigin()); - return this.unproject(projectedPoint); - }, - - // @method latLngToLayerPoint(latlng: LatLng): Point - // Given a geographical coordinate, returns the corresponding pixel coordinate - // relative to the [origin pixel](#map-getpixelorigin). - latLngToLayerPoint: function (latlng) { - var projectedPoint = this.project(toLatLng(latlng))._round(); - return projectedPoint._subtract(this.getPixelOrigin()); - }, - - // @method wrapLatLng(latlng: LatLng): LatLng - // Returns a `LatLng` where `lat` and `lng` has been wrapped according to the - // map's CRS's `wrapLat` and `wrapLng` properties, if they are outside the - // CRS's bounds. - // By default this means longitude is wrapped around the dateline so its - // value is between -180 and +180 degrees. - wrapLatLng: function (latlng) { - return this.options.crs.wrapLatLng(toLatLng(latlng)); - }, - - // @method wrapLatLngBounds(bounds: LatLngBounds): LatLngBounds - // Returns a `LatLngBounds` with the same size as the given one, ensuring that - // its center is within the CRS's bounds. - // By default this means the center longitude is wrapped around the dateline so its - // value is between -180 and +180 degrees, and the majority of the bounds - // overlaps the CRS's bounds. - wrapLatLngBounds: function (latlng) { - return this.options.crs.wrapLatLngBounds(toLatLngBounds(latlng)); - }, - - // @method distance(latlng1: LatLng, latlng2: LatLng): Number - // Returns the distance between two geographical coordinates according to - // the map's CRS. By default this measures distance in meters. - distance: function (latlng1, latlng2) { - return this.options.crs.distance(toLatLng(latlng1), toLatLng(latlng2)); - }, - - // @method containerPointToLayerPoint(point: Point): Point - // Given a pixel coordinate relative to the map container, returns the corresponding - // pixel coordinate relative to the [origin pixel](#map-getpixelorigin). - containerPointToLayerPoint: function (point) { // (Point) - return toPoint(point).subtract(this._getMapPanePos()); - }, - - // @method layerPointToContainerPoint(point: Point): Point - // Given a pixel coordinate relative to the [origin pixel](#map-getpixelorigin), - // returns the corresponding pixel coordinate relative to the map container. - layerPointToContainerPoint: function (point) { // (Point) - return toPoint(point).add(this._getMapPanePos()); - }, - - // @method containerPointToLatLng(point: Point): LatLng - // Given a pixel coordinate relative to the map container, returns - // the corresponding geographical coordinate (for the current zoom level). - containerPointToLatLng: function (point) { - var layerPoint = this.containerPointToLayerPoint(toPoint(point)); - return this.layerPointToLatLng(layerPoint); - }, - - // @method latLngToContainerPoint(latlng: LatLng): Point - // Given a geographical coordinate, returns the corresponding pixel coordinate - // relative to the map container. - latLngToContainerPoint: function (latlng) { - return this.layerPointToContainerPoint(this.latLngToLayerPoint(toLatLng(latlng))); - }, - - // @method mouseEventToContainerPoint(ev: MouseEvent): Point - // Given a MouseEvent object, returns the pixel coordinate relative to the - // map container where the event took place. - mouseEventToContainerPoint: function (e) { - return getMousePosition(e, this._container); - }, - - // @method mouseEventToLayerPoint(ev: MouseEvent): Point - // Given a MouseEvent object, returns the pixel coordinate relative to - // the [origin pixel](#map-getpixelorigin) where the event took place. - mouseEventToLayerPoint: function (e) { - return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(e)); - }, - - // @method mouseEventToLatLng(ev: MouseEvent): LatLng - // Given a MouseEvent object, returns geographical coordinate where the - // event took place. - mouseEventToLatLng: function (e) { // (MouseEvent) - return this.layerPointToLatLng(this.mouseEventToLayerPoint(e)); - }, - - - // map initialization methods - - _initContainer: function (id) { - var container = this._container = get(id); - - if (!container) { - throw new Error('Map container not found.'); - } else if (container._leaflet_id) { - throw new Error('Map container is already initialized.'); - } - - on(container, 'scroll', this._onScroll, this); - this._containerId = stamp(container); - }, - - _initLayout: function () { - var container = this._container; - - this._fadeAnimated = this.options.fadeAnimation && any3d; - - addClass(container, 'leaflet-container' + - (touch ? ' leaflet-touch' : '') + - (retina ? ' leaflet-retina' : '') + - (ielt9 ? ' leaflet-oldie' : '') + - (safari ? ' leaflet-safari' : '') + - (this._fadeAnimated ? ' leaflet-fade-anim' : '')); - - var position = getStyle(container, 'position'); - - if (position !== 'absolute' && position !== 'relative' && position !== 'fixed') { - container.style.position = 'relative'; - } - - this._initPanes(); - - if (this._initControlPos) { - this._initControlPos(); - } - }, - - _initPanes: function () { - var panes = this._panes = {}; - this._paneRenderers = {}; - - // @section - // - // Panes are DOM elements used to control the ordering of layers on the map. You - // can access panes with [`map.getPane`](#map-getpane) or - // [`map.getPanes`](#map-getpanes) methods. New panes can be created with the - // [`map.createPane`](#map-createpane) method. - // - // Every map has the following default panes that differ only in zIndex. - // - // @pane mapPane: HTMLElement = 'auto' - // Pane that contains all other map panes - - this._mapPane = this.createPane('mapPane', this._container); - setPosition(this._mapPane, new Point(0, 0)); - - // @pane tilePane: HTMLElement = 200 - // Pane for `GridLayer`s and `TileLayer`s - this.createPane('tilePane'); - // @pane overlayPane: HTMLElement = 400 - // Pane for overlay shadows (e.g. `Marker` shadows) - this.createPane('shadowPane'); - // @pane shadowPane: HTMLElement = 500 - // Pane for vectors (`Path`s, like `Polyline`s and `Polygon`s), `ImageOverlay`s and `VideoOverlay`s - this.createPane('overlayPane'); - // @pane markerPane: HTMLElement = 600 - // Pane for `Icon`s of `Marker`s - this.createPane('markerPane'); - // @pane tooltipPane: HTMLElement = 650 - // Pane for `Tooltip`s. - this.createPane('tooltipPane'); - // @pane popupPane: HTMLElement = 700 - // Pane for `Popup`s. - this.createPane('popupPane'); - - if (!this.options.markerZoomAnimation) { - addClass(panes.markerPane, 'leaflet-zoom-hide'); - addClass(panes.shadowPane, 'leaflet-zoom-hide'); - } - }, - - - // private methods that modify map state - - // @section Map state change events - _resetView: function (center, zoom) { - setPosition(this._mapPane, new Point(0, 0)); - - var loading = !this._loaded; - this._loaded = true; - zoom = this._limitZoom(zoom); - - this.fire('viewprereset'); - - var zoomChanged = this._zoom !== zoom; - this - ._moveStart(zoomChanged, false) - ._move(center, zoom) - ._moveEnd(zoomChanged); - - // @event viewreset: Event - // Fired when the map needs to redraw its content (this usually happens - // on map zoom or load). Very useful for creating custom overlays. - this.fire('viewreset'); - - // @event load: Event - // Fired when the map is initialized (when its center and zoom are set - // for the first time). - if (loading) { - this.fire('load'); - } - }, - - _moveStart: function (zoomChanged, noMoveStart) { - // @event zoomstart: Event - // Fired when the map zoom is about to change (e.g. before zoom animation). - // @event movestart: Event - // Fired when the view of the map starts changing (e.g. user starts dragging the map). - if (zoomChanged) { - this.fire('zoomstart'); - } - if (!noMoveStart) { - this.fire('movestart'); - } - return this; - }, - - _move: function (center, zoom, data) { - if (zoom === undefined) { - zoom = this._zoom; - } - var zoomChanged = this._zoom !== zoom; - - this._zoom = zoom; - this._lastCenter = center; - this._pixelOrigin = this._getNewPixelOrigin(center); - - // @event zoom: Event - // Fired repeatedly during any change in zoom level, including zoom - // and fly animations. - if (zoomChanged || (data && data.pinch)) { // Always fire 'zoom' if pinching because #3530 - this.fire('zoom', data); - } - - // @event move: Event - // Fired repeatedly during any movement of the map, including pan and - // fly animations. - return this.fire('move', data); - }, - - _moveEnd: function (zoomChanged) { - // @event zoomend: Event - // Fired when the map has changed, after any animations. - if (zoomChanged) { - this.fire('zoomend'); - } - - // @event moveend: Event - // Fired when the center of the map stops changing (e.g. user stopped - // dragging the map). - return this.fire('moveend'); - }, - - _stop: function () { - cancelAnimFrame(this._flyToFrame); - if (this._panAnim) { - this._panAnim.stop(); - } - return this; - }, - - _rawPanBy: function (offset) { - setPosition(this._mapPane, this._getMapPanePos().subtract(offset)); - }, - - _getZoomSpan: function () { - return this.getMaxZoom() - this.getMinZoom(); - }, - - _panInsideMaxBounds: function () { - if (!this._enforcingBounds) { - this.panInsideBounds(this.options.maxBounds); - } - }, - - _checkIfLoaded: function () { - if (!this._loaded) { - throw new Error('Set map center and zoom first.'); - } - }, - - // DOM event handling - - // @section Interaction events - _initEvents: function (remove$$1) { - this._targets = {}; - this._targets[stamp(this._container)] = this; - - var onOff = remove$$1 ? off : on; - - // @event click: MouseEvent - // Fired when the user clicks (or taps) the map. - // @event dblclick: MouseEvent - // Fired when the user double-clicks (or double-taps) the map. - // @event mousedown: MouseEvent - // Fired when the user pushes the mouse button on the map. - // @event mouseup: MouseEvent - // Fired when the user releases the mouse button on the map. - // @event mouseover: MouseEvent - // Fired when the mouse enters the map. - // @event mouseout: MouseEvent - // Fired when the mouse leaves the map. - // @event mousemove: MouseEvent - // Fired while the mouse moves over the map. - // @event contextmenu: MouseEvent - // Fired when the user pushes the right mouse button on the map, prevents - // default browser context menu from showing if there are listeners on - // this event. Also fired on mobile when the user holds a single touch - // for a second (also called long press). - // @event keypress: KeyboardEvent - // Fired when the user presses a key from the keyboard that produces a character value while the map is focused. - // @event keydown: KeyboardEvent - // Fired when the user presses a key from the keyboard while the map is focused. Unlike the `keypress` event, - // the `keydown` event is fired for keys that produce a character value and for keys - // that do not produce a character value. - // @event keyup: KeyboardEvent - // Fired when the user releases a key from the keyboard while the map is focused. - onOff(this._container, 'click dblclick mousedown mouseup ' + - 'mouseover mouseout mousemove contextmenu keypress keydown keyup', this._handleDOMEvent, this); - - if (this.options.trackResize) { - onOff(window, 'resize', this._onResize, this); - } - - if (any3d && this.options.transform3DLimit) { - (remove$$1 ? this.off : this.on).call(this, 'moveend', this._onMoveEnd); - } - }, - - _onResize: function () { - cancelAnimFrame(this._resizeRequest); - this._resizeRequest = requestAnimFrame( - function () { this.invalidateSize({debounceMoveend: true}); }, this); - }, - - _onScroll: function () { - this._container.scrollTop = 0; - this._container.scrollLeft = 0; - }, - - _onMoveEnd: function () { - var pos = this._getMapPanePos(); - if (Math.max(Math.abs(pos.x), Math.abs(pos.y)) >= this.options.transform3DLimit) { - // https://bugzilla.mozilla.org/show_bug.cgi?id=1203873 but Webkit also have - // a pixel offset on very high values, see: http://jsfiddle.net/dg6r5hhb/ - this._resetView(this.getCenter(), this.getZoom()); - } - }, - - _findEventTargets: function (e, type) { - var targets = [], - target, - isHover = type === 'mouseout' || type === 'mouseover', - src = e.target || e.srcElement, - dragging = false; - - while (src) { - target = this._targets[stamp(src)]; - if (target && (type === 'click' || type === 'preclick') && !e._simulated && this._draggableMoved(target)) { - // Prevent firing click after you just dragged an object. - dragging = true; - break; - } - if (target && target.listens(type, true)) { - if (isHover && !isExternalTarget(src, e)) { break; } - targets.push(target); - if (isHover) { break; } - } - if (src === this._container) { break; } - src = src.parentNode; - } - if (!targets.length && !dragging && !isHover && isExternalTarget(src, e)) { - targets = [this]; - } - return targets; - }, - - _handleDOMEvent: function (e) { - if (!this._loaded || skipped(e)) { return; } - - var type = e.type; - - if (type === 'mousedown' || type === 'keypress' || type === 'keyup' || type === 'keydown') { - // prevents outline when clicking on keyboard-focusable element - preventOutline(e.target || e.srcElement); - } - - this._fireDOMEvent(e, type); - }, - - _mouseEvents: ['click', 'dblclick', 'mouseover', 'mouseout', 'contextmenu'], - - _fireDOMEvent: function (e, type, targets) { - - if (e.type === 'click') { - // Fire a synthetic 'preclick' event which propagates up (mainly for closing popups). - // @event preclick: MouseEvent - // Fired before mouse click on the map (sometimes useful when you - // want something to happen on click before any existing click - // handlers start running). - var synth = extend({}, e); - synth.type = 'preclick'; - this._fireDOMEvent(synth, synth.type, targets); - } - - if (e._stopped) { return; } - - // Find the layer the event is propagating from and its parents. - targets = (targets || []).concat(this._findEventTargets(e, type)); - - if (!targets.length) { return; } - - var target = targets[0]; - if (type === 'contextmenu' && target.listens(type, true)) { - preventDefault(e); - } - - var data = { - originalEvent: e - }; - - if (e.type !== 'keypress' && e.type !== 'keydown' && e.type !== 'keyup') { - var isMarker = target.getLatLng && (!target._radius || target._radius <= 10); - data.containerPoint = isMarker ? - this.latLngToContainerPoint(target.getLatLng()) : this.mouseEventToContainerPoint(e); - data.layerPoint = this.containerPointToLayerPoint(data.containerPoint); - data.latlng = isMarker ? target.getLatLng() : this.layerPointToLatLng(data.layerPoint); - } - - for (var i = 0; i < targets.length; i++) { - targets[i].fire(type, data, true); - if (data.originalEvent._stopped || - (targets[i].options.bubblingMouseEvents === false && indexOf(this._mouseEvents, type) !== -1)) { return; } - } - }, - - _draggableMoved: function (obj) { - obj = obj.dragging && obj.dragging.enabled() ? obj : this; - return (obj.dragging && obj.dragging.moved()) || (this.boxZoom && this.boxZoom.moved()); - }, - - _clearHandlers: function () { - for (var i = 0, len = this._handlers.length; i < len; i++) { - this._handlers[i].disable(); - } - }, - - // @section Other Methods - - // @method whenReady(fn: Function, context?: Object): this - // Runs the given function `fn` when the map gets initialized with - // a view (center and zoom) and at least one layer, or immediately - // if it's already initialized, optionally passing a function context. - whenReady: function (callback, context) { - if (this._loaded) { - callback.call(context || this, {target: this}); - } else { - this.on('load', callback, context); - } - return this; - }, - - - // private methods for getting map state - - _getMapPanePos: function () { - return getPosition(this._mapPane) || new Point(0, 0); - }, - - _moved: function () { - var pos = this._getMapPanePos(); - return pos && !pos.equals([0, 0]); - }, - - _getTopLeftPoint: function (center, zoom) { - var pixelOrigin = center && zoom !== undefined ? - this._getNewPixelOrigin(center, zoom) : - this.getPixelOrigin(); - return pixelOrigin.subtract(this._getMapPanePos()); - }, - - _getNewPixelOrigin: function (center, zoom) { - var viewHalf = this.getSize()._divideBy(2); - return this.project(center, zoom)._subtract(viewHalf)._add(this._getMapPanePos())._round(); - }, - - _latLngToNewLayerPoint: function (latlng, zoom, center) { - var topLeft = this._getNewPixelOrigin(center, zoom); - return this.project(latlng, zoom)._subtract(topLeft); - }, - - _latLngBoundsToNewLayerBounds: function (latLngBounds, zoom, center) { - var topLeft = this._getNewPixelOrigin(center, zoom); - return toBounds([ - this.project(latLngBounds.getSouthWest(), zoom)._subtract(topLeft), - this.project(latLngBounds.getNorthWest(), zoom)._subtract(topLeft), - this.project(latLngBounds.getSouthEast(), zoom)._subtract(topLeft), - this.project(latLngBounds.getNorthEast(), zoom)._subtract(topLeft) - ]); - }, - - // layer point of the current center - _getCenterLayerPoint: function () { - return this.containerPointToLayerPoint(this.getSize()._divideBy(2)); - }, - - // offset of the specified place to the current center in pixels - _getCenterOffset: function (latlng) { - return this.latLngToLayerPoint(latlng).subtract(this._getCenterLayerPoint()); - }, - - // adjust center for view to get inside bounds - _limitCenter: function (center, zoom, bounds) { - - if (!bounds) { return center; } - - var centerPoint = this.project(center, zoom), - viewHalf = this.getSize().divideBy(2), - viewBounds = new Bounds(centerPoint.subtract(viewHalf), centerPoint.add(viewHalf)), - offset = this._getBoundsOffset(viewBounds, bounds, zoom); - - // If offset is less than a pixel, ignore. - // This prevents unstable projections from getting into - // an infinite loop of tiny offsets. - if (offset.round().equals([0, 0])) { - return center; - } - - return this.unproject(centerPoint.add(offset), zoom); - }, - - // adjust offset for view to get inside bounds - _limitOffset: function (offset, bounds) { - if (!bounds) { return offset; } - - var viewBounds = this.getPixelBounds(), - newBounds = new Bounds(viewBounds.min.add(offset), viewBounds.max.add(offset)); - - return offset.add(this._getBoundsOffset(newBounds, bounds)); - }, - - // returns offset needed for pxBounds to get inside maxBounds at a specified zoom - _getBoundsOffset: function (pxBounds, maxBounds, zoom) { - var projectedMaxBounds = toBounds( - this.project(maxBounds.getNorthEast(), zoom), - this.project(maxBounds.getSouthWest(), zoom) - ), - minOffset = projectedMaxBounds.min.subtract(pxBounds.min), - maxOffset = projectedMaxBounds.max.subtract(pxBounds.max), - - dx = this._rebound(minOffset.x, -maxOffset.x), - dy = this._rebound(minOffset.y, -maxOffset.y); - - return new Point(dx, dy); - }, - - _rebound: function (left, right) { - return left + right > 0 ? - Math.round(left - right) / 2 : - Math.max(0, Math.ceil(left)) - Math.max(0, Math.floor(right)); - }, - - _limitZoom: function (zoom) { - var min = this.getMinZoom(), - max = this.getMaxZoom(), - snap = any3d ? this.options.zoomSnap : 1; - if (snap) { - zoom = Math.round(zoom / snap) * snap; - } - return Math.max(min, Math.min(max, zoom)); - }, - - _onPanTransitionStep: function () { - this.fire('move'); - }, - - _onPanTransitionEnd: function () { - removeClass(this._mapPane, 'leaflet-pan-anim'); - this.fire('moveend'); - }, - - _tryAnimatedPan: function (center, options) { - // difference between the new and current centers in pixels - var offset = this._getCenterOffset(center)._trunc(); - - // don't animate too far unless animate: true specified in options - if ((options && options.animate) !== true && !this.getSize().contains(offset)) { return false; } - - this.panBy(offset, options); - - return true; - }, - - _createAnimProxy: function () { - - var proxy = this._proxy = create$1('div', 'leaflet-proxy leaflet-zoom-animated'); - this._panes.mapPane.appendChild(proxy); - - this.on('zoomanim', function (e) { - var prop = TRANSFORM, - transform = this._proxy.style[prop]; - - setTransform(this._proxy, this.project(e.center, e.zoom), this.getZoomScale(e.zoom, 1)); - - // workaround for case when transform is the same and so transitionend event is not fired - if (transform === this._proxy.style[prop] && this._animatingZoom) { - this._onZoomTransitionEnd(); - } - }, this); - - this.on('load moveend', this._animMoveEnd, this); - - this._on('unload', this._destroyAnimProxy, this); - }, - - _destroyAnimProxy: function () { - remove(this._proxy); - this.off('load moveend', this._animMoveEnd, this); - delete this._proxy; - }, - - _animMoveEnd: function () { - var c = this.getCenter(), - z = this.getZoom(); - setTransform(this._proxy, this.project(c, z), this.getZoomScale(z, 1)); - }, - - _catchTransitionEnd: function (e) { - if (this._animatingZoom && e.propertyName.indexOf('transform') >= 0) { - this._onZoomTransitionEnd(); - } - }, - - _nothingToAnimate: function () { - return !this._container.getElementsByClassName('leaflet-zoom-animated').length; - }, - - _tryAnimatedZoom: function (center, zoom, options) { - - if (this._animatingZoom) { return true; } - - options = options || {}; - - // don't animate if disabled, not supported or zoom difference is too large - if (!this._zoomAnimated || options.animate === false || this._nothingToAnimate() || - Math.abs(zoom - this._zoom) > this.options.zoomAnimationThreshold) { return false; } - - // offset is the pixel coords of the zoom origin relative to the current center - var scale = this.getZoomScale(zoom), - offset = this._getCenterOffset(center)._divideBy(1 - 1 / scale); - - // don't animate if the zoom origin isn't within one screen from the current center, unless forced - if (options.animate !== true && !this.getSize().contains(offset)) { return false; } - - requestAnimFrame(function () { - this - ._moveStart(true, false) - ._animateZoom(center, zoom, true); - }, this); - - return true; - }, - - _animateZoom: function (center, zoom, startAnim, noUpdate) { - if (!this._mapPane) { return; } - - if (startAnim) { - this._animatingZoom = true; - - // remember what center/zoom to set after animation - this._animateToCenter = center; - this._animateToZoom = zoom; - - addClass(this._mapPane, 'leaflet-zoom-anim'); - } - - // @section Other Events - // @event zoomanim: ZoomAnimEvent - // Fired at least once per zoom animation. For continuous zoom, like pinch zooming, fired once per frame during zoom. - this.fire('zoomanim', { - center: center, - zoom: zoom, - noUpdate: noUpdate - }); - - // Work around webkit not firing 'transitionend', see https://github.com/Leaflet/Leaflet/issues/3689, 2693 - setTimeout(bind(this._onZoomTransitionEnd, this), 250); - }, - - _onZoomTransitionEnd: function () { - if (!this._animatingZoom) { return; } - - if (this._mapPane) { - removeClass(this._mapPane, 'leaflet-zoom-anim'); - } - - this._animatingZoom = false; - - this._move(this._animateToCenter, this._animateToZoom); - - // This anim frame should prevent an obscure iOS webkit tile loading race condition. - requestAnimFrame(function () { - this._moveEnd(true); - }, this); - } -}); - -// @section - -// @factory L.map(id: String, options?: Map options) -// Instantiates a map object given the DOM ID of a `
      ` element -// and optionally an object literal with `Map options`. -// -// @alternative -// @factory L.map(el: HTMLElement, options?: Map options) -// Instantiates a map object given an instance of a `
      ` HTML element -// and optionally an object literal with `Map options`. -function createMap(id, options) { - return new Map(id, options); +/* + * @class Map + * @aka L.Map + * @inherits Evented + * + * The central class of the API — it is used to create a map on a page and manipulate it. + * + * @example + * + * ```js + * // initialize the map on the "map" div with a given center and zoom + * var map = L.map('map', { + * center: [51.505, -0.09], + * zoom: 13 + * }); + * ``` + * + */ + +var Map = Evented.extend({ + + options: { + // @section Map State Options + // @option crs: CRS = L.CRS.EPSG3857 + // The [Coordinate Reference System](#crs) to use. Don't change this if you're not + // sure what it means. + crs: EPSG3857, + + // @option center: LatLng = undefined + // Initial geographic center of the map + center: undefined, + + // @option zoom: Number = undefined + // Initial map zoom level + zoom: undefined, + + // @option minZoom: Number = * + // Minimum zoom level of the map. + // If not specified and at least one `GridLayer` or `TileLayer` is in the map, + // the lowest of their `minZoom` options will be used instead. + minZoom: undefined, + + // @option maxZoom: Number = * + // Maximum zoom level of the map. + // If not specified and at least one `GridLayer` or `TileLayer` is in the map, + // the highest of their `maxZoom` options will be used instead. + maxZoom: undefined, + + // @option layers: Layer[] = [] + // Array of layers that will be added to the map initially + layers: [], + + // @option maxBounds: LatLngBounds = null + // When this option is set, the map restricts the view to the given + // geographical bounds, bouncing the user back if the user tries to pan + // outside the view. To set the restriction dynamically, use + // [`setMaxBounds`](#map-setmaxbounds) method. + maxBounds: undefined, + + // @option renderer: Renderer = * + // The default method for drawing vector layers on the map. `L.SVG` + // or `L.Canvas` by default depending on browser support. + renderer: undefined, + + + // @section Animation Options + // @option zoomAnimation: Boolean = true + // Whether the map zoom animation is enabled. By default it's enabled + // in all browsers that support CSS3 Transitions except Android. + zoomAnimation: true, + + // @option zoomAnimationThreshold: Number = 4 + // Won't animate zoom if the zoom difference exceeds this value. + zoomAnimationThreshold: 4, + + // @option fadeAnimation: Boolean = true + // Whether the tile fade animation is enabled. By default it's enabled + // in all browsers that support CSS3 Transitions except Android. + fadeAnimation: true, + + // @option markerZoomAnimation: Boolean = true + // Whether markers animate their zoom with the zoom animation, if disabled + // they will disappear for the length of the animation. By default it's + // enabled in all browsers that support CSS3 Transitions except Android. + markerZoomAnimation: true, + + // @option transform3DLimit: Number = 2^23 + // Defines the maximum size of a CSS translation transform. The default + // value should not be changed unless a web browser positions layers in + // the wrong place after doing a large `panBy`. + transform3DLimit: 8388608, // Precision limit of a 32-bit float + + // @section Interaction Options + // @option zoomSnap: Number = 1 + // Forces the map's zoom level to always be a multiple of this, particularly + // right after a [`fitBounds()`](#map-fitbounds) or a pinch-zoom. + // By default, the zoom level snaps to the nearest integer; lower values + // (e.g. `0.5` or `0.1`) allow for greater granularity. A value of `0` + // means the zoom level will not be snapped after `fitBounds` or a pinch-zoom. + zoomSnap: 1, + + // @option zoomDelta: Number = 1 + // Controls how much the map's zoom level will change after a + // [`zoomIn()`](#map-zoomin), [`zoomOut()`](#map-zoomout), pressing `+` + // or `-` on the keyboard, or using the [zoom controls](#control-zoom). + // Values smaller than `1` (e.g. `0.5`) allow for greater granularity. + zoomDelta: 1, + + // @option trackResize: Boolean = true + // Whether the map automatically handles browser window resize to update itself. + trackResize: true + }, + + initialize: function (id, options) { // (HTMLElement or String, Object) + options = setOptions(this, options); + + // Make sure to assign internal flags at the beginning, + // to avoid inconsistent state in some edge cases. + this._handlers = []; + this._layers = {}; + this._zoomBoundLayers = {}; + this._sizeChanged = true; + + this._initContainer(id); + this._initLayout(); + + // hack for https://github.com/Leaflet/Leaflet/issues/1980 + this._onResize = bind(this._onResize, this); + + this._initEvents(); + + if (options.maxBounds) { + this.setMaxBounds(options.maxBounds); + } + + if (options.zoom !== undefined) { + this._zoom = this._limitZoom(options.zoom); + } + + if (options.center && options.zoom !== undefined) { + this.setView(toLatLng(options.center), options.zoom, {reset: true}); + } + + this.callInitHooks(); + + // don't animate on browsers without hardware-accelerated transitions or old Android/Opera + this._zoomAnimated = TRANSITION && any3d && !mobileOpera && + this.options.zoomAnimation; + + // zoom transitions run with the same duration for all layers, so if one of transitionend events + // happens after starting zoom animation (propagating to the map pane), we know that it ended globally + if (this._zoomAnimated) { + this._createAnimProxy(); + on(this._proxy, TRANSITION_END, this._catchTransitionEnd, this); + } + + this._addLayers(this.options.layers); + }, + + + // @section Methods for modifying map state + + // @method setView(center: LatLng, zoom: Number, options?: Zoom/pan options): this + // Sets the view of the map (geographical center and zoom) with the given + // animation options. + setView: function (center, zoom, options) { + + zoom = zoom === undefined ? this._zoom : this._limitZoom(zoom); + center = this._limitCenter(toLatLng(center), zoom, this.options.maxBounds); + options = options || {}; + + this._stop(); + + if (this._loaded && !options.reset && options !== true) { + + if (options.animate !== undefined) { + options.zoom = extend({animate: options.animate}, options.zoom); + options.pan = extend({animate: options.animate, duration: options.duration}, options.pan); + } + + // try animating pan or zoom + var moved = (this._zoom !== zoom) ? + this._tryAnimatedZoom && this._tryAnimatedZoom(center, zoom, options.zoom) : + this._tryAnimatedPan(center, options.pan); + + if (moved) { + // prevent resize handler call, the view will refresh after animation anyway + clearTimeout(this._sizeTimer); + return this; + } + } + + // animation didn't start, just reset the map view + this._resetView(center, zoom); + + return this; + }, + + // @method setZoom(zoom: Number, options?: Zoom/pan options): this + // Sets the zoom of the map. + setZoom: function (zoom, options) { + if (!this._loaded) { + this._zoom = zoom; + return this; + } + return this.setView(this.getCenter(), zoom, {zoom: options}); + }, + + // @method zoomIn(delta?: Number, options?: Zoom options): this + // Increases the zoom of the map by `delta` ([`zoomDelta`](#map-zoomdelta) by default). + zoomIn: function (delta, options) { + delta = delta || (any3d ? this.options.zoomDelta : 1); + return this.setZoom(this._zoom + delta, options); + }, + + // @method zoomOut(delta?: Number, options?: Zoom options): this + // Decreases the zoom of the map by `delta` ([`zoomDelta`](#map-zoomdelta) by default). + zoomOut: function (delta, options) { + delta = delta || (any3d ? this.options.zoomDelta : 1); + return this.setZoom(this._zoom - delta, options); + }, + + // @method setZoomAround(latlng: LatLng, zoom: Number, options: Zoom options): this + // Zooms the map while keeping a specified geographical point on the map + // stationary (e.g. used internally for scroll zoom and double-click zoom). + // @alternative + // @method setZoomAround(offset: Point, zoom: Number, options: Zoom options): this + // Zooms the map while keeping a specified pixel on the map (relative to the top-left corner) stationary. + setZoomAround: function (latlng, zoom, options) { + var scale = this.getZoomScale(zoom), + viewHalf = this.getSize().divideBy(2), + containerPoint = latlng instanceof Point ? latlng : this.latLngToContainerPoint(latlng), + + centerOffset = containerPoint.subtract(viewHalf).multiplyBy(1 - 1 / scale), + newCenter = this.containerPointToLatLng(viewHalf.add(centerOffset)); + + return this.setView(newCenter, zoom, {zoom: options}); + }, + + _getBoundsCenterZoom: function (bounds, options) { + + options = options || {}; + bounds = bounds.getBounds ? bounds.getBounds() : toLatLngBounds(bounds); + + var paddingTL = toPoint(options.paddingTopLeft || options.padding || [0, 0]), + paddingBR = toPoint(options.paddingBottomRight || options.padding || [0, 0]), + + zoom = this.getBoundsZoom(bounds, false, paddingTL.add(paddingBR)); + + zoom = (typeof options.maxZoom === 'number') ? Math.min(options.maxZoom, zoom) : zoom; + + if (zoom === Infinity) { + return { + center: bounds.getCenter(), + zoom: zoom + }; + } + + var paddingOffset = paddingBR.subtract(paddingTL).divideBy(2), + + swPoint = this.project(bounds.getSouthWest(), zoom), + nePoint = this.project(bounds.getNorthEast(), zoom), + center = this.unproject(swPoint.add(nePoint).divideBy(2).add(paddingOffset), zoom); + + return { + center: center, + zoom: zoom + }; + }, + + // @method fitBounds(bounds: LatLngBounds, options?: fitBounds options): this + // Sets a map view that contains the given geographical bounds with the + // maximum zoom level possible. + fitBounds: function (bounds, options) { + + bounds = toLatLngBounds(bounds); + + if (!bounds.isValid()) { + throw new Error('Bounds are not valid.'); + } + + var target = this._getBoundsCenterZoom(bounds, options); + return this.setView(target.center, target.zoom, options); + }, + + // @method fitWorld(options?: fitBounds options): this + // Sets a map view that mostly contains the whole world with the maximum + // zoom level possible. + fitWorld: function (options) { + return this.fitBounds([[-90, -180], [90, 180]], options); + }, + + // @method panTo(latlng: LatLng, options?: Pan options): this + // Pans the map to a given center. + panTo: function (center, options) { // (LatLng) + return this.setView(center, this._zoom, {pan: options}); + }, + + // @method panBy(offset: Point, options?: Pan options): this + // Pans the map by a given number of pixels (animated). + panBy: function (offset, options) { + offset = toPoint(offset).round(); + options = options || {}; + + if (!offset.x && !offset.y) { + return this.fire('moveend'); + } + // If we pan too far, Chrome gets issues with tiles + // and makes them disappear or appear in the wrong place (slightly offset) #2602 + if (options.animate !== true && !this.getSize().contains(offset)) { + this._resetView(this.unproject(this.project(this.getCenter()).add(offset)), this.getZoom()); + return this; + } + + if (!this._panAnim) { + this._panAnim = new PosAnimation(); + + this._panAnim.on({ + 'step': this._onPanTransitionStep, + 'end': this._onPanTransitionEnd + }, this); + } + + // don't fire movestart if animating inertia + if (!options.noMoveStart) { + this.fire('movestart'); + } + + // animate pan unless animate: false specified + if (options.animate !== false) { + addClass(this._mapPane, 'leaflet-pan-anim'); + + var newPos = this._getMapPanePos().subtract(offset).round(); + this._panAnim.run(this._mapPane, newPos, options.duration || 0.25, options.easeLinearity); + } else { + this._rawPanBy(offset); + this.fire('move').fire('moveend'); + } + + return this; + }, + + // @method flyTo(latlng: LatLng, zoom?: Number, options?: Zoom/pan options): this + // Sets the view of the map (geographical center and zoom) performing a smooth + // pan-zoom animation. + flyTo: function (targetCenter, targetZoom, options) { + + options = options || {}; + if (options.animate === false || !any3d) { + return this.setView(targetCenter, targetZoom, options); + } + + this._stop(); + + var from = this.project(this.getCenter()), + to = this.project(targetCenter), + size = this.getSize(), + startZoom = this._zoom; + + targetCenter = toLatLng(targetCenter); + targetZoom = targetZoom === undefined ? startZoom : targetZoom; + + var w0 = Math.max(size.x, size.y), + w1 = w0 * this.getZoomScale(startZoom, targetZoom), + u1 = (to.distanceTo(from)) || 1, + rho = 1.42, + rho2 = rho * rho; + + function r(i) { + var s1 = i ? -1 : 1, + s2 = i ? w1 : w0, + t1 = w1 * w1 - w0 * w0 + s1 * rho2 * rho2 * u1 * u1, + b1 = 2 * s2 * rho2 * u1, + b = t1 / b1, + sq = Math.sqrt(b * b + 1) - b; + + // workaround for floating point precision bug when sq = 0, log = -Infinite, + // thus triggering an infinite loop in flyTo + var log = sq < 0.000000001 ? -18 : Math.log(sq); + + return log; + } + + function sinh(n) { return (Math.exp(n) - Math.exp(-n)) / 2; } + function cosh(n) { return (Math.exp(n) + Math.exp(-n)) / 2; } + function tanh(n) { return sinh(n) / cosh(n); } + + var r0 = r(0); + + function w(s) { return w0 * (cosh(r0) / cosh(r0 + rho * s)); } + function u(s) { return w0 * (cosh(r0) * tanh(r0 + rho * s) - sinh(r0)) / rho2; } + + function easeOut(t) { return 1 - Math.pow(1 - t, 1.5); } + + var start = Date.now(), + S = (r(1) - r0) / rho, + duration = options.duration ? 1000 * options.duration : 1000 * S * 0.8; + + function frame() { + var t = (Date.now() - start) / duration, + s = easeOut(t) * S; + + if (t <= 1) { + this._flyToFrame = requestAnimFrame(frame, this); + + this._move( + this.unproject(from.add(to.subtract(from).multiplyBy(u(s) / u1)), startZoom), + this.getScaleZoom(w0 / w(s), startZoom), + {flyTo: true}); + + } else { + this + ._move(targetCenter, targetZoom) + ._moveEnd(true); + } + } + + this._moveStart(true, options.noMoveStart); + + frame.call(this); + return this; + }, + + // @method flyToBounds(bounds: LatLngBounds, options?: fitBounds options): this + // Sets the view of the map with a smooth animation like [`flyTo`](#map-flyto), + // but takes a bounds parameter like [`fitBounds`](#map-fitbounds). + flyToBounds: function (bounds, options) { + var target = this._getBoundsCenterZoom(bounds, options); + return this.flyTo(target.center, target.zoom, options); + }, + + // @method setMaxBounds(bounds: LatLngBounds): this + // Restricts the map view to the given bounds (see the [maxBounds](#map-maxbounds) option). + setMaxBounds: function (bounds) { + bounds = toLatLngBounds(bounds); + + if (!bounds.isValid()) { + this.options.maxBounds = null; + return this.off('moveend', this._panInsideMaxBounds); + } else if (this.options.maxBounds) { + this.off('moveend', this._panInsideMaxBounds); + } + + this.options.maxBounds = bounds; + + if (this._loaded) { + this._panInsideMaxBounds(); + } + + return this.on('moveend', this._panInsideMaxBounds); + }, + + // @method setMinZoom(zoom: Number): this + // Sets the lower limit for the available zoom levels (see the [minZoom](#map-minzoom) option). + setMinZoom: function (zoom) { + var oldZoom = this.options.minZoom; + this.options.minZoom = zoom; + + if (this._loaded && oldZoom !== zoom) { + this.fire('zoomlevelschange'); + + if (this.getZoom() < this.options.minZoom) { + return this.setZoom(zoom); + } + } + + return this; + }, + + // @method setMaxZoom(zoom: Number): this + // Sets the upper limit for the available zoom levels (see the [maxZoom](#map-maxzoom) option). + setMaxZoom: function (zoom) { + var oldZoom = this.options.maxZoom; + this.options.maxZoom = zoom; + + if (this._loaded && oldZoom !== zoom) { + this.fire('zoomlevelschange'); + + if (this.getZoom() > this.options.maxZoom) { + return this.setZoom(zoom); + } + } + + return this; + }, + + // @method panInsideBounds(bounds: LatLngBounds, options?: Pan options): this + // Pans the map to the closest view that would lie inside the given bounds (if it's not already), controlling the animation using the options specific, if any. + panInsideBounds: function (bounds, options) { + this._enforcingBounds = true; + var center = this.getCenter(), + newCenter = this._limitCenter(center, this._zoom, toLatLngBounds(bounds)); + + if (!center.equals(newCenter)) { + this.panTo(newCenter, options); + } + + this._enforcingBounds = false; + return this; + }, + + // @method panInside(latlng: LatLng, options?: options): this + // Pans the map the minimum amount to make the `latlng` visible. Use + // `padding`, `paddingTopLeft` and `paddingTopRight` options to fit + // the display to more restricted bounds, like [`fitBounds`](#map-fitbounds). + // If `latlng` is already within the (optionally padded) display bounds, + // the map will not be panned. + panInside: function (latlng, options) { + options = options || {}; + + var paddingTL = toPoint(options.paddingTopLeft || options.padding || [0, 0]), + paddingBR = toPoint(options.paddingBottomRight || options.padding || [0, 0]), + center = this.getCenter(), + pixelCenter = this.project(center), + pixelPoint = this.project(latlng), + pixelBounds = this.getPixelBounds(), + halfPixelBounds = pixelBounds.getSize().divideBy(2), + paddedBounds = toBounds([pixelBounds.min.add(paddingTL), pixelBounds.max.subtract(paddingBR)]); + + if (!paddedBounds.contains(pixelPoint)) { + this._enforcingBounds = true; + var diff = pixelCenter.subtract(pixelPoint), + newCenter = toPoint(pixelPoint.x + diff.x, pixelPoint.y + diff.y); + + if (pixelPoint.x < paddedBounds.min.x || pixelPoint.x > paddedBounds.max.x) { + newCenter.x = pixelCenter.x - diff.x; + if (diff.x > 0) { + newCenter.x += halfPixelBounds.x - paddingTL.x; + } else { + newCenter.x -= halfPixelBounds.x - paddingBR.x; + } + } + if (pixelPoint.y < paddedBounds.min.y || pixelPoint.y > paddedBounds.max.y) { + newCenter.y = pixelCenter.y - diff.y; + if (diff.y > 0) { + newCenter.y += halfPixelBounds.y - paddingTL.y; + } else { + newCenter.y -= halfPixelBounds.y - paddingBR.y; + } + } + this.panTo(this.unproject(newCenter), options); + this._enforcingBounds = false; + } + return this; + }, + + // @method invalidateSize(options: Zoom/pan options): this + // Checks if the map container size changed and updates the map if so — + // call it after you've changed the map size dynamically, also animating + // pan by default. If `options.pan` is `false`, panning will not occur. + // If `options.debounceMoveend` is `true`, it will delay `moveend` event so + // that it doesn't happen often even if the method is called many + // times in a row. + + // @alternative + // @method invalidateSize(animate: Boolean): this + // Checks if the map container size changed and updates the map if so — + // call it after you've changed the map size dynamically, also animating + // pan by default. + invalidateSize: function (options) { + if (!this._loaded) { return this; } + + options = extend({ + animate: false, + pan: true + }, options === true ? {animate: true} : options); + + var oldSize = this.getSize(); + this._sizeChanged = true; + this._lastCenter = null; + + var newSize = this.getSize(), + oldCenter = oldSize.divideBy(2).round(), + newCenter = newSize.divideBy(2).round(), + offset = oldCenter.subtract(newCenter); + + if (!offset.x && !offset.y) { return this; } + + if (options.animate && options.pan) { + this.panBy(offset); + + } else { + if (options.pan) { + this._rawPanBy(offset); + } + + this.fire('move'); + + if (options.debounceMoveend) { + clearTimeout(this._sizeTimer); + this._sizeTimer = setTimeout(bind(this.fire, this, 'moveend'), 200); + } else { + this.fire('moveend'); + } + } + + // @section Map state change events + // @event resize: ResizeEvent + // Fired when the map is resized. + return this.fire('resize', { + oldSize: oldSize, + newSize: newSize + }); + }, + + // @section Methods for modifying map state + // @method stop(): this + // Stops the currently running `panTo` or `flyTo` animation, if any. + stop: function () { + this.setZoom(this._limitZoom(this._zoom)); + if (!this.options.zoomSnap) { + this.fire('viewreset'); + } + return this._stop(); + }, + + // @section Geolocation methods + // @method locate(options?: Locate options): this + // Tries to locate the user using the Geolocation API, firing a [`locationfound`](#map-locationfound) + // event with location data on success or a [`locationerror`](#map-locationerror) event on failure, + // and optionally sets the map view to the user's location with respect to + // detection accuracy (or to the world view if geolocation failed). + // Note that, if your page doesn't use HTTPS, this method will fail in + // modern browsers ([Chrome 50 and newer](https://sites.google.com/a/chromium.org/dev/Home/chromium-security/deprecating-powerful-features-on-insecure-origins)) + // See `Locate options` for more details. + locate: function (options) { + + options = this._locateOptions = extend({ + timeout: 10000, + watch: false + // setView: false + // maxZoom: + // maximumAge: 0 + // enableHighAccuracy: false + }, options); + + if (!('geolocation' in navigator)) { + this._handleGeolocationError({ + code: 0, + message: 'Geolocation not supported.' + }); + return this; + } + + var onResponse = bind(this._handleGeolocationResponse, this), + onError = bind(this._handleGeolocationError, this); + + if (options.watch) { + this._locationWatchId = + navigator.geolocation.watchPosition(onResponse, onError, options); + } else { + navigator.geolocation.getCurrentPosition(onResponse, onError, options); + } + return this; + }, + + // @method stopLocate(): this + // Stops watching location previously initiated by `map.locate({watch: true})` + // and aborts resetting the map view if map.locate was called with + // `{setView: true}`. + stopLocate: function () { + if (navigator.geolocation && navigator.geolocation.clearWatch) { + navigator.geolocation.clearWatch(this._locationWatchId); + } + if (this._locateOptions) { + this._locateOptions.setView = false; + } + return this; + }, + + _handleGeolocationError: function (error) { + var c = error.code, + message = error.message || + (c === 1 ? 'permission denied' : + (c === 2 ? 'position unavailable' : 'timeout')); + + if (this._locateOptions.setView && !this._loaded) { + this.fitWorld(); + } + + // @section Location events + // @event locationerror: ErrorEvent + // Fired when geolocation (using the [`locate`](#map-locate) method) failed. + this.fire('locationerror', { + code: c, + message: 'Geolocation error: ' + message + '.' + }); + }, + + _handleGeolocationResponse: function (pos) { + var lat = pos.coords.latitude, + lng = pos.coords.longitude, + latlng = new LatLng(lat, lng), + bounds = latlng.toBounds(pos.coords.accuracy * 2), + options = this._locateOptions; + + if (options.setView) { + var zoom = this.getBoundsZoom(bounds); + this.setView(latlng, options.maxZoom ? Math.min(zoom, options.maxZoom) : zoom); + } + + var data = { + latlng: latlng, + bounds: bounds, + timestamp: pos.timestamp + }; + + for (var i in pos.coords) { + if (typeof pos.coords[i] === 'number') { + data[i] = pos.coords[i]; + } + } + + // @event locationfound: LocationEvent + // Fired when geolocation (using the [`locate`](#map-locate) method) + // went successfully. + this.fire('locationfound', data); + }, + + // TODO Appropriate docs section? + // @section Other Methods + // @method addHandler(name: String, HandlerClass: Function): this + // Adds a new `Handler` to the map, given its name and constructor function. + addHandler: function (name, HandlerClass) { + if (!HandlerClass) { return this; } + + var handler = this[name] = new HandlerClass(this); + + this._handlers.push(handler); + + if (this.options[name]) { + handler.enable(); + } + + return this; + }, + + // @method remove(): this + // Destroys the map and clears all related event listeners. + remove: function () { + + this._initEvents(true); + this.off('moveend', this._panInsideMaxBounds); + + if (this._containerId !== this._container._leaflet_id) { + throw new Error('Map container is being reused by another instance'); + } + + try { + // throws error in IE6-8 + delete this._container._leaflet_id; + delete this._containerId; + } catch (e) { + /*eslint-disable */ + this._container._leaflet_id = undefined; + /* eslint-enable */ + this._containerId = undefined; + } + + if (this._locationWatchId !== undefined) { + this.stopLocate(); + } + + this._stop(); + + remove(this._mapPane); + + if (this._clearControlPos) { + this._clearControlPos(); + } + if (this._resizeRequest) { + cancelAnimFrame(this._resizeRequest); + this._resizeRequest = null; + } + + this._clearHandlers(); + + if (this._loaded) { + // @section Map state change events + // @event unload: Event + // Fired when the map is destroyed with [remove](#map-remove) method. + this.fire('unload'); + } + + var i; + for (i in this._layers) { + this._layers[i].remove(); + } + for (i in this._panes) { + remove(this._panes[i]); + } + + this._layers = []; + this._panes = []; + delete this._mapPane; + delete this._renderer; + + return this; + }, + + // @section Other Methods + // @method createPane(name: String, container?: HTMLElement): HTMLElement + // Creates a new [map pane](#map-pane) with the given name if it doesn't exist already, + // then returns it. The pane is created as a child of `container`, or + // as a child of the main map pane if not set. + createPane: function (name, container) { + var className = 'leaflet-pane' + (name ? ' leaflet-' + name.replace('Pane', '') + '-pane' : ''), + pane = create$1('div', className, container || this._mapPane); + + if (name) { + this._panes[name] = pane; + } + return pane; + }, + + // @section Methods for Getting Map State + + // @method getCenter(): LatLng + // Returns the geographical center of the map view + getCenter: function () { + this._checkIfLoaded(); + + if (this._lastCenter && !this._moved()) { + return this._lastCenter; + } + return this.layerPointToLatLng(this._getCenterLayerPoint()); + }, + + // @method getZoom(): Number + // Returns the current zoom level of the map view + getZoom: function () { + return this._zoom; + }, + + // @method getBounds(): LatLngBounds + // Returns the geographical bounds visible in the current map view + getBounds: function () { + var bounds = this.getPixelBounds(), + sw = this.unproject(bounds.getBottomLeft()), + ne = this.unproject(bounds.getTopRight()); + + return new LatLngBounds(sw, ne); + }, + + // @method getMinZoom(): Number + // Returns the minimum zoom level of the map (if set in the `minZoom` option of the map or of any layers), or `0` by default. + getMinZoom: function () { + return this.options.minZoom === undefined ? this._layersMinZoom || 0 : this.options.minZoom; + }, + + // @method getMaxZoom(): Number + // Returns the maximum zoom level of the map (if set in the `maxZoom` option of the map or of any layers). + getMaxZoom: function () { + return this.options.maxZoom === undefined ? + (this._layersMaxZoom === undefined ? Infinity : this._layersMaxZoom) : + this.options.maxZoom; + }, + + // @method getBoundsZoom(bounds: LatLngBounds, inside?: Boolean, padding?: Point): Number + // Returns the maximum zoom level on which the given bounds fit to the map + // view in its entirety. If `inside` (optional) is set to `true`, the method + // instead returns the minimum zoom level on which the map view fits into + // the given bounds in its entirety. + getBoundsZoom: function (bounds, inside, padding) { // (LatLngBounds[, Boolean, Point]) -> Number + bounds = toLatLngBounds(bounds); + padding = toPoint(padding || [0, 0]); + + var zoom = this.getZoom() || 0, + min = this.getMinZoom(), + max = this.getMaxZoom(), + nw = bounds.getNorthWest(), + se = bounds.getSouthEast(), + size = this.getSize().subtract(padding), + boundsSize = toBounds(this.project(se, zoom), this.project(nw, zoom)).getSize(), + snap = any3d ? this.options.zoomSnap : 1, + scalex = size.x / boundsSize.x, + scaley = size.y / boundsSize.y, + scale = inside ? Math.max(scalex, scaley) : Math.min(scalex, scaley); + + zoom = this.getScaleZoom(scale, zoom); + + if (snap) { + zoom = Math.round(zoom / (snap / 100)) * (snap / 100); // don't jump if within 1% of a snap level + zoom = inside ? Math.ceil(zoom / snap) * snap : Math.floor(zoom / snap) * snap; + } + + return Math.max(min, Math.min(max, zoom)); + }, + + // @method getSize(): Point + // Returns the current size of the map container (in pixels). + getSize: function () { + if (!this._size || this._sizeChanged) { + this._size = new Point( + this._container.clientWidth || 0, + this._container.clientHeight || 0); + + this._sizeChanged = false; + } + return this._size.clone(); + }, + + // @method getPixelBounds(): Bounds + // Returns the bounds of the current map view in projected pixel + // coordinates (sometimes useful in layer and overlay implementations). + getPixelBounds: function (center, zoom) { + var topLeftPoint = this._getTopLeftPoint(center, zoom); + return new Bounds(topLeftPoint, topLeftPoint.add(this.getSize())); + }, + + // TODO: Check semantics - isn't the pixel origin the 0,0 coord relative to + // the map pane? "left point of the map layer" can be confusing, specially + // since there can be negative offsets. + // @method getPixelOrigin(): Point + // Returns the projected pixel coordinates of the top left point of + // the map layer (useful in custom layer and overlay implementations). + getPixelOrigin: function () { + this._checkIfLoaded(); + return this._pixelOrigin; + }, + + // @method getPixelWorldBounds(zoom?: Number): Bounds + // Returns the world's bounds in pixel coordinates for zoom level `zoom`. + // If `zoom` is omitted, the map's current zoom level is used. + getPixelWorldBounds: function (zoom) { + return this.options.crs.getProjectedBounds(zoom === undefined ? this.getZoom() : zoom); + }, + + // @section Other Methods + + // @method getPane(pane: String|HTMLElement): HTMLElement + // Returns a [map pane](#map-pane), given its name or its HTML element (its identity). + getPane: function (pane) { + return typeof pane === 'string' ? this._panes[pane] : pane; + }, + + // @method getPanes(): Object + // Returns a plain object containing the names of all [panes](#map-pane) as keys and + // the panes as values. + getPanes: function () { + return this._panes; + }, + + // @method getContainer: HTMLElement + // Returns the HTML element that contains the map. + getContainer: function () { + return this._container; + }, + + + // @section Conversion Methods + + // @method getZoomScale(toZoom: Number, fromZoom: Number): Number + // Returns the scale factor to be applied to a map transition from zoom level + // `fromZoom` to `toZoom`. Used internally to help with zoom animations. + getZoomScale: function (toZoom, fromZoom) { + // TODO replace with universal implementation after refactoring projections + var crs = this.options.crs; + fromZoom = fromZoom === undefined ? this._zoom : fromZoom; + return crs.scale(toZoom) / crs.scale(fromZoom); + }, + + // @method getScaleZoom(scale: Number, fromZoom: Number): Number + // Returns the zoom level that the map would end up at, if it is at `fromZoom` + // level and everything is scaled by a factor of `scale`. Inverse of + // [`getZoomScale`](#map-getZoomScale). + getScaleZoom: function (scale, fromZoom) { + var crs = this.options.crs; + fromZoom = fromZoom === undefined ? this._zoom : fromZoom; + var zoom = crs.zoom(scale * crs.scale(fromZoom)); + return isNaN(zoom) ? Infinity : zoom; + }, + + // @method project(latlng: LatLng, zoom: Number): Point + // Projects a geographical coordinate `LatLng` according to the projection + // of the map's CRS, then scales it according to `zoom` and the CRS's + // `Transformation`. The result is pixel coordinate relative to + // the CRS origin. + project: function (latlng, zoom) { + zoom = zoom === undefined ? this._zoom : zoom; + return this.options.crs.latLngToPoint(toLatLng(latlng), zoom); + }, + + // @method unproject(point: Point, zoom: Number): LatLng + // Inverse of [`project`](#map-project). + unproject: function (point, zoom) { + zoom = zoom === undefined ? this._zoom : zoom; + return this.options.crs.pointToLatLng(toPoint(point), zoom); + }, + + // @method layerPointToLatLng(point: Point): LatLng + // Given a pixel coordinate relative to the [origin pixel](#map-getpixelorigin), + // returns the corresponding geographical coordinate (for the current zoom level). + layerPointToLatLng: function (point) { + var projectedPoint = toPoint(point).add(this.getPixelOrigin()); + return this.unproject(projectedPoint); + }, + + // @method latLngToLayerPoint(latlng: LatLng): Point + // Given a geographical coordinate, returns the corresponding pixel coordinate + // relative to the [origin pixel](#map-getpixelorigin). + latLngToLayerPoint: function (latlng) { + var projectedPoint = this.project(toLatLng(latlng))._round(); + return projectedPoint._subtract(this.getPixelOrigin()); + }, + + // @method wrapLatLng(latlng: LatLng): LatLng + // Returns a `LatLng` where `lat` and `lng` has been wrapped according to the + // map's CRS's `wrapLat` and `wrapLng` properties, if they are outside the + // CRS's bounds. + // By default this means longitude is wrapped around the dateline so its + // value is between -180 and +180 degrees. + wrapLatLng: function (latlng) { + return this.options.crs.wrapLatLng(toLatLng(latlng)); + }, + + // @method wrapLatLngBounds(bounds: LatLngBounds): LatLngBounds + // Returns a `LatLngBounds` with the same size as the given one, ensuring that + // its center is within the CRS's bounds. + // By default this means the center longitude is wrapped around the dateline so its + // value is between -180 and +180 degrees, and the majority of the bounds + // overlaps the CRS's bounds. + wrapLatLngBounds: function (latlng) { + return this.options.crs.wrapLatLngBounds(toLatLngBounds(latlng)); + }, + + // @method distance(latlng1: LatLng, latlng2: LatLng): Number + // Returns the distance between two geographical coordinates according to + // the map's CRS. By default this measures distance in meters. + distance: function (latlng1, latlng2) { + return this.options.crs.distance(toLatLng(latlng1), toLatLng(latlng2)); + }, + + // @method containerPointToLayerPoint(point: Point): Point + // Given a pixel coordinate relative to the map container, returns the corresponding + // pixel coordinate relative to the [origin pixel](#map-getpixelorigin). + containerPointToLayerPoint: function (point) { // (Point) + return toPoint(point).subtract(this._getMapPanePos()); + }, + + // @method layerPointToContainerPoint(point: Point): Point + // Given a pixel coordinate relative to the [origin pixel](#map-getpixelorigin), + // returns the corresponding pixel coordinate relative to the map container. + layerPointToContainerPoint: function (point) { // (Point) + return toPoint(point).add(this._getMapPanePos()); + }, + + // @method containerPointToLatLng(point: Point): LatLng + // Given a pixel coordinate relative to the map container, returns + // the corresponding geographical coordinate (for the current zoom level). + containerPointToLatLng: function (point) { + var layerPoint = this.containerPointToLayerPoint(toPoint(point)); + return this.layerPointToLatLng(layerPoint); + }, + + // @method latLngToContainerPoint(latlng: LatLng): Point + // Given a geographical coordinate, returns the corresponding pixel coordinate + // relative to the map container. + latLngToContainerPoint: function (latlng) { + return this.layerPointToContainerPoint(this.latLngToLayerPoint(toLatLng(latlng))); + }, + + // @method mouseEventToContainerPoint(ev: MouseEvent): Point + // Given a MouseEvent object, returns the pixel coordinate relative to the + // map container where the event took place. + mouseEventToContainerPoint: function (e) { + return getMousePosition(e, this._container); + }, + + // @method mouseEventToLayerPoint(ev: MouseEvent): Point + // Given a MouseEvent object, returns the pixel coordinate relative to + // the [origin pixel](#map-getpixelorigin) where the event took place. + mouseEventToLayerPoint: function (e) { + return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(e)); + }, + + // @method mouseEventToLatLng(ev: MouseEvent): LatLng + // Given a MouseEvent object, returns geographical coordinate where the + // event took place. + mouseEventToLatLng: function (e) { // (MouseEvent) + return this.layerPointToLatLng(this.mouseEventToLayerPoint(e)); + }, + + + // map initialization methods + + _initContainer: function (id) { + var container = this._container = get(id); + + if (!container) { + throw new Error('Map container not found.'); + } else if (container._leaflet_id) { + throw new Error('Map container is already initialized.'); + } + + on(container, 'scroll', this._onScroll, this); + this._containerId = stamp(container); + }, + + _initLayout: function () { + var container = this._container; + + this._fadeAnimated = this.options.fadeAnimation && any3d; + + addClass(container, 'leaflet-container' + + (touch ? ' leaflet-touch' : '') + + (retina ? ' leaflet-retina' : '') + + (ielt9 ? ' leaflet-oldie' : '') + + (safari ? ' leaflet-safari' : '') + + (this._fadeAnimated ? ' leaflet-fade-anim' : '')); + + var position = getStyle(container, 'position'); + + if (position !== 'absolute' && position !== 'relative' && position !== 'fixed') { + container.style.position = 'relative'; + } + + this._initPanes(); + + if (this._initControlPos) { + this._initControlPos(); + } + }, + + _initPanes: function () { + var panes = this._panes = {}; + this._paneRenderers = {}; + + // @section + // + // Panes are DOM elements used to control the ordering of layers on the map. You + // can access panes with [`map.getPane`](#map-getpane) or + // [`map.getPanes`](#map-getpanes) methods. New panes can be created with the + // [`map.createPane`](#map-createpane) method. + // + // Every map has the following default panes that differ only in zIndex. + // + // @pane mapPane: HTMLElement = 'auto' + // Pane that contains all other map panes + + this._mapPane = this.createPane('mapPane', this._container); + setPosition(this._mapPane, new Point(0, 0)); + + // @pane tilePane: HTMLElement = 200 + // Pane for `GridLayer`s and `TileLayer`s + this.createPane('tilePane'); + // @pane overlayPane: HTMLElement = 400 + // Pane for overlay shadows (e.g. `Marker` shadows) + this.createPane('shadowPane'); + // @pane shadowPane: HTMLElement = 500 + // Pane for vectors (`Path`s, like `Polyline`s and `Polygon`s), `ImageOverlay`s and `VideoOverlay`s + this.createPane('overlayPane'); + // @pane markerPane: HTMLElement = 600 + // Pane for `Icon`s of `Marker`s + this.createPane('markerPane'); + // @pane tooltipPane: HTMLElement = 650 + // Pane for `Tooltip`s. + this.createPane('tooltipPane'); + // @pane popupPane: HTMLElement = 700 + // Pane for `Popup`s. + this.createPane('popupPane'); + + if (!this.options.markerZoomAnimation) { + addClass(panes.markerPane, 'leaflet-zoom-hide'); + addClass(panes.shadowPane, 'leaflet-zoom-hide'); + } + }, + + + // private methods that modify map state + + // @section Map state change events + _resetView: function (center, zoom) { + setPosition(this._mapPane, new Point(0, 0)); + + var loading = !this._loaded; + this._loaded = true; + zoom = this._limitZoom(zoom); + + this.fire('viewprereset'); + + var zoomChanged = this._zoom !== zoom; + this + ._moveStart(zoomChanged, false) + ._move(center, zoom) + ._moveEnd(zoomChanged); + + // @event viewreset: Event + // Fired when the map needs to redraw its content (this usually happens + // on map zoom or load). Very useful for creating custom overlays. + this.fire('viewreset'); + + // @event load: Event + // Fired when the map is initialized (when its center and zoom are set + // for the first time). + if (loading) { + this.fire('load'); + } + }, + + _moveStart: function (zoomChanged, noMoveStart) { + // @event zoomstart: Event + // Fired when the map zoom is about to change (e.g. before zoom animation). + // @event movestart: Event + // Fired when the view of the map starts changing (e.g. user starts dragging the map). + if (zoomChanged) { + this.fire('zoomstart'); + } + if (!noMoveStart) { + this.fire('movestart'); + } + return this; + }, + + _move: function (center, zoom, data) { + if (zoom === undefined) { + zoom = this._zoom; + } + var zoomChanged = this._zoom !== zoom; + + this._zoom = zoom; + this._lastCenter = center; + this._pixelOrigin = this._getNewPixelOrigin(center); + + // @event zoom: Event + // Fired repeatedly during any change in zoom level, including zoom + // and fly animations. + if (zoomChanged || (data && data.pinch)) { // Always fire 'zoom' if pinching because #3530 + this.fire('zoom', data); + } + + // @event move: Event + // Fired repeatedly during any movement of the map, including pan and + // fly animations. + return this.fire('move', data); + }, + + _moveEnd: function (zoomChanged) { + // @event zoomend: Event + // Fired when the map has changed, after any animations. + if (zoomChanged) { + this.fire('zoomend'); + } + + // @event moveend: Event + // Fired when the center of the map stops changing (e.g. user stopped + // dragging the map). + return this.fire('moveend'); + }, + + _stop: function () { + cancelAnimFrame(this._flyToFrame); + if (this._panAnim) { + this._panAnim.stop(); + } + return this; + }, + + _rawPanBy: function (offset) { + setPosition(this._mapPane, this._getMapPanePos().subtract(offset)); + }, + + _getZoomSpan: function () { + return this.getMaxZoom() - this.getMinZoom(); + }, + + _panInsideMaxBounds: function () { + if (!this._enforcingBounds) { + this.panInsideBounds(this.options.maxBounds); + } + }, + + _checkIfLoaded: function () { + if (!this._loaded) { + throw new Error('Set map center and zoom first.'); + } + }, + + // DOM event handling + + // @section Interaction events + _initEvents: function (remove$$1) { + this._targets = {}; + this._targets[stamp(this._container)] = this; + + var onOff = remove$$1 ? off : on; + + // @event click: MouseEvent + // Fired when the user clicks (or taps) the map. + // @event dblclick: MouseEvent + // Fired when the user double-clicks (or double-taps) the map. + // @event mousedown: MouseEvent + // Fired when the user pushes the mouse button on the map. + // @event mouseup: MouseEvent + // Fired when the user releases the mouse button on the map. + // @event mouseover: MouseEvent + // Fired when the mouse enters the map. + // @event mouseout: MouseEvent + // Fired when the mouse leaves the map. + // @event mousemove: MouseEvent + // Fired while the mouse moves over the map. + // @event contextmenu: MouseEvent + // Fired when the user pushes the right mouse button on the map, prevents + // default browser context menu from showing if there are listeners on + // this event. Also fired on mobile when the user holds a single touch + // for a second (also called long press). + // @event keypress: KeyboardEvent + // Fired when the user presses a key from the keyboard that produces a character value while the map is focused. + // @event keydown: KeyboardEvent + // Fired when the user presses a key from the keyboard while the map is focused. Unlike the `keypress` event, + // the `keydown` event is fired for keys that produce a character value and for keys + // that do not produce a character value. + // @event keyup: KeyboardEvent + // Fired when the user releases a key from the keyboard while the map is focused. + onOff(this._container, 'click dblclick mousedown mouseup ' + + 'mouseover mouseout mousemove contextmenu keypress keydown keyup', this._handleDOMEvent, this); + + if (this.options.trackResize) { + onOff(window, 'resize', this._onResize, this); + } + + if (any3d && this.options.transform3DLimit) { + (remove$$1 ? this.off : this.on).call(this, 'moveend', this._onMoveEnd); + } + }, + + _onResize: function () { + cancelAnimFrame(this._resizeRequest); + this._resizeRequest = requestAnimFrame( + function () { this.invalidateSize({debounceMoveend: true}); }, this); + }, + + _onScroll: function () { + this._container.scrollTop = 0; + this._container.scrollLeft = 0; + }, + + _onMoveEnd: function () { + var pos = this._getMapPanePos(); + if (Math.max(Math.abs(pos.x), Math.abs(pos.y)) >= this.options.transform3DLimit) { + // https://bugzilla.mozilla.org/show_bug.cgi?id=1203873 but Webkit also have + // a pixel offset on very high values, see: http://jsfiddle.net/dg6r5hhb/ + this._resetView(this.getCenter(), this.getZoom()); + } + }, + + _findEventTargets: function (e, type) { + var targets = [], + target, + isHover = type === 'mouseout' || type === 'mouseover', + src = e.target || e.srcElement, + dragging = false; + + while (src) { + target = this._targets[stamp(src)]; + if (target && (type === 'click' || type === 'preclick') && !e._simulated && this._draggableMoved(target)) { + // Prevent firing click after you just dragged an object. + dragging = true; + break; + } + if (target && target.listens(type, true)) { + if (isHover && !isExternalTarget(src, e)) { break; } + targets.push(target); + if (isHover) { break; } + } + if (src === this._container) { break; } + src = src.parentNode; + } + if (!targets.length && !dragging && !isHover && isExternalTarget(src, e)) { + targets = [this]; + } + return targets; + }, + + _handleDOMEvent: function (e) { + if (!this._loaded || skipped(e)) { return; } + + var type = e.type; + + if (type === 'mousedown' || type === 'keypress' || type === 'keyup' || type === 'keydown') { + // prevents outline when clicking on keyboard-focusable element + preventOutline(e.target || e.srcElement); + } + + this._fireDOMEvent(e, type); + }, + + _mouseEvents: ['click', 'dblclick', 'mouseover', 'mouseout', 'contextmenu'], + + _fireDOMEvent: function (e, type, targets) { + + if (e.type === 'click') { + // Fire a synthetic 'preclick' event which propagates up (mainly for closing popups). + // @event preclick: MouseEvent + // Fired before mouse click on the map (sometimes useful when you + // want something to happen on click before any existing click + // handlers start running). + var synth = extend({}, e); + synth.type = 'preclick'; + this._fireDOMEvent(synth, synth.type, targets); + } + + if (e._stopped) { return; } + + // Find the layer the event is propagating from and its parents. + targets = (targets || []).concat(this._findEventTargets(e, type)); + + if (!targets.length) { return; } + + var target = targets[0]; + if (type === 'contextmenu' && target.listens(type, true)) { + preventDefault(e); + } + + var data = { + originalEvent: e + }; + + if (e.type !== 'keypress' && e.type !== 'keydown' && e.type !== 'keyup') { + var isMarker = target.getLatLng && (!target._radius || target._radius <= 10); + data.containerPoint = isMarker ? + this.latLngToContainerPoint(target.getLatLng()) : this.mouseEventToContainerPoint(e); + data.layerPoint = this.containerPointToLayerPoint(data.containerPoint); + data.latlng = isMarker ? target.getLatLng() : this.layerPointToLatLng(data.layerPoint); + } + + for (var i = 0; i < targets.length; i++) { + targets[i].fire(type, data, true); + if (data.originalEvent._stopped || + (targets[i].options.bubblingMouseEvents === false && indexOf(this._mouseEvents, type) !== -1)) { return; } + } + }, + + _draggableMoved: function (obj) { + obj = obj.dragging && obj.dragging.enabled() ? obj : this; + return (obj.dragging && obj.dragging.moved()) || (this.boxZoom && this.boxZoom.moved()); + }, + + _clearHandlers: function () { + for (var i = 0, len = this._handlers.length; i < len; i++) { + this._handlers[i].disable(); + } + }, + + // @section Other Methods + + // @method whenReady(fn: Function, context?: Object): this + // Runs the given function `fn` when the map gets initialized with + // a view (center and zoom) and at least one layer, or immediately + // if it's already initialized, optionally passing a function context. + whenReady: function (callback, context) { + if (this._loaded) { + callback.call(context || this, {target: this}); + } else { + this.on('load', callback, context); + } + return this; + }, + + + // private methods for getting map state + + _getMapPanePos: function () { + return getPosition(this._mapPane) || new Point(0, 0); + }, + + _moved: function () { + var pos = this._getMapPanePos(); + return pos && !pos.equals([0, 0]); + }, + + _getTopLeftPoint: function (center, zoom) { + var pixelOrigin = center && zoom !== undefined ? + this._getNewPixelOrigin(center, zoom) : + this.getPixelOrigin(); + return pixelOrigin.subtract(this._getMapPanePos()); + }, + + _getNewPixelOrigin: function (center, zoom) { + var viewHalf = this.getSize()._divideBy(2); + return this.project(center, zoom)._subtract(viewHalf)._add(this._getMapPanePos())._round(); + }, + + _latLngToNewLayerPoint: function (latlng, zoom, center) { + var topLeft = this._getNewPixelOrigin(center, zoom); + return this.project(latlng, zoom)._subtract(topLeft); + }, + + _latLngBoundsToNewLayerBounds: function (latLngBounds, zoom, center) { + var topLeft = this._getNewPixelOrigin(center, zoom); + return toBounds([ + this.project(latLngBounds.getSouthWest(), zoom)._subtract(topLeft), + this.project(latLngBounds.getNorthWest(), zoom)._subtract(topLeft), + this.project(latLngBounds.getSouthEast(), zoom)._subtract(topLeft), + this.project(latLngBounds.getNorthEast(), zoom)._subtract(topLeft) + ]); + }, + + // layer point of the current center + _getCenterLayerPoint: function () { + return this.containerPointToLayerPoint(this.getSize()._divideBy(2)); + }, + + // offset of the specified place to the current center in pixels + _getCenterOffset: function (latlng) { + return this.latLngToLayerPoint(latlng).subtract(this._getCenterLayerPoint()); + }, + + // adjust center for view to get inside bounds + _limitCenter: function (center, zoom, bounds) { + + if (!bounds) { return center; } + + var centerPoint = this.project(center, zoom), + viewHalf = this.getSize().divideBy(2), + viewBounds = new Bounds(centerPoint.subtract(viewHalf), centerPoint.add(viewHalf)), + offset = this._getBoundsOffset(viewBounds, bounds, zoom); + + // If offset is less than a pixel, ignore. + // This prevents unstable projections from getting into + // an infinite loop of tiny offsets. + if (offset.round().equals([0, 0])) { + return center; + } + + return this.unproject(centerPoint.add(offset), zoom); + }, + + // adjust offset for view to get inside bounds + _limitOffset: function (offset, bounds) { + if (!bounds) { return offset; } + + var viewBounds = this.getPixelBounds(), + newBounds = new Bounds(viewBounds.min.add(offset), viewBounds.max.add(offset)); + + return offset.add(this._getBoundsOffset(newBounds, bounds)); + }, + + // returns offset needed for pxBounds to get inside maxBounds at a specified zoom + _getBoundsOffset: function (pxBounds, maxBounds, zoom) { + var projectedMaxBounds = toBounds( + this.project(maxBounds.getNorthEast(), zoom), + this.project(maxBounds.getSouthWest(), zoom) + ), + minOffset = projectedMaxBounds.min.subtract(pxBounds.min), + maxOffset = projectedMaxBounds.max.subtract(pxBounds.max), + + dx = this._rebound(minOffset.x, -maxOffset.x), + dy = this._rebound(minOffset.y, -maxOffset.y); + + return new Point(dx, dy); + }, + + _rebound: function (left, right) { + return left + right > 0 ? + Math.round(left - right) / 2 : + Math.max(0, Math.ceil(left)) - Math.max(0, Math.floor(right)); + }, + + _limitZoom: function (zoom) { + var min = this.getMinZoom(), + max = this.getMaxZoom(), + snap = any3d ? this.options.zoomSnap : 1; + if (snap) { + zoom = Math.round(zoom / snap) * snap; + } + return Math.max(min, Math.min(max, zoom)); + }, + + _onPanTransitionStep: function () { + this.fire('move'); + }, + + _onPanTransitionEnd: function () { + removeClass(this._mapPane, 'leaflet-pan-anim'); + this.fire('moveend'); + }, + + _tryAnimatedPan: function (center, options) { + // difference between the new and current centers in pixels + var offset = this._getCenterOffset(center)._trunc(); + + // don't animate too far unless animate: true specified in options + if ((options && options.animate) !== true && !this.getSize().contains(offset)) { return false; } + + this.panBy(offset, options); + + return true; + }, + + _createAnimProxy: function () { + + var proxy = this._proxy = create$1('div', 'leaflet-proxy leaflet-zoom-animated'); + this._panes.mapPane.appendChild(proxy); + + this.on('zoomanim', function (e) { + var prop = TRANSFORM, + transform = this._proxy.style[prop]; + + setTransform(this._proxy, this.project(e.center, e.zoom), this.getZoomScale(e.zoom, 1)); + + // workaround for case when transform is the same and so transitionend event is not fired + if (transform === this._proxy.style[prop] && this._animatingZoom) { + this._onZoomTransitionEnd(); + } + }, this); + + this.on('load moveend', this._animMoveEnd, this); + + this._on('unload', this._destroyAnimProxy, this); + }, + + _destroyAnimProxy: function () { + remove(this._proxy); + this.off('load moveend', this._animMoveEnd, this); + delete this._proxy; + }, + + _animMoveEnd: function () { + var c = this.getCenter(), + z = this.getZoom(); + setTransform(this._proxy, this.project(c, z), this.getZoomScale(z, 1)); + }, + + _catchTransitionEnd: function (e) { + if (this._animatingZoom && e.propertyName.indexOf('transform') >= 0) { + this._onZoomTransitionEnd(); + } + }, + + _nothingToAnimate: function () { + return !this._container.getElementsByClassName('leaflet-zoom-animated').length; + }, + + _tryAnimatedZoom: function (center, zoom, options) { + + if (this._animatingZoom) { return true; } + + options = options || {}; + + // don't animate if disabled, not supported or zoom difference is too large + if (!this._zoomAnimated || options.animate === false || this._nothingToAnimate() || + Math.abs(zoom - this._zoom) > this.options.zoomAnimationThreshold) { return false; } + + // offset is the pixel coords of the zoom origin relative to the current center + var scale = this.getZoomScale(zoom), + offset = this._getCenterOffset(center)._divideBy(1 - 1 / scale); + + // don't animate if the zoom origin isn't within one screen from the current center, unless forced + if (options.animate !== true && !this.getSize().contains(offset)) { return false; } + + requestAnimFrame(function () { + this + ._moveStart(true, false) + ._animateZoom(center, zoom, true); + }, this); + + return true; + }, + + _animateZoom: function (center, zoom, startAnim, noUpdate) { + if (!this._mapPane) { return; } + + if (startAnim) { + this._animatingZoom = true; + + // remember what center/zoom to set after animation + this._animateToCenter = center; + this._animateToZoom = zoom; + + addClass(this._mapPane, 'leaflet-zoom-anim'); + } + + // @section Other Events + // @event zoomanim: ZoomAnimEvent + // Fired at least once per zoom animation. For continuous zoom, like pinch zooming, fired once per frame during zoom. + this.fire('zoomanim', { + center: center, + zoom: zoom, + noUpdate: noUpdate + }); + + // Work around webkit not firing 'transitionend', see https://github.com/Leaflet/Leaflet/issues/3689, 2693 + setTimeout(bind(this._onZoomTransitionEnd, this), 250); + }, + + _onZoomTransitionEnd: function () { + if (!this._animatingZoom) { return; } + + if (this._mapPane) { + removeClass(this._mapPane, 'leaflet-zoom-anim'); + } + + this._animatingZoom = false; + + this._move(this._animateToCenter, this._animateToZoom); + + // This anim frame should prevent an obscure iOS webkit tile loading race condition. + requestAnimFrame(function () { + this._moveEnd(true); + }, this); + } +}); + +// @section + +// @factory L.map(id: String, options?: Map options) +// Instantiates a map object given the DOM ID of a `
      ` element +// and optionally an object literal with `Map options`. +// +// @alternative +// @factory L.map(el: HTMLElement, options?: Map options) +// Instantiates a map object given an instance of a `
      ` HTML element +// and optionally an object literal with `Map options`. +function createMap(id, options) { + return new Map(id, options); } -/* - * @class Control - * @aka L.Control - * @inherits Class - * - * L.Control is a base class for implementing map controls. Handles positioning. - * All other controls extend from this class. - */ - -var Control = Class.extend({ - // @section - // @aka Control options - options: { - // @option position: String = 'topright' - // The position of the control (one of the map corners). Possible values are `'topleft'`, - // `'topright'`, `'bottomleft'` or `'bottomright'` - position: 'topright' - }, - - initialize: function (options) { - setOptions(this, options); - }, - - /* @section - * Classes extending L.Control will inherit the following methods: - * - * @method getPosition: string - * Returns the position of the control. - */ - getPosition: function () { - return this.options.position; - }, - - // @method setPosition(position: string): this - // Sets the position of the control. - setPosition: function (position) { - var map = this._map; - - if (map) { - map.removeControl(this); - } - - this.options.position = position; - - if (map) { - map.addControl(this); - } - - return this; - }, - - // @method getContainer: HTMLElement - // Returns the HTMLElement that contains the control. - getContainer: function () { - return this._container; - }, - - // @method addTo(map: Map): this - // Adds the control to the given map. - addTo: function (map) { - this.remove(); - this._map = map; - - var container = this._container = this.onAdd(map), - pos = this.getPosition(), - corner = map._controlCorners[pos]; - - addClass(container, 'leaflet-control'); - - if (pos.indexOf('bottom') !== -1) { - corner.insertBefore(container, corner.firstChild); - } else { - corner.appendChild(container); - } - - this._map.on('unload', this.remove, this); - - return this; - }, - - // @method remove: this - // Removes the control from the map it is currently active on. - remove: function () { - if (!this._map) { - return this; - } - - remove(this._container); - - if (this.onRemove) { - this.onRemove(this._map); - } - - this._map.off('unload', this.remove, this); - this._map = null; - - return this; - }, - - _refocusOnMap: function (e) { - // if map exists and event is not a keyboard event - if (this._map && e && e.screenX > 0 && e.screenY > 0) { - this._map.getContainer().focus(); - } - } +/* + * @class Control + * @aka L.Control + * @inherits Class + * + * L.Control is a base class for implementing map controls. Handles positioning. + * All other controls extend from this class. + */ + +var Control = Class.extend({ + // @section + // @aka Control options + options: { + // @option position: String = 'topright' + // The position of the control (one of the map corners). Possible values are `'topleft'`, + // `'topright'`, `'bottomleft'` or `'bottomright'` + position: 'topright' + }, + + initialize: function (options) { + setOptions(this, options); + }, + + /* @section + * Classes extending L.Control will inherit the following methods: + * + * @method getPosition: string + * Returns the position of the control. + */ + getPosition: function () { + return this.options.position; + }, + + // @method setPosition(position: string): this + // Sets the position of the control. + setPosition: function (position) { + var map = this._map; + + if (map) { + map.removeControl(this); + } + + this.options.position = position; + + if (map) { + map.addControl(this); + } + + return this; + }, + + // @method getContainer: HTMLElement + // Returns the HTMLElement that contains the control. + getContainer: function () { + return this._container; + }, + + // @method addTo(map: Map): this + // Adds the control to the given map. + addTo: function (map) { + this.remove(); + this._map = map; + + var container = this._container = this.onAdd(map), + pos = this.getPosition(), + corner = map._controlCorners[pos]; + + addClass(container, 'leaflet-control'); + + if (pos.indexOf('bottom') !== -1) { + corner.insertBefore(container, corner.firstChild); + } else { + corner.appendChild(container); + } + + this._map.on('unload', this.remove, this); + + return this; + }, + + // @method remove: this + // Removes the control from the map it is currently active on. + remove: function () { + if (!this._map) { + return this; + } + + remove(this._container); + + if (this.onRemove) { + this.onRemove(this._map); + } + + this._map.off('unload', this.remove, this); + this._map = null; + + return this; + }, + + _refocusOnMap: function (e) { + // if map exists and event is not a keyboard event + if (this._map && e && e.screenX > 0 && e.screenY > 0) { + this._map.getContainer().focus(); + } + } +}); + +var control = function (options) { + return new Control(options); +}; + +/* @section Extension methods + * @uninheritable + * + * Every control should extend from `L.Control` and (re-)implement the following methods. + * + * @method onAdd(map: Map): HTMLElement + * Should return the container DOM element for the control and add listeners on relevant map events. Called on [`control.addTo(map)`](#control-addTo). + * + * @method onRemove(map: Map) + * Optional method. Should contain all clean up code that removes the listeners previously added in [`onAdd`](#control-onadd). Called on [`control.remove()`](#control-remove). + */ + +/* @namespace Map + * @section Methods for Layers and Controls + */ +Map.include({ + // @method addControl(control: Control): this + // Adds the given control to the map + addControl: function (control) { + control.addTo(this); + return this; + }, + + // @method removeControl(control: Control): this + // Removes the given control from the map + removeControl: function (control) { + control.remove(); + return this; + }, + + _initControlPos: function () { + var corners = this._controlCorners = {}, + l = 'leaflet-', + container = this._controlContainer = + create$1('div', l + 'control-container', this._container); + + function createCorner(vSide, hSide) { + var className = l + vSide + ' ' + l + hSide; + + corners[vSide + hSide] = create$1('div', className, container); + } + + createCorner('top', 'left'); + createCorner('top', 'right'); + createCorner('bottom', 'left'); + createCorner('bottom', 'right'); + }, + + _clearControlPos: function () { + for (var i in this._controlCorners) { + remove(this._controlCorners[i]); + } + remove(this._controlContainer); + delete this._controlCorners; + delete this._controlContainer; + } }); -var control = function (options) { - return new Control(options); +/* + * @class Control.Layers + * @aka L.Control.Layers + * @inherits Control + * + * The layers control gives users the ability to switch between different base layers and switch overlays on/off (check out the [detailed example](http://leafletjs.com/examples/layers-control/)). Extends `Control`. + * + * @example + * + * ```js + * var baseLayers = { + * "Mapbox": mapbox, + * "OpenStreetMap": osm + * }; + * + * var overlays = { + * "Marker": marker, + * "Roads": roadsLayer + * }; + * + * L.control.layers(baseLayers, overlays).addTo(map); + * ``` + * + * The `baseLayers` and `overlays` parameters are object literals with layer names as keys and `Layer` objects as values: + * + * ```js + * { + * "": layer1, + * "": layer2 + * } + * ``` + * + * The layer names can contain HTML, which allows you to add additional styling to the items: + * + * ```js + * {" My Layer": myLayer} + * ``` + */ + +var Layers = Control.extend({ + // @section + // @aka Control.Layers options + options: { + // @option collapsed: Boolean = true + // If `true`, the control will be collapsed into an icon and expanded on mouse hover or touch. + collapsed: true, + position: 'topright', + + // @option autoZIndex: Boolean = true + // If `true`, the control will assign zIndexes in increasing order to all of its layers so that the order is preserved when switching them on/off. + autoZIndex: true, + + // @option hideSingleBase: Boolean = false + // If `true`, the base layers in the control will be hidden when there is only one. + hideSingleBase: false, + + // @option sortLayers: Boolean = false + // Whether to sort the layers. When `false`, layers will keep the order + // in which they were added to the control. + sortLayers: false, + + // @option sortFunction: Function = * + // A [compare function](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/sort) + // that will be used for sorting the layers, when `sortLayers` is `true`. + // The function receives both the `L.Layer` instances and their names, as in + // `sortFunction(layerA, layerB, nameA, nameB)`. + // By default, it sorts layers alphabetically by their name. + sortFunction: function (layerA, layerB, nameA, nameB) { + return nameA < nameB ? -1 : (nameB < nameA ? 1 : 0); + } + }, + + initialize: function (baseLayers, overlays, options) { + setOptions(this, options); + + this._layerControlInputs = []; + this._layers = []; + this._lastZIndex = 0; + this._handlingClick = false; + + for (var i in baseLayers) { + this._addLayer(baseLayers[i], i); + } + + for (i in overlays) { + this._addLayer(overlays[i], i, true); + } + }, + + onAdd: function (map) { + this._initLayout(); + this._update(); + + this._map = map; + map.on('zoomend', this._checkDisabledLayers, this); + + for (var i = 0; i < this._layers.length; i++) { + this._layers[i].layer.on('add remove', this._onLayerChange, this); + } + + return this._container; + }, + + addTo: function (map) { + Control.prototype.addTo.call(this, map); + // Trigger expand after Layers Control has been inserted into DOM so that is now has an actual height. + return this._expandIfNotCollapsed(); + }, + + onRemove: function () { + this._map.off('zoomend', this._checkDisabledLayers, this); + + for (var i = 0; i < this._layers.length; i++) { + this._layers[i].layer.off('add remove', this._onLayerChange, this); + } + }, + + // @method addBaseLayer(layer: Layer, name: String): this + // Adds a base layer (radio button entry) with the given name to the control. + addBaseLayer: function (layer, name) { + this._addLayer(layer, name); + return (this._map) ? this._update() : this; + }, + + // @method addOverlay(layer: Layer, name: String): this + // Adds an overlay (checkbox entry) with the given name to the control. + addOverlay: function (layer, name) { + this._addLayer(layer, name, true); + return (this._map) ? this._update() : this; + }, + + // @method removeLayer(layer: Layer): this + // Remove the given layer from the control. + removeLayer: function (layer) { + layer.off('add remove', this._onLayerChange, this); + + var obj = this._getLayer(stamp(layer)); + if (obj) { + this._layers.splice(this._layers.indexOf(obj), 1); + } + return (this._map) ? this._update() : this; + }, + + // @method expand(): this + // Expand the control container if collapsed. + expand: function () { + addClass(this._container, 'leaflet-control-layers-expanded'); + this._section.style.height = null; + var acceptableHeight = this._map.getSize().y - (this._container.offsetTop + 50); + if (acceptableHeight < this._section.clientHeight) { + addClass(this._section, 'leaflet-control-layers-scrollbar'); + this._section.style.height = acceptableHeight + 'px'; + } else { + removeClass(this._section, 'leaflet-control-layers-scrollbar'); + } + this._checkDisabledLayers(); + return this; + }, + + // @method collapse(): this + // Collapse the control container if expanded. + collapse: function () { + removeClass(this._container, 'leaflet-control-layers-expanded'); + return this; + }, + + _initLayout: function () { + var className = 'leaflet-control-layers', + container = this._container = create$1('div', className), + collapsed = this.options.collapsed; + + // makes this work on IE touch devices by stopping it from firing a mouseout event when the touch is released + container.setAttribute('aria-haspopup', true); + + disableClickPropagation(container); + disableScrollPropagation(container); + + var section = this._section = create$1('section', className + '-list'); + + if (collapsed) { + this._map.on('click', this.collapse, this); + + if (!android) { + on(container, { + mouseenter: this.expand, + mouseleave: this.collapse + }, this); + } + } + + var link = this._layersLink = create$1('a', className + '-toggle', container); + link.href = '#'; + link.title = 'Layers'; + + if (touch) { + on(link, 'click', stop); + on(link, 'click', this.expand, this); + } else { + on(link, 'focus', this.expand, this); + } + + if (!collapsed) { + this.expand(); + } + + this._baseLayersList = create$1('div', className + '-base', section); + this._separator = create$1('div', className + '-separator', section); + this._overlaysList = create$1('div', className + '-overlays', section); + + container.appendChild(section); + }, + + _getLayer: function (id) { + for (var i = 0; i < this._layers.length; i++) { + + if (this._layers[i] && stamp(this._layers[i].layer) === id) { + return this._layers[i]; + } + } + }, + + _addLayer: function (layer, name, overlay) { + if (this._map) { + layer.on('add remove', this._onLayerChange, this); + } + + this._layers.push({ + layer: layer, + name: name, + overlay: overlay + }); + + if (this.options.sortLayers) { + this._layers.sort(bind(function (a, b) { + return this.options.sortFunction(a.layer, b.layer, a.name, b.name); + }, this)); + } + + if (this.options.autoZIndex && layer.setZIndex) { + this._lastZIndex++; + layer.setZIndex(this._lastZIndex); + } + + this._expandIfNotCollapsed(); + }, + + _update: function () { + if (!this._container) { return this; } + + empty(this._baseLayersList); + empty(this._overlaysList); + + this._layerControlInputs = []; + var baseLayersPresent, overlaysPresent, i, obj, baseLayersCount = 0; + + for (i = 0; i < this._layers.length; i++) { + obj = this._layers[i]; + this._addItem(obj); + overlaysPresent = overlaysPresent || obj.overlay; + baseLayersPresent = baseLayersPresent || !obj.overlay; + baseLayersCount += !obj.overlay ? 1 : 0; + } + + // Hide base layers section if there's only one layer. + if (this.options.hideSingleBase) { + baseLayersPresent = baseLayersPresent && baseLayersCount > 1; + this._baseLayersList.style.display = baseLayersPresent ? '' : 'none'; + } + + this._separator.style.display = overlaysPresent && baseLayersPresent ? '' : 'none'; + + return this; + }, + + _onLayerChange: function (e) { + if (!this._handlingClick) { + this._update(); + } + + var obj = this._getLayer(stamp(e.target)); + + // @namespace Map + // @section Layer events + // @event baselayerchange: LayersControlEvent + // Fired when the base layer is changed through the [layers control](#control-layers). + // @event overlayadd: LayersControlEvent + // Fired when an overlay is selected through the [layers control](#control-layers). + // @event overlayremove: LayersControlEvent + // Fired when an overlay is deselected through the [layers control](#control-layers). + // @namespace Control.Layers + var type = obj.overlay ? + (e.type === 'add' ? 'overlayadd' : 'overlayremove') : + (e.type === 'add' ? 'baselayerchange' : null); + + if (type) { + this._map.fire(type, obj); + } + }, + + // IE7 bugs out if you create a radio dynamically, so you have to do it this hacky way (see http://bit.ly/PqYLBe) + _createRadioElement: function (name, checked) { + + var radioHtml = ''; + + var radioFragment = document.createElement('div'); + radioFragment.innerHTML = radioHtml; + + return radioFragment.firstChild; + }, + + _addItem: function (obj) { + var label = document.createElement('label'), + checked = this._map.hasLayer(obj.layer), + input; + + if (obj.overlay) { + input = document.createElement('input'); + input.type = 'checkbox'; + input.className = 'leaflet-control-layers-selector'; + input.defaultChecked = checked; + } else { + input = this._createRadioElement('leaflet-base-layers_' + stamp(this), checked); + } + + this._layerControlInputs.push(input); + input.layerId = stamp(obj.layer); + + on(input, 'click', this._onInputClick, this); + + var name = document.createElement('span'); + name.innerHTML = ' ' + obj.name; + + // Helps from preventing layer control flicker when checkboxes are disabled + // https://github.com/Leaflet/Leaflet/issues/2771 + var holder = document.createElement('div'); + + label.appendChild(holder); + holder.appendChild(input); + holder.appendChild(name); + + var container = obj.overlay ? this._overlaysList : this._baseLayersList; + container.appendChild(label); + + this._checkDisabledLayers(); + return label; + }, + + _onInputClick: function () { + var inputs = this._layerControlInputs, + input, layer; + var addedLayers = [], + removedLayers = []; + + this._handlingClick = true; + + for (var i = inputs.length - 1; i >= 0; i--) { + input = inputs[i]; + layer = this._getLayer(input.layerId).layer; + + if (input.checked) { + addedLayers.push(layer); + } else if (!input.checked) { + removedLayers.push(layer); + } + } + + // Bugfix issue 2318: Should remove all old layers before readding new ones + for (i = 0; i < removedLayers.length; i++) { + if (this._map.hasLayer(removedLayers[i])) { + this._map.removeLayer(removedLayers[i]); + } + } + for (i = 0; i < addedLayers.length; i++) { + if (!this._map.hasLayer(addedLayers[i])) { + this._map.addLayer(addedLayers[i]); + } + } + + this._handlingClick = false; + + this._refocusOnMap(); + }, + + _checkDisabledLayers: function () { + var inputs = this._layerControlInputs, + input, + layer, + zoom = this._map.getZoom(); + + for (var i = inputs.length - 1; i >= 0; i--) { + input = inputs[i]; + layer = this._getLayer(input.layerId).layer; + input.disabled = (layer.options.minZoom !== undefined && zoom < layer.options.minZoom) || + (layer.options.maxZoom !== undefined && zoom > layer.options.maxZoom); + + } + }, + + _expandIfNotCollapsed: function () { + if (this._map && !this.options.collapsed) { + this.expand(); + } + return this; + }, + + _expand: function () { + // Backward compatibility, remove me in 1.1. + return this.expand(); + }, + + _collapse: function () { + // Backward compatibility, remove me in 1.1. + return this.collapse(); + } + +}); + + +// @factory L.control.layers(baselayers?: Object, overlays?: Object, options?: Control.Layers options) +// Creates a layers control with the given layers. Base layers will be switched with radio buttons, while overlays will be switched with checkboxes. Note that all base layers should be passed in the base layers object, but only one should be added to the map during map instantiation. +var layers = function (baseLayers, overlays, options) { + return new Layers(baseLayers, overlays, options); }; -/* @section Extension methods - * @uninheritable - * - * Every control should extend from `L.Control` and (re-)implement the following methods. - * - * @method onAdd(map: Map): HTMLElement - * Should return the container DOM element for the control and add listeners on relevant map events. Called on [`control.addTo(map)`](#control-addTo). - * - * @method onRemove(map: Map) - * Optional method. Should contain all clean up code that removes the listeners previously added in [`onAdd`](#control-onadd). Called on [`control.remove()`](#control-remove). - */ - -/* @namespace Map - * @section Methods for Layers and Controls - */ -Map.include({ - // @method addControl(control: Control): this - // Adds the given control to the map - addControl: function (control) { - control.addTo(this); - return this; - }, - - // @method removeControl(control: Control): this - // Removes the given control from the map - removeControl: function (control) { - control.remove(); - return this; - }, - - _initControlPos: function () { - var corners = this._controlCorners = {}, - l = 'leaflet-', - container = this._controlContainer = - create$1('div', l + 'control-container', this._container); - - function createCorner(vSide, hSide) { - var className = l + vSide + ' ' + l + hSide; - - corners[vSide + hSide] = create$1('div', className, container); - } - - createCorner('top', 'left'); - createCorner('top', 'right'); - createCorner('bottom', 'left'); - createCorner('bottom', 'right'); - }, - - _clearControlPos: function () { - for (var i in this._controlCorners) { - remove(this._controlCorners[i]); - } - remove(this._controlContainer); - delete this._controlCorners; - delete this._controlContainer; - } -}); - -/* - * @class Control.Layers - * @aka L.Control.Layers - * @inherits Control - * - * The layers control gives users the ability to switch between different base layers and switch overlays on/off (check out the [detailed example](http://leafletjs.com/examples/layers-control/)). Extends `Control`. - * - * @example - * - * ```js - * var baseLayers = { - * "Mapbox": mapbox, - * "OpenStreetMap": osm - * }; - * - * var overlays = { - * "Marker": marker, - * "Roads": roadsLayer - * }; - * - * L.control.layers(baseLayers, overlays).addTo(map); - * ``` - * - * The `baseLayers` and `overlays` parameters are object literals with layer names as keys and `Layer` objects as values: - * - * ```js - * { - * "": layer1, - * "": layer2 - * } - * ``` - * - * The layer names can contain HTML, which allows you to add additional styling to the items: - * - * ```js - * {" My Layer": myLayer} - * ``` - */ - -var Layers = Control.extend({ - // @section - // @aka Control.Layers options - options: { - // @option collapsed: Boolean = true - // If `true`, the control will be collapsed into an icon and expanded on mouse hover or touch. - collapsed: true, - position: 'topright', - - // @option autoZIndex: Boolean = true - // If `true`, the control will assign zIndexes in increasing order to all of its layers so that the order is preserved when switching them on/off. - autoZIndex: true, - - // @option hideSingleBase: Boolean = false - // If `true`, the base layers in the control will be hidden when there is only one. - hideSingleBase: false, - - // @option sortLayers: Boolean = false - // Whether to sort the layers. When `false`, layers will keep the order - // in which they were added to the control. - sortLayers: false, - - // @option sortFunction: Function = * - // A [compare function](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/sort) - // that will be used for sorting the layers, when `sortLayers` is `true`. - // The function receives both the `L.Layer` instances and their names, as in - // `sortFunction(layerA, layerB, nameA, nameB)`. - // By default, it sorts layers alphabetically by their name. - sortFunction: function (layerA, layerB, nameA, nameB) { - return nameA < nameB ? -1 : (nameB < nameA ? 1 : 0); - } - }, - - initialize: function (baseLayers, overlays, options) { - setOptions(this, options); - - this._layerControlInputs = []; - this._layers = []; - this._lastZIndex = 0; - this._handlingClick = false; - - for (var i in baseLayers) { - this._addLayer(baseLayers[i], i); - } - - for (i in overlays) { - this._addLayer(overlays[i], i, true); - } - }, - - onAdd: function (map) { - this._initLayout(); - this._update(); - - this._map = map; - map.on('zoomend', this._checkDisabledLayers, this); - - for (var i = 0; i < this._layers.length; i++) { - this._layers[i].layer.on('add remove', this._onLayerChange, this); - } - - return this._container; - }, - - addTo: function (map) { - Control.prototype.addTo.call(this, map); - // Trigger expand after Layers Control has been inserted into DOM so that is now has an actual height. - return this._expandIfNotCollapsed(); - }, - - onRemove: function () { - this._map.off('zoomend', this._checkDisabledLayers, this); - - for (var i = 0; i < this._layers.length; i++) { - this._layers[i].layer.off('add remove', this._onLayerChange, this); - } - }, - - // @method addBaseLayer(layer: Layer, name: String): this - // Adds a base layer (radio button entry) with the given name to the control. - addBaseLayer: function (layer, name) { - this._addLayer(layer, name); - return (this._map) ? this._update() : this; - }, - - // @method addOverlay(layer: Layer, name: String): this - // Adds an overlay (checkbox entry) with the given name to the control. - addOverlay: function (layer, name) { - this._addLayer(layer, name, true); - return (this._map) ? this._update() : this; - }, - - // @method removeLayer(layer: Layer): this - // Remove the given layer from the control. - removeLayer: function (layer) { - layer.off('add remove', this._onLayerChange, this); - - var obj = this._getLayer(stamp(layer)); - if (obj) { - this._layers.splice(this._layers.indexOf(obj), 1); - } - return (this._map) ? this._update() : this; - }, - - // @method expand(): this - // Expand the control container if collapsed. - expand: function () { - addClass(this._container, 'leaflet-control-layers-expanded'); - this._section.style.height = null; - var acceptableHeight = this._map.getSize().y - (this._container.offsetTop + 50); - if (acceptableHeight < this._section.clientHeight) { - addClass(this._section, 'leaflet-control-layers-scrollbar'); - this._section.style.height = acceptableHeight + 'px'; - } else { - removeClass(this._section, 'leaflet-control-layers-scrollbar'); - } - this._checkDisabledLayers(); - return this; - }, - - // @method collapse(): this - // Collapse the control container if expanded. - collapse: function () { - removeClass(this._container, 'leaflet-control-layers-expanded'); - return this; - }, - - _initLayout: function () { - var className = 'leaflet-control-layers', - container = this._container = create$1('div', className), - collapsed = this.options.collapsed; - - // makes this work on IE touch devices by stopping it from firing a mouseout event when the touch is released - container.setAttribute('aria-haspopup', true); - - disableClickPropagation(container); - disableScrollPropagation(container); - - var section = this._section = create$1('section', className + '-list'); - - if (collapsed) { - this._map.on('click', this.collapse, this); - - if (!android) { - on(container, { - mouseenter: this.expand, - mouseleave: this.collapse - }, this); - } - } - - var link = this._layersLink = create$1('a', className + '-toggle', container); - link.href = '#'; - link.title = 'Layers'; - - if (touch) { - on(link, 'click', stop); - on(link, 'click', this.expand, this); - } else { - on(link, 'focus', this.expand, this); - } - - if (!collapsed) { - this.expand(); - } - - this._baseLayersList = create$1('div', className + '-base', section); - this._separator = create$1('div', className + '-separator', section); - this._overlaysList = create$1('div', className + '-overlays', section); - - container.appendChild(section); - }, - - _getLayer: function (id) { - for (var i = 0; i < this._layers.length; i++) { - - if (this._layers[i] && stamp(this._layers[i].layer) === id) { - return this._layers[i]; - } - } - }, - - _addLayer: function (layer, name, overlay) { - if (this._map) { - layer.on('add remove', this._onLayerChange, this); - } - - this._layers.push({ - layer: layer, - name: name, - overlay: overlay - }); - - if (this.options.sortLayers) { - this._layers.sort(bind(function (a, b) { - return this.options.sortFunction(a.layer, b.layer, a.name, b.name); - }, this)); - } - - if (this.options.autoZIndex && layer.setZIndex) { - this._lastZIndex++; - layer.setZIndex(this._lastZIndex); - } - - this._expandIfNotCollapsed(); - }, - - _update: function () { - if (!this._container) { return this; } - - empty(this._baseLayersList); - empty(this._overlaysList); - - this._layerControlInputs = []; - var baseLayersPresent, overlaysPresent, i, obj, baseLayersCount = 0; - - for (i = 0; i < this._layers.length; i++) { - obj = this._layers[i]; - this._addItem(obj); - overlaysPresent = overlaysPresent || obj.overlay; - baseLayersPresent = baseLayersPresent || !obj.overlay; - baseLayersCount += !obj.overlay ? 1 : 0; - } - - // Hide base layers section if there's only one layer. - if (this.options.hideSingleBase) { - baseLayersPresent = baseLayersPresent && baseLayersCount > 1; - this._baseLayersList.style.display = baseLayersPresent ? '' : 'none'; - } - - this._separator.style.display = overlaysPresent && baseLayersPresent ? '' : 'none'; - - return this; - }, - - _onLayerChange: function (e) { - if (!this._handlingClick) { - this._update(); - } - - var obj = this._getLayer(stamp(e.target)); - - // @namespace Map - // @section Layer events - // @event baselayerchange: LayersControlEvent - // Fired when the base layer is changed through the [layers control](#control-layers). - // @event overlayadd: LayersControlEvent - // Fired when an overlay is selected through the [layers control](#control-layers). - // @event overlayremove: LayersControlEvent - // Fired when an overlay is deselected through the [layers control](#control-layers). - // @namespace Control.Layers - var type = obj.overlay ? - (e.type === 'add' ? 'overlayadd' : 'overlayremove') : - (e.type === 'add' ? 'baselayerchange' : null); - - if (type) { - this._map.fire(type, obj); - } - }, - - // IE7 bugs out if you create a radio dynamically, so you have to do it this hacky way (see http://bit.ly/PqYLBe) - _createRadioElement: function (name, checked) { - - var radioHtml = ''; - - var radioFragment = document.createElement('div'); - radioFragment.innerHTML = radioHtml; - - return radioFragment.firstChild; - }, - - _addItem: function (obj) { - var label = document.createElement('label'), - checked = this._map.hasLayer(obj.layer), - input; - - if (obj.overlay) { - input = document.createElement('input'); - input.type = 'checkbox'; - input.className = 'leaflet-control-layers-selector'; - input.defaultChecked = checked; - } else { - input = this._createRadioElement('leaflet-base-layers_' + stamp(this), checked); - } - - this._layerControlInputs.push(input); - input.layerId = stamp(obj.layer); - - on(input, 'click', this._onInputClick, this); - - var name = document.createElement('span'); - name.innerHTML = ' ' + obj.name; - - // Helps from preventing layer control flicker when checkboxes are disabled - // https://github.com/Leaflet/Leaflet/issues/2771 - var holder = document.createElement('div'); - - label.appendChild(holder); - holder.appendChild(input); - holder.appendChild(name); - - var container = obj.overlay ? this._overlaysList : this._baseLayersList; - container.appendChild(label); - - this._checkDisabledLayers(); - return label; - }, - - _onInputClick: function () { - var inputs = this._layerControlInputs, - input, layer; - var addedLayers = [], - removedLayers = []; - - this._handlingClick = true; - - for (var i = inputs.length - 1; i >= 0; i--) { - input = inputs[i]; - layer = this._getLayer(input.layerId).layer; - - if (input.checked) { - addedLayers.push(layer); - } else if (!input.checked) { - removedLayers.push(layer); - } - } - - // Bugfix issue 2318: Should remove all old layers before readding new ones - for (i = 0; i < removedLayers.length; i++) { - if (this._map.hasLayer(removedLayers[i])) { - this._map.removeLayer(removedLayers[i]); - } - } - for (i = 0; i < addedLayers.length; i++) { - if (!this._map.hasLayer(addedLayers[i])) { - this._map.addLayer(addedLayers[i]); - } - } - - this._handlingClick = false; - - this._refocusOnMap(); - }, - - _checkDisabledLayers: function () { - var inputs = this._layerControlInputs, - input, - layer, - zoom = this._map.getZoom(); - - for (var i = inputs.length - 1; i >= 0; i--) { - input = inputs[i]; - layer = this._getLayer(input.layerId).layer; - input.disabled = (layer.options.minZoom !== undefined && zoom < layer.options.minZoom) || - (layer.options.maxZoom !== undefined && zoom > layer.options.maxZoom); - - } - }, - - _expandIfNotCollapsed: function () { - if (this._map && !this.options.collapsed) { - this.expand(); - } - return this; - }, - - _expand: function () { - // Backward compatibility, remove me in 1.1. - return this.expand(); - }, - - _collapse: function () { - // Backward compatibility, remove me in 1.1. - return this.collapse(); - } - -}); - - -// @factory L.control.layers(baselayers?: Object, overlays?: Object, options?: Control.Layers options) -// Creates a layers control with the given layers. Base layers will be switched with radio buttons, while overlays will be switched with checkboxes. Note that all base layers should be passed in the base layers object, but only one should be added to the map during map instantiation. -var layers = function (baseLayers, overlays, options) { - return new Layers(baseLayers, overlays, options); -}; - -/* - * @class Control.Zoom - * @aka L.Control.Zoom - * @inherits Control - * - * A basic zoom control with two buttons (zoom in and zoom out). It is put on the map by default unless you set its [`zoomControl` option](#map-zoomcontrol) to `false`. Extends `Control`. - */ - -var Zoom = Control.extend({ - // @section - // @aka Control.Zoom options - options: { - position: 'topleft', - - // @option zoomInText: String = '+' - // The text set on the 'zoom in' button. - zoomInText: '+', - - // @option zoomInTitle: String = 'Zoom in' - // The title set on the 'zoom in' button. - zoomInTitle: 'Zoom in', - - // @option zoomOutText: String = '−' - // The text set on the 'zoom out' button. - zoomOutText: '−', - - // @option zoomOutTitle: String = 'Zoom out' - // The title set on the 'zoom out' button. - zoomOutTitle: 'Zoom out' - }, - - onAdd: function (map) { - var zoomName = 'leaflet-control-zoom', - container = create$1('div', zoomName + ' leaflet-bar'), - options = this.options; - - this._zoomInButton = this._createButton(options.zoomInText, options.zoomInTitle, - zoomName + '-in', container, this._zoomIn); - this._zoomOutButton = this._createButton(options.zoomOutText, options.zoomOutTitle, - zoomName + '-out', container, this._zoomOut); - - this._updateDisabled(); - map.on('zoomend zoomlevelschange', this._updateDisabled, this); - - return container; - }, - - onRemove: function (map) { - map.off('zoomend zoomlevelschange', this._updateDisabled, this); - }, - - disable: function () { - this._disabled = true; - this._updateDisabled(); - return this; - }, - - enable: function () { - this._disabled = false; - this._updateDisabled(); - return this; - }, - - _zoomIn: function (e) { - if (!this._disabled && this._map._zoom < this._map.getMaxZoom()) { - this._map.zoomIn(this._map.options.zoomDelta * (e.shiftKey ? 3 : 1)); - } - }, - - _zoomOut: function (e) { - if (!this._disabled && this._map._zoom > this._map.getMinZoom()) { - this._map.zoomOut(this._map.options.zoomDelta * (e.shiftKey ? 3 : 1)); - } - }, - - _createButton: function (html, title, className, container, fn) { - var link = create$1('a', className, container); - link.innerHTML = html; - link.href = '#'; - link.title = title; - - /* - * Will force screen readers like VoiceOver to read this as "Zoom in - button" - */ - link.setAttribute('role', 'button'); - link.setAttribute('aria-label', title); - - disableClickPropagation(link); - on(link, 'click', stop); - on(link, 'click', fn, this); - on(link, 'click', this._refocusOnMap, this); - - return link; - }, - - _updateDisabled: function () { - var map = this._map, - className = 'leaflet-disabled'; - - removeClass(this._zoomInButton, className); - removeClass(this._zoomOutButton, className); - - if (this._disabled || map._zoom === map.getMinZoom()) { - addClass(this._zoomOutButton, className); - } - if (this._disabled || map._zoom === map.getMaxZoom()) { - addClass(this._zoomInButton, className); - } - } -}); - -// @namespace Map -// @section Control options -// @option zoomControl: Boolean = true -// Whether a [zoom control](#control-zoom) is added to the map by default. -Map.mergeOptions({ - zoomControl: true -}); - -Map.addInitHook(function () { - if (this.options.zoomControl) { - // @section Controls - // @property zoomControl: Control.Zoom - // The default zoom control (only available if the - // [`zoomControl` option](#map-zoomcontrol) was `true` when creating the map). - this.zoomControl = new Zoom(); - this.addControl(this.zoomControl); - } -}); - -// @namespace Control.Zoom -// @factory L.control.zoom(options: Control.Zoom options) -// Creates a zoom control -var zoom = function (options) { - return new Zoom(options); +/* + * @class Control.Zoom + * @aka L.Control.Zoom + * @inherits Control + * + * A basic zoom control with two buttons (zoom in and zoom out). It is put on the map by default unless you set its [`zoomControl` option](#map-zoomcontrol) to `false`. Extends `Control`. + */ + +var Zoom = Control.extend({ + // @section + // @aka Control.Zoom options + options: { + position: 'topleft', + + // @option zoomInText: String = '+' + // The text set on the 'zoom in' button. + zoomInText: '+', + + // @option zoomInTitle: String = 'Zoom in' + // The title set on the 'zoom in' button. + zoomInTitle: 'Zoom in', + + // @option zoomOutText: String = '−' + // The text set on the 'zoom out' button. + zoomOutText: '−', + + // @option zoomOutTitle: String = 'Zoom out' + // The title set on the 'zoom out' button. + zoomOutTitle: 'Zoom out' + }, + + onAdd: function (map) { + var zoomName = 'leaflet-control-zoom', + container = create$1('div', zoomName + ' leaflet-bar'), + options = this.options; + + this._zoomInButton = this._createButton(options.zoomInText, options.zoomInTitle, + zoomName + '-in', container, this._zoomIn); + this._zoomOutButton = this._createButton(options.zoomOutText, options.zoomOutTitle, + zoomName + '-out', container, this._zoomOut); + + this._updateDisabled(); + map.on('zoomend zoomlevelschange', this._updateDisabled, this); + + return container; + }, + + onRemove: function (map) { + map.off('zoomend zoomlevelschange', this._updateDisabled, this); + }, + + disable: function () { + this._disabled = true; + this._updateDisabled(); + return this; + }, + + enable: function () { + this._disabled = false; + this._updateDisabled(); + return this; + }, + + _zoomIn: function (e) { + if (!this._disabled && this._map._zoom < this._map.getMaxZoom()) { + this._map.zoomIn(this._map.options.zoomDelta * (e.shiftKey ? 3 : 1)); + } + }, + + _zoomOut: function (e) { + if (!this._disabled && this._map._zoom > this._map.getMinZoom()) { + this._map.zoomOut(this._map.options.zoomDelta * (e.shiftKey ? 3 : 1)); + } + }, + + _createButton: function (html, title, className, container, fn) { + var link = create$1('a', className, container); + link.innerHTML = html; + link.href = '#'; + link.title = title; + + /* + * Will force screen readers like VoiceOver to read this as "Zoom in - button" + */ + link.setAttribute('role', 'button'); + link.setAttribute('aria-label', title); + + disableClickPropagation(link); + on(link, 'click', stop); + on(link, 'click', fn, this); + on(link, 'click', this._refocusOnMap, this); + + return link; + }, + + _updateDisabled: function () { + var map = this._map, + className = 'leaflet-disabled'; + + removeClass(this._zoomInButton, className); + removeClass(this._zoomOutButton, className); + + if (this._disabled || map._zoom === map.getMinZoom()) { + addClass(this._zoomOutButton, className); + } + if (this._disabled || map._zoom === map.getMaxZoom()) { + addClass(this._zoomInButton, className); + } + } +}); + +// @namespace Map +// @section Control options +// @option zoomControl: Boolean = true +// Whether a [zoom control](#control-zoom) is added to the map by default. +Map.mergeOptions({ + zoomControl: true +}); + +Map.addInitHook(function () { + if (this.options.zoomControl) { + // @section Controls + // @property zoomControl: Control.Zoom + // The default zoom control (only available if the + // [`zoomControl` option](#map-zoomcontrol) was `true` when creating the map). + this.zoomControl = new Zoom(); + this.addControl(this.zoomControl); + } +}); + +// @namespace Control.Zoom +// @factory L.control.zoom(options: Control.Zoom options) +// Creates a zoom control +var zoom = function (options) { + return new Zoom(options); }; /* @@ -5545,127 +5545,127 @@ var scale = function (options) { return new Scale(options); }; -/* - * @class Control.Attribution - * @aka L.Control.Attribution - * @inherits Control - * - * The attribution control allows you to display attribution data in a small text box on a map. It is put on the map by default unless you set its [`attributionControl` option](#map-attributioncontrol) to `false`, and it fetches attribution texts from layers with the [`getAttribution` method](#layer-getattribution) automatically. Extends Control. - */ - -var Attribution = Control.extend({ - // @section - // @aka Control.Attribution options - options: { - position: 'bottomright', - - // @option prefix: String = 'Leaflet' - // The HTML text shown before the attributions. Pass `false` to disable. - prefix: 'Leaflet' - }, - - initialize: function (options) { - setOptions(this, options); - - this._attributions = {}; - }, - - onAdd: function (map) { - map.attributionControl = this; - this._container = create$1('div', 'leaflet-control-attribution'); - disableClickPropagation(this._container); - - // TODO ugly, refactor - for (var i in map._layers) { - if (map._layers[i].getAttribution) { - this.addAttribution(map._layers[i].getAttribution()); - } - } - - this._update(); - - return this._container; - }, - - // @method setPrefix(prefix: String): this - // Sets the text before the attributions. - setPrefix: function (prefix) { - this.options.prefix = prefix; - this._update(); - return this; - }, - - // @method addAttribution(text: String): this - // Adds an attribution text (e.g. `'Vector data © Mapbox'`). - addAttribution: function (text) { - if (!text) { return this; } - - if (!this._attributions[text]) { - this._attributions[text] = 0; - } - this._attributions[text]++; - - this._update(); - - return this; - }, - - // @method removeAttribution(text: String): this - // Removes an attribution text. - removeAttribution: function (text) { - if (!text) { return this; } - - if (this._attributions[text]) { - this._attributions[text]--; - this._update(); - } - - return this; - }, - - _update: function () { - if (!this._map) { return; } - - var attribs = []; - - for (var i in this._attributions) { - if (this._attributions[i]) { - attribs.push(i); - } - } - - var prefixAndAttribs = []; - - if (this.options.prefix) { - prefixAndAttribs.push(this.options.prefix); - } - if (attribs.length) { - prefixAndAttribs.push(attribs.join(', ')); - } - - this._container.innerHTML = prefixAndAttribs.join(' | '); - } -}); - -// @namespace Map -// @section Control options -// @option attributionControl: Boolean = true -// Whether a [attribution control](#control-attribution) is added to the map by default. -Map.mergeOptions({ - attributionControl: true -}); - -Map.addInitHook(function () { - if (this.options.attributionControl) { - new Attribution().addTo(this); - } -}); - -// @namespace Control.Attribution -// @factory L.control.attribution(options: Control.Attribution options) -// Creates an attribution control. -var attribution = function (options) { - return new Attribution(options); +/* + * @class Control.Attribution + * @aka L.Control.Attribution + * @inherits Control + * + * The attribution control allows you to display attribution data in a small text box on a map. It is put on the map by default unless you set its [`attributionControl` option](#map-attributioncontrol) to `false`, and it fetches attribution texts from layers with the [`getAttribution` method](#layer-getattribution) automatically. Extends Control. + */ + +var Attribution = Control.extend({ + // @section + // @aka Control.Attribution options + options: { + position: 'bottomright', + + // @option prefix: String = 'Leaflet' + // The HTML text shown before the attributions. Pass `false` to disable. + prefix: 'Leaflet' + }, + + initialize: function (options) { + setOptions(this, options); + + this._attributions = {}; + }, + + onAdd: function (map) { + map.attributionControl = this; + this._container = create$1('div', 'leaflet-control-attribution'); + disableClickPropagation(this._container); + + // TODO ugly, refactor + for (var i in map._layers) { + if (map._layers[i].getAttribution) { + this.addAttribution(map._layers[i].getAttribution()); + } + } + + this._update(); + + return this._container; + }, + + // @method setPrefix(prefix: String): this + // Sets the text before the attributions. + setPrefix: function (prefix) { + this.options.prefix = prefix; + this._update(); + return this; + }, + + // @method addAttribution(text: String): this + // Adds an attribution text (e.g. `'Vector data © Mapbox'`). + addAttribution: function (text) { + if (!text) { return this; } + + if (!this._attributions[text]) { + this._attributions[text] = 0; + } + this._attributions[text]++; + + this._update(); + + return this; + }, + + // @method removeAttribution(text: String): this + // Removes an attribution text. + removeAttribution: function (text) { + if (!text) { return this; } + + if (this._attributions[text]) { + this._attributions[text]--; + this._update(); + } + + return this; + }, + + _update: function () { + if (!this._map) { return; } + + var attribs = []; + + for (var i in this._attributions) { + if (this._attributions[i]) { + attribs.push(i); + } + } + + var prefixAndAttribs = []; + + if (this.options.prefix) { + prefixAndAttribs.push(this.options.prefix); + } + if (attribs.length) { + prefixAndAttribs.push(attribs.join(', ')); + } + + this._container.innerHTML = prefixAndAttribs.join(' | '); + } +}); + +// @namespace Map +// @section Control options +// @option attributionControl: Boolean = true +// Whether a [attribution control](#control-attribution) is added to the map by default. +Map.mergeOptions({ + attributionControl: true +}); + +Map.addInitHook(function () { + if (this.options.attributionControl) { + new Attribution().addTo(this); + } +}); + +// @namespace Control.Attribution +// @factory L.control.attribution(options: Control.Attribution options) +// Creates an attribution control. +var attribution = function (options) { + return new Attribution(options); }; Control.Layers = Layers; @@ -5736,473 +5736,473 @@ Handler.addTo = function (map, name) { var Mixin = {Events: Events}; -/* - * @class Draggable - * @aka L.Draggable - * @inherits Evented - * - * A class for making DOM elements draggable (including touch support). - * Used internally for map and marker dragging. Only works for elements - * that were positioned with [`L.DomUtil.setPosition`](#domutil-setposition). - * - * @example - * ```js - * var draggable = new L.Draggable(elementToDrag); - * draggable.enable(); - * ``` - */ - -var START = touch ? 'touchstart mousedown' : 'mousedown'; -var END = { - mousedown: 'mouseup', - touchstart: 'touchend', - pointerdown: 'touchend', - MSPointerDown: 'touchend' -}; -var MOVE = { - mousedown: 'mousemove', - touchstart: 'touchmove', - pointerdown: 'touchmove', - MSPointerDown: 'touchmove' -}; - - -var Draggable = Evented.extend({ - - options: { - // @section - // @aka Draggable options - // @option clickTolerance: Number = 3 - // The max number of pixels a user can shift the mouse pointer during a click - // for it to be considered a valid click (as opposed to a mouse drag). - clickTolerance: 3 - }, - - // @constructor L.Draggable(el: HTMLElement, dragHandle?: HTMLElement, preventOutline?: Boolean, options?: Draggable options) - // Creates a `Draggable` object for moving `el` when you start dragging the `dragHandle` element (equals `el` itself by default). - initialize: function (element, dragStartTarget, preventOutline$$1, options) { - setOptions(this, options); - - this._element = element; - this._dragStartTarget = dragStartTarget || element; - this._preventOutline = preventOutline$$1; - }, - - // @method enable() - // Enables the dragging ability - enable: function () { - if (this._enabled) { return; } - - on(this._dragStartTarget, START, this._onDown, this); - - this._enabled = true; - }, - - // @method disable() - // Disables the dragging ability - disable: function () { - if (!this._enabled) { return; } - - // If we're currently dragging this draggable, - // disabling it counts as first ending the drag. - if (Draggable._dragging === this) { - this.finishDrag(); - } - - off(this._dragStartTarget, START, this._onDown, this); - - this._enabled = false; - this._moved = false; - }, - - _onDown: function (e) { - // Ignore simulated events, since we handle both touch and - // mouse explicitly; otherwise we risk getting duplicates of - // touch events, see #4315. - // Also ignore the event if disabled; this happens in IE11 - // under some circumstances, see #3666. - if (e._simulated || !this._enabled) { return; } - - this._moved = false; - - if (hasClass(this._element, 'leaflet-zoom-anim')) { return; } - - if (Draggable._dragging || e.shiftKey || ((e.which !== 1) && (e.button !== 1) && !e.touches)) { return; } - Draggable._dragging = this; // Prevent dragging multiple objects at once. - - if (this._preventOutline) { - preventOutline(this._element); - } - - disableImageDrag(); - disableTextSelection(); - - if (this._moving) { return; } - - // @event down: Event - // Fired when a drag is about to start. - this.fire('down'); - - var first = e.touches ? e.touches[0] : e, - sizedParent = getSizedParentNode(this._element); - - this._startPoint = new Point(first.clientX, first.clientY); - - // Cache the scale, so that we can continuously compensate for it during drag (_onMove). - this._parentScale = getScale(sizedParent); - - on(document, MOVE[e.type], this._onMove, this); - on(document, END[e.type], this._onUp, this); - }, - - _onMove: function (e) { - // Ignore simulated events, since we handle both touch and - // mouse explicitly; otherwise we risk getting duplicates of - // touch events, see #4315. - // Also ignore the event if disabled; this happens in IE11 - // under some circumstances, see #3666. - if (e._simulated || !this._enabled) { return; } - - if (e.touches && e.touches.length > 1) { - this._moved = true; - return; - } - - var first = (e.touches && e.touches.length === 1 ? e.touches[0] : e), - offset = new Point(first.clientX, first.clientY)._subtract(this._startPoint); - - if (!offset.x && !offset.y) { return; } - if (Math.abs(offset.x) + Math.abs(offset.y) < this.options.clickTolerance) { return; } - - // We assume that the parent container's position, border and scale do not change for the duration of the drag. - // Therefore there is no need to account for the position and border (they are eliminated by the subtraction) - // and we can use the cached value for the scale. - offset.x /= this._parentScale.x; - offset.y /= this._parentScale.y; - - preventDefault(e); - - if (!this._moved) { - // @event dragstart: Event - // Fired when a drag starts - this.fire('dragstart'); - - this._moved = true; - this._startPos = getPosition(this._element).subtract(offset); - - addClass(document.body, 'leaflet-dragging'); - - this._lastTarget = e.target || e.srcElement; - // IE and Edge do not give the element, so fetch it - // if necessary - if (window.SVGElementInstance && this._lastTarget instanceof window.SVGElementInstance) { - this._lastTarget = this._lastTarget.correspondingUseElement; - } - addClass(this._lastTarget, 'leaflet-drag-target'); - } - - this._newPos = this._startPos.add(offset); - this._moving = true; - - cancelAnimFrame(this._animRequest); - this._lastEvent = e; - this._animRequest = requestAnimFrame(this._updatePosition, this, true); - }, - - _updatePosition: function () { - var e = {originalEvent: this._lastEvent}; - - // @event predrag: Event - // Fired continuously during dragging *before* each corresponding - // update of the element's position. - this.fire('predrag', e); - setPosition(this._element, this._newPos); - - // @event drag: Event - // Fired continuously during dragging. - this.fire('drag', e); - }, - - _onUp: function (e) { - // Ignore simulated events, since we handle both touch and - // mouse explicitly; otherwise we risk getting duplicates of - // touch events, see #4315. - // Also ignore the event if disabled; this happens in IE11 - // under some circumstances, see #3666. - if (e._simulated || !this._enabled) { return; } - this.finishDrag(); - }, - - finishDrag: function () { - removeClass(document.body, 'leaflet-dragging'); - - if (this._lastTarget) { - removeClass(this._lastTarget, 'leaflet-drag-target'); - this._lastTarget = null; - } - - for (var i in MOVE) { - off(document, MOVE[i], this._onMove, this); - off(document, END[i], this._onUp, this); - } - - enableImageDrag(); - enableTextSelection(); - - if (this._moved && this._moving) { - // ensure drag is not fired after dragend - cancelAnimFrame(this._animRequest); - - // @event dragend: DragEndEvent - // Fired when the drag ends. - this.fire('dragend', { - distance: this._newPos.distanceTo(this._startPos) - }); - } - - this._moving = false; - Draggable._dragging = false; - } - +/* + * @class Draggable + * @aka L.Draggable + * @inherits Evented + * + * A class for making DOM elements draggable (including touch support). + * Used internally for map and marker dragging. Only works for elements + * that were positioned with [`L.DomUtil.setPosition`](#domutil-setposition). + * + * @example + * ```js + * var draggable = new L.Draggable(elementToDrag); + * draggable.enable(); + * ``` + */ + +var START = touch ? 'touchstart mousedown' : 'mousedown'; +var END = { + mousedown: 'mouseup', + touchstart: 'touchend', + pointerdown: 'touchend', + MSPointerDown: 'touchend' +}; +var MOVE = { + mousedown: 'mousemove', + touchstart: 'touchmove', + pointerdown: 'touchmove', + MSPointerDown: 'touchmove' +}; + + +var Draggable = Evented.extend({ + + options: { + // @section + // @aka Draggable options + // @option clickTolerance: Number = 3 + // The max number of pixels a user can shift the mouse pointer during a click + // for it to be considered a valid click (as opposed to a mouse drag). + clickTolerance: 3 + }, + + // @constructor L.Draggable(el: HTMLElement, dragHandle?: HTMLElement, preventOutline?: Boolean, options?: Draggable options) + // Creates a `Draggable` object for moving `el` when you start dragging the `dragHandle` element (equals `el` itself by default). + initialize: function (element, dragStartTarget, preventOutline$$1, options) { + setOptions(this, options); + + this._element = element; + this._dragStartTarget = dragStartTarget || element; + this._preventOutline = preventOutline$$1; + }, + + // @method enable() + // Enables the dragging ability + enable: function () { + if (this._enabled) { return; } + + on(this._dragStartTarget, START, this._onDown, this); + + this._enabled = true; + }, + + // @method disable() + // Disables the dragging ability + disable: function () { + if (!this._enabled) { return; } + + // If we're currently dragging this draggable, + // disabling it counts as first ending the drag. + if (Draggable._dragging === this) { + this.finishDrag(); + } + + off(this._dragStartTarget, START, this._onDown, this); + + this._enabled = false; + this._moved = false; + }, + + _onDown: function (e) { + // Ignore simulated events, since we handle both touch and + // mouse explicitly; otherwise we risk getting duplicates of + // touch events, see #4315. + // Also ignore the event if disabled; this happens in IE11 + // under some circumstances, see #3666. + if (e._simulated || !this._enabled) { return; } + + this._moved = false; + + if (hasClass(this._element, 'leaflet-zoom-anim')) { return; } + + if (Draggable._dragging || e.shiftKey || ((e.which !== 1) && (e.button !== 1) && !e.touches)) { return; } + Draggable._dragging = this; // Prevent dragging multiple objects at once. + + if (this._preventOutline) { + preventOutline(this._element); + } + + disableImageDrag(); + disableTextSelection(); + + if (this._moving) { return; } + + // @event down: Event + // Fired when a drag is about to start. + this.fire('down'); + + var first = e.touches ? e.touches[0] : e, + sizedParent = getSizedParentNode(this._element); + + this._startPoint = new Point(first.clientX, first.clientY); + + // Cache the scale, so that we can continuously compensate for it during drag (_onMove). + this._parentScale = getScale(sizedParent); + + on(document, MOVE[e.type], this._onMove, this); + on(document, END[e.type], this._onUp, this); + }, + + _onMove: function (e) { + // Ignore simulated events, since we handle both touch and + // mouse explicitly; otherwise we risk getting duplicates of + // touch events, see #4315. + // Also ignore the event if disabled; this happens in IE11 + // under some circumstances, see #3666. + if (e._simulated || !this._enabled) { return; } + + if (e.touches && e.touches.length > 1) { + this._moved = true; + return; + } + + var first = (e.touches && e.touches.length === 1 ? e.touches[0] : e), + offset = new Point(first.clientX, first.clientY)._subtract(this._startPoint); + + if (!offset.x && !offset.y) { return; } + if (Math.abs(offset.x) + Math.abs(offset.y) < this.options.clickTolerance) { return; } + + // We assume that the parent container's position, border and scale do not change for the duration of the drag. + // Therefore there is no need to account for the position and border (they are eliminated by the subtraction) + // and we can use the cached value for the scale. + offset.x /= this._parentScale.x; + offset.y /= this._parentScale.y; + + preventDefault(e); + + if (!this._moved) { + // @event dragstart: Event + // Fired when a drag starts + this.fire('dragstart'); + + this._moved = true; + this._startPos = getPosition(this._element).subtract(offset); + + addClass(document.body, 'leaflet-dragging'); + + this._lastTarget = e.target || e.srcElement; + // IE and Edge do not give the element, so fetch it + // if necessary + if (window.SVGElementInstance && this._lastTarget instanceof window.SVGElementInstance) { + this._lastTarget = this._lastTarget.correspondingUseElement; + } + addClass(this._lastTarget, 'leaflet-drag-target'); + } + + this._newPos = this._startPos.add(offset); + this._moving = true; + + cancelAnimFrame(this._animRequest); + this._lastEvent = e; + this._animRequest = requestAnimFrame(this._updatePosition, this, true); + }, + + _updatePosition: function () { + var e = {originalEvent: this._lastEvent}; + + // @event predrag: Event + // Fired continuously during dragging *before* each corresponding + // update of the element's position. + this.fire('predrag', e); + setPosition(this._element, this._newPos); + + // @event drag: Event + // Fired continuously during dragging. + this.fire('drag', e); + }, + + _onUp: function (e) { + // Ignore simulated events, since we handle both touch and + // mouse explicitly; otherwise we risk getting duplicates of + // touch events, see #4315. + // Also ignore the event if disabled; this happens in IE11 + // under some circumstances, see #3666. + if (e._simulated || !this._enabled) { return; } + this.finishDrag(); + }, + + finishDrag: function () { + removeClass(document.body, 'leaflet-dragging'); + + if (this._lastTarget) { + removeClass(this._lastTarget, 'leaflet-drag-target'); + this._lastTarget = null; + } + + for (var i in MOVE) { + off(document, MOVE[i], this._onMove, this); + off(document, END[i], this._onUp, this); + } + + enableImageDrag(); + enableTextSelection(); + + if (this._moved && this._moving) { + // ensure drag is not fired after dragend + cancelAnimFrame(this._animRequest); + + // @event dragend: DragEndEvent + // Fired when the drag ends. + this.fire('dragend', { + distance: this._newPos.distanceTo(this._startPos) + }); + } + + this._moving = false; + Draggable._dragging = false; + } + }); -/* - * @namespace LineUtil - * - * Various utility functions for polyline points processing, used by Leaflet internally to make polylines lightning-fast. - */ - -// Simplify polyline with vertex reduction and Douglas-Peucker simplification. -// Improves rendering performance dramatically by lessening the number of points to draw. - -// @function simplify(points: Point[], tolerance: Number): Point[] -// Dramatically reduces the number of points in a polyline while retaining -// its shape and returns a new array of simplified points, using the -// [Douglas-Peucker algorithm](http://en.wikipedia.org/wiki/Douglas-Peucker_algorithm). -// Used for a huge performance boost when processing/displaying Leaflet polylines for -// each zoom level and also reducing visual noise. tolerance affects the amount of -// simplification (lesser value means higher quality but slower and with more points). -// Also released as a separated micro-library [Simplify.js](http://mourner.github.com/simplify-js/). -function simplify(points, tolerance) { - if (!tolerance || !points.length) { - return points.slice(); - } - - var sqTolerance = tolerance * tolerance; - - // stage 1: vertex reduction - points = _reducePoints(points, sqTolerance); - - // stage 2: Douglas-Peucker simplification - points = _simplifyDP(points, sqTolerance); - - return points; -} - -// @function pointToSegmentDistance(p: Point, p1: Point, p2: Point): Number -// Returns the distance between point `p` and segment `p1` to `p2`. -function pointToSegmentDistance(p, p1, p2) { - return Math.sqrt(_sqClosestPointOnSegment(p, p1, p2, true)); -} - -// @function closestPointOnSegment(p: Point, p1: Point, p2: Point): Number -// Returns the closest point from a point `p` on a segment `p1` to `p2`. -function closestPointOnSegment(p, p1, p2) { - return _sqClosestPointOnSegment(p, p1, p2); -} - -// Douglas-Peucker simplification, see http://en.wikipedia.org/wiki/Douglas-Peucker_algorithm -function _simplifyDP(points, sqTolerance) { - - var len = points.length, - ArrayConstructor = typeof Uint8Array !== undefined + '' ? Uint8Array : Array, - markers = new ArrayConstructor(len); - - markers[0] = markers[len - 1] = 1; - - _simplifyDPStep(points, markers, sqTolerance, 0, len - 1); - - var i, - newPoints = []; - - for (i = 0; i < len; i++) { - if (markers[i]) { - newPoints.push(points[i]); - } - } - - return newPoints; -} - -function _simplifyDPStep(points, markers, sqTolerance, first, last) { - - var maxSqDist = 0, - index, i, sqDist; - - for (i = first + 1; i <= last - 1; i++) { - sqDist = _sqClosestPointOnSegment(points[i], points[first], points[last], true); - - if (sqDist > maxSqDist) { - index = i; - maxSqDist = sqDist; - } - } - - if (maxSqDist > sqTolerance) { - markers[index] = 1; - - _simplifyDPStep(points, markers, sqTolerance, first, index); - _simplifyDPStep(points, markers, sqTolerance, index, last); - } -} - -// reduce points that are too close to each other to a single point -function _reducePoints(points, sqTolerance) { - var reducedPoints = [points[0]]; - - for (var i = 1, prev = 0, len = points.length; i < len; i++) { - if (_sqDist(points[i], points[prev]) > sqTolerance) { - reducedPoints.push(points[i]); - prev = i; - } - } - if (prev < len - 1) { - reducedPoints.push(points[len - 1]); - } - return reducedPoints; -} - -var _lastCode; - -// @function clipSegment(a: Point, b: Point, bounds: Bounds, useLastCode?: Boolean, round?: Boolean): Point[]|Boolean -// Clips the segment a to b by rectangular bounds with the -// [Cohen-Sutherland algorithm](https://en.wikipedia.org/wiki/Cohen%E2%80%93Sutherland_algorithm) -// (modifying the segment points directly!). Used by Leaflet to only show polyline -// points that are on the screen or near, increasing performance. -function clipSegment(a, b, bounds, useLastCode, round) { - var codeA = useLastCode ? _lastCode : _getBitCode(a, bounds), - codeB = _getBitCode(b, bounds), - - codeOut, p, newCode; - - // save 2nd code to avoid calculating it on the next segment - _lastCode = codeB; - - while (true) { - // if a,b is inside the clip window (trivial accept) - if (!(codeA | codeB)) { - return [a, b]; - } - - // if a,b is outside the clip window (trivial reject) - if (codeA & codeB) { - return false; - } - - // other cases - codeOut = codeA || codeB; - p = _getEdgeIntersection(a, b, codeOut, bounds, round); - newCode = _getBitCode(p, bounds); - - if (codeOut === codeA) { - a = p; - codeA = newCode; - } else { - b = p; - codeB = newCode; - } - } -} - -function _getEdgeIntersection(a, b, code, bounds, round) { - var dx = b.x - a.x, - dy = b.y - a.y, - min = bounds.min, - max = bounds.max, - x, y; - - if (code & 8) { // top - x = a.x + dx * (max.y - a.y) / dy; - y = max.y; - - } else if (code & 4) { // bottom - x = a.x + dx * (min.y - a.y) / dy; - y = min.y; - - } else if (code & 2) { // right - x = max.x; - y = a.y + dy * (max.x - a.x) / dx; - - } else if (code & 1) { // left - x = min.x; - y = a.y + dy * (min.x - a.x) / dx; - } - - return new Point(x, y, round); -} - -function _getBitCode(p, bounds) { - var code = 0; - - if (p.x < bounds.min.x) { // left - code |= 1; - } else if (p.x > bounds.max.x) { // right - code |= 2; - } - - if (p.y < bounds.min.y) { // bottom - code |= 4; - } else if (p.y > bounds.max.y) { // top - code |= 8; - } - - return code; -} - -// square distance (to avoid unnecessary Math.sqrt calls) -function _sqDist(p1, p2) { - var dx = p2.x - p1.x, - dy = p2.y - p1.y; - return dx * dx + dy * dy; -} - -// return closest point on segment or distance to that point -function _sqClosestPointOnSegment(p, p1, p2, sqDist) { - var x = p1.x, - y = p1.y, - dx = p2.x - x, - dy = p2.y - y, - dot = dx * dx + dy * dy, - t; - - if (dot > 0) { - t = ((p.x - x) * dx + (p.y - y) * dy) / dot; - - if (t > 1) { - x = p2.x; - y = p2.y; - } else if (t > 0) { - x += dx * t; - y += dy * t; - } - } - - dx = p.x - x; - dy = p.y - y; - - return sqDist ? dx * dx + dy * dy : new Point(x, y); -} - - -// @function isFlat(latlngs: LatLng[]): Boolean -// Returns true if `latlngs` is a flat array, false is nested. -function isFlat(latlngs) { - return !isArray(latlngs[0]) || (typeof latlngs[0][0] !== 'object' && typeof latlngs[0][0] !== 'undefined'); -} - -function _flat(latlngs) { - console.warn('Deprecated use of _flat, please use L.LineUtil.isFlat instead.'); - return isFlat(latlngs); +/* + * @namespace LineUtil + * + * Various utility functions for polyline points processing, used by Leaflet internally to make polylines lightning-fast. + */ + +// Simplify polyline with vertex reduction and Douglas-Peucker simplification. +// Improves rendering performance dramatically by lessening the number of points to draw. + +// @function simplify(points: Point[], tolerance: Number): Point[] +// Dramatically reduces the number of points in a polyline while retaining +// its shape and returns a new array of simplified points, using the +// [Douglas-Peucker algorithm](http://en.wikipedia.org/wiki/Douglas-Peucker_algorithm). +// Used for a huge performance boost when processing/displaying Leaflet polylines for +// each zoom level and also reducing visual noise. tolerance affects the amount of +// simplification (lesser value means higher quality but slower and with more points). +// Also released as a separated micro-library [Simplify.js](http://mourner.github.com/simplify-js/). +function simplify(points, tolerance) { + if (!tolerance || !points.length) { + return points.slice(); + } + + var sqTolerance = tolerance * tolerance; + + // stage 1: vertex reduction + points = _reducePoints(points, sqTolerance); + + // stage 2: Douglas-Peucker simplification + points = _simplifyDP(points, sqTolerance); + + return points; +} + +// @function pointToSegmentDistance(p: Point, p1: Point, p2: Point): Number +// Returns the distance between point `p` and segment `p1` to `p2`. +function pointToSegmentDistance(p, p1, p2) { + return Math.sqrt(_sqClosestPointOnSegment(p, p1, p2, true)); +} + +// @function closestPointOnSegment(p: Point, p1: Point, p2: Point): Number +// Returns the closest point from a point `p` on a segment `p1` to `p2`. +function closestPointOnSegment(p, p1, p2) { + return _sqClosestPointOnSegment(p, p1, p2); +} + +// Douglas-Peucker simplification, see http://en.wikipedia.org/wiki/Douglas-Peucker_algorithm +function _simplifyDP(points, sqTolerance) { + + var len = points.length, + ArrayConstructor = typeof Uint8Array !== undefined + '' ? Uint8Array : Array, + markers = new ArrayConstructor(len); + + markers[0] = markers[len - 1] = 1; + + _simplifyDPStep(points, markers, sqTolerance, 0, len - 1); + + var i, + newPoints = []; + + for (i = 0; i < len; i++) { + if (markers[i]) { + newPoints.push(points[i]); + } + } + + return newPoints; +} + +function _simplifyDPStep(points, markers, sqTolerance, first, last) { + + var maxSqDist = 0, + index, i, sqDist; + + for (i = first + 1; i <= last - 1; i++) { + sqDist = _sqClosestPointOnSegment(points[i], points[first], points[last], true); + + if (sqDist > maxSqDist) { + index = i; + maxSqDist = sqDist; + } + } + + if (maxSqDist > sqTolerance) { + markers[index] = 1; + + _simplifyDPStep(points, markers, sqTolerance, first, index); + _simplifyDPStep(points, markers, sqTolerance, index, last); + } +} + +// reduce points that are too close to each other to a single point +function _reducePoints(points, sqTolerance) { + var reducedPoints = [points[0]]; + + for (var i = 1, prev = 0, len = points.length; i < len; i++) { + if (_sqDist(points[i], points[prev]) > sqTolerance) { + reducedPoints.push(points[i]); + prev = i; + } + } + if (prev < len - 1) { + reducedPoints.push(points[len - 1]); + } + return reducedPoints; +} + +var _lastCode; + +// @function clipSegment(a: Point, b: Point, bounds: Bounds, useLastCode?: Boolean, round?: Boolean): Point[]|Boolean +// Clips the segment a to b by rectangular bounds with the +// [Cohen-Sutherland algorithm](https://en.wikipedia.org/wiki/Cohen%E2%80%93Sutherland_algorithm) +// (modifying the segment points directly!). Used by Leaflet to only show polyline +// points that are on the screen or near, increasing performance. +function clipSegment(a, b, bounds, useLastCode, round) { + var codeA = useLastCode ? _lastCode : _getBitCode(a, bounds), + codeB = _getBitCode(b, bounds), + + codeOut, p, newCode; + + // save 2nd code to avoid calculating it on the next segment + _lastCode = codeB; + + while (true) { + // if a,b is inside the clip window (trivial accept) + if (!(codeA | codeB)) { + return [a, b]; + } + + // if a,b is outside the clip window (trivial reject) + if (codeA & codeB) { + return false; + } + + // other cases + codeOut = codeA || codeB; + p = _getEdgeIntersection(a, b, codeOut, bounds, round); + newCode = _getBitCode(p, bounds); + + if (codeOut === codeA) { + a = p; + codeA = newCode; + } else { + b = p; + codeB = newCode; + } + } +} + +function _getEdgeIntersection(a, b, code, bounds, round) { + var dx = b.x - a.x, + dy = b.y - a.y, + min = bounds.min, + max = bounds.max, + x, y; + + if (code & 8) { // top + x = a.x + dx * (max.y - a.y) / dy; + y = max.y; + + } else if (code & 4) { // bottom + x = a.x + dx * (min.y - a.y) / dy; + y = min.y; + + } else if (code & 2) { // right + x = max.x; + y = a.y + dy * (max.x - a.x) / dx; + + } else if (code & 1) { // left + x = min.x; + y = a.y + dy * (min.x - a.x) / dx; + } + + return new Point(x, y, round); +} + +function _getBitCode(p, bounds) { + var code = 0; + + if (p.x < bounds.min.x) { // left + code |= 1; + } else if (p.x > bounds.max.x) { // right + code |= 2; + } + + if (p.y < bounds.min.y) { // bottom + code |= 4; + } else if (p.y > bounds.max.y) { // top + code |= 8; + } + + return code; +} + +// square distance (to avoid unnecessary Math.sqrt calls) +function _sqDist(p1, p2) { + var dx = p2.x - p1.x, + dy = p2.y - p1.y; + return dx * dx + dy * dy; +} + +// return closest point on segment or distance to that point +function _sqClosestPointOnSegment(p, p1, p2, sqDist) { + var x = p1.x, + y = p1.y, + dx = p2.x - x, + dy = p2.y - y, + dot = dx * dx + dy * dy, + t; + + if (dot > 0) { + t = ((p.x - x) * dx + (p.y - y) * dy) / dot; + + if (t > 1) { + x = p2.x; + y = p2.y; + } else if (t > 0) { + x += dx * t; + y += dy * t; + } + } + + dx = p.x - x; + dy = p.y - y; + + return sqDist ? dx * dx + dy * dy : new Point(x, y); +} + + +// @function isFlat(latlngs: LatLng[]): Boolean +// Returns true if `latlngs` is a flat array, false is nested. +function isFlat(latlngs) { + return !isArray(latlngs[0]) || (typeof latlngs[0][0] !== 'object' && typeof latlngs[0][0] !== 'undefined'); +} + +function _flat(latlngs) { + console.warn('Deprecated use of _flat, please use L.LineUtil.isFlat instead.'); + return isFlat(latlngs); } var LineUtil = ({ @@ -6217,133 +6217,133 @@ var LineUtil = ({ _flat: _flat }); -/* - * @namespace PolyUtil - * Various utility functions for polygon geometries. - */ - -/* @function clipPolygon(points: Point[], bounds: Bounds, round?: Boolean): Point[] - * Clips the polygon geometry defined by the given `points` by the given bounds (using the [Sutherland-Hodgman algorithm](https://en.wikipedia.org/wiki/Sutherland%E2%80%93Hodgman_algorithm)). - * Used by Leaflet to only show polygon points that are on the screen or near, increasing - * performance. Note that polygon points needs different algorithm for clipping - * than polyline, so there's a separate method for it. - */ -function clipPolygon(points, bounds, round) { - var clippedPoints, - edges = [1, 4, 2, 8], - i, j, k, - a, b, - len, edge, p; - - for (i = 0, len = points.length; i < len; i++) { - points[i]._code = _getBitCode(points[i], bounds); - } - - // for each edge (left, bottom, right, top) - for (k = 0; k < 4; k++) { - edge = edges[k]; - clippedPoints = []; - - for (i = 0, len = points.length, j = len - 1; i < len; j = i++) { - a = points[i]; - b = points[j]; - - // if a is inside the clip window - if (!(a._code & edge)) { - // if b is outside the clip window (a->b goes out of screen) - if (b._code & edge) { - p = _getEdgeIntersection(b, a, edge, bounds, round); - p._code = _getBitCode(p, bounds); - clippedPoints.push(p); - } - clippedPoints.push(a); - - // else if b is inside the clip window (a->b enters the screen) - } else if (!(b._code & edge)) { - p = _getEdgeIntersection(b, a, edge, bounds, round); - p._code = _getBitCode(p, bounds); - clippedPoints.push(p); - } - } - points = clippedPoints; - } - - return points; +/* + * @namespace PolyUtil + * Various utility functions for polygon geometries. + */ + +/* @function clipPolygon(points: Point[], bounds: Bounds, round?: Boolean): Point[] + * Clips the polygon geometry defined by the given `points` by the given bounds (using the [Sutherland-Hodgman algorithm](https://en.wikipedia.org/wiki/Sutherland%E2%80%93Hodgman_algorithm)). + * Used by Leaflet to only show polygon points that are on the screen or near, increasing + * performance. Note that polygon points needs different algorithm for clipping + * than polyline, so there's a separate method for it. + */ +function clipPolygon(points, bounds, round) { + var clippedPoints, + edges = [1, 4, 2, 8], + i, j, k, + a, b, + len, edge, p; + + for (i = 0, len = points.length; i < len; i++) { + points[i]._code = _getBitCode(points[i], bounds); + } + + // for each edge (left, bottom, right, top) + for (k = 0; k < 4; k++) { + edge = edges[k]; + clippedPoints = []; + + for (i = 0, len = points.length, j = len - 1; i < len; j = i++) { + a = points[i]; + b = points[j]; + + // if a is inside the clip window + if (!(a._code & edge)) { + // if b is outside the clip window (a->b goes out of screen) + if (b._code & edge) { + p = _getEdgeIntersection(b, a, edge, bounds, round); + p._code = _getBitCode(p, bounds); + clippedPoints.push(p); + } + clippedPoints.push(a); + + // else if b is inside the clip window (a->b enters the screen) + } else if (!(b._code & edge)) { + p = _getEdgeIntersection(b, a, edge, bounds, round); + p._code = _getBitCode(p, bounds); + clippedPoints.push(p); + } + } + points = clippedPoints; + } + + return points; } var PolyUtil = ({ clipPolygon: clipPolygon }); -/* - * @namespace Projection - * @section - * Leaflet comes with a set of already defined Projections out of the box: - * - * @projection L.Projection.LonLat - * - * Equirectangular, or Plate Carree projection — the most simple projection, - * mostly used by GIS enthusiasts. Directly maps `x` as longitude, and `y` as - * latitude. Also suitable for flat worlds, e.g. game maps. Used by the - * `EPSG:4326` and `Simple` CRS. - */ - -var LonLat = { - project: function (latlng) { - return new Point(latlng.lng, latlng.lat); - }, - - unproject: function (point) { - return new LatLng(point.y, point.x); - }, - - bounds: new Bounds([-180, -90], [180, 90]) +/* + * @namespace Projection + * @section + * Leaflet comes with a set of already defined Projections out of the box: + * + * @projection L.Projection.LonLat + * + * Equirectangular, or Plate Carree projection — the most simple projection, + * mostly used by GIS enthusiasts. Directly maps `x` as longitude, and `y` as + * latitude. Also suitable for flat worlds, e.g. game maps. Used by the + * `EPSG:4326` and `Simple` CRS. + */ + +var LonLat = { + project: function (latlng) { + return new Point(latlng.lng, latlng.lat); + }, + + unproject: function (point) { + return new LatLng(point.y, point.x); + }, + + bounds: new Bounds([-180, -90], [180, 90]) }; -/* - * @namespace Projection - * @projection L.Projection.Mercator - * - * Elliptical Mercator projection — more complex than Spherical Mercator. Assumes that Earth is an ellipsoid. Used by the EPSG:3395 CRS. - */ - -var Mercator = { - R: 6378137, - R_MINOR: 6356752.314245179, - - bounds: new Bounds([-20037508.34279, -15496570.73972], [20037508.34279, 18764656.23138]), - - project: function (latlng) { - var d = Math.PI / 180, - r = this.R, - y = latlng.lat * d, - tmp = this.R_MINOR / r, - e = Math.sqrt(1 - tmp * tmp), - con = e * Math.sin(y); - - var ts = Math.tan(Math.PI / 4 - y / 2) / Math.pow((1 - con) / (1 + con), e / 2); - y = -r * Math.log(Math.max(ts, 1E-10)); - - return new Point(latlng.lng * d * r, y); - }, - - unproject: function (point) { - var d = 180 / Math.PI, - r = this.R, - tmp = this.R_MINOR / r, - e = Math.sqrt(1 - tmp * tmp), - ts = Math.exp(-point.y / r), - phi = Math.PI / 2 - 2 * Math.atan(ts); - - for (var i = 0, dphi = 0.1, con; i < 15 && Math.abs(dphi) > 1e-7; i++) { - con = e * Math.sin(phi); - con = Math.pow((1 - con) / (1 + con), e / 2); - dphi = Math.PI / 2 - 2 * Math.atan(ts * con) - phi; - phi += dphi; - } - - return new LatLng(phi * d, point.x * d / r); - } +/* + * @namespace Projection + * @projection L.Projection.Mercator + * + * Elliptical Mercator projection — more complex than Spherical Mercator. Assumes that Earth is an ellipsoid. Used by the EPSG:3395 CRS. + */ + +var Mercator = { + R: 6378137, + R_MINOR: 6356752.314245179, + + bounds: new Bounds([-20037508.34279, -15496570.73972], [20037508.34279, 18764656.23138]), + + project: function (latlng) { + var d = Math.PI / 180, + r = this.R, + y = latlng.lat * d, + tmp = this.R_MINOR / r, + e = Math.sqrt(1 - tmp * tmp), + con = e * Math.sin(y); + + var ts = Math.tan(Math.PI / 4 - y / 2) / Math.pow((1 - con) / (1 + con), e / 2); + y = -r * Math.log(Math.max(ts, 1E-10)); + + return new Point(latlng.lng * d * r, y); + }, + + unproject: function (point) { + var d = 180 / Math.PI, + r = this.R, + tmp = this.R_MINOR / r, + e = Math.sqrt(1 - tmp * tmp), + ts = Math.exp(-point.y / r), + phi = Math.PI / 2 - 2 * Math.atan(ts); + + for (var i = 0, dphi = 0.1, con; i < 15 && Math.abs(dphi) > 1e-7; i++) { + con = e * Math.sin(phi); + con = Math.pow((1 - con) / (1 + con), e / 2); + dphi = Math.PI / 2 - 2 * Math.atan(ts * con) - phi; + phi += dphi; + } + + return new LatLng(phi * d, point.x * d / r); + } }; /* @@ -6375,39 +6375,39 @@ var index = ({ SphericalMercator: SphericalMercator }); -/* - * @namespace CRS - * @crs L.CRS.EPSG3395 - * - * Rarely used by some commercial tile providers. Uses Elliptical Mercator projection. - */ -var EPSG3395 = extend({}, Earth, { - code: 'EPSG:3395', - projection: Mercator, - - transformation: (function () { - var scale = 0.5 / (Math.PI * Mercator.R); - return toTransformation(scale, 0.5, -scale, 0.5); - }()) +/* + * @namespace CRS + * @crs L.CRS.EPSG3395 + * + * Rarely used by some commercial tile providers. Uses Elliptical Mercator projection. + */ +var EPSG3395 = extend({}, Earth, { + code: 'EPSG:3395', + projection: Mercator, + + transformation: (function () { + var scale = 0.5 / (Math.PI * Mercator.R); + return toTransformation(scale, 0.5, -scale, 0.5); + }()) }); -/* - * @namespace CRS - * @crs L.CRS.EPSG4326 - * - * A common CRS among GIS enthusiasts. Uses simple Equirectangular projection. - * - * Leaflet 1.0.x complies with the [TMS coordinate scheme for EPSG:4326](https://wiki.osgeo.org/wiki/Tile_Map_Service_Specification#global-geodetic), - * which is a breaking change from 0.7.x behaviour. If you are using a `TileLayer` - * with this CRS, ensure that there are two 256x256 pixel tiles covering the - * whole earth at zoom level zero, and that the tile coordinate origin is (-180,+90), - * or (-180,-90) for `TileLayer`s with [the `tms` option](#tilelayer-tms) set. - */ - -var EPSG4326 = extend({}, Earth, { - code: 'EPSG:4326', - projection: LonLat, - transformation: toTransformation(1 / 180, 1, -1 / 180, 0.5) +/* + * @namespace CRS + * @crs L.CRS.EPSG4326 + * + * A common CRS among GIS enthusiasts. Uses simple Equirectangular projection. + * + * Leaflet 1.0.x complies with the [TMS coordinate scheme for EPSG:4326](https://wiki.osgeo.org/wiki/Tile_Map_Service_Specification#global-geodetic), + * which is a breaking change from 0.7.x behaviour. If you are using a `TileLayer` + * with this CRS, ensure that there are two 256x256 pixel tiles covering the + * whole earth at zoom level zero, and that the tile coordinate origin is (-180,+90), + * or (-180,-90) for `TileLayer`s with [the `tms` option](#tilelayer-tms) set. + */ + +var EPSG4326 = extend({}, Earth, { + code: 'EPSG:4326', + projection: LonLat, + transformation: toTransformation(1 / 180, 1, -1 / 180, 0.5) }); /* @@ -6729,404 +6729,404 @@ Map.include({ } }); -/* - * @class LayerGroup - * @aka L.LayerGroup - * @inherits Layer - * - * Used to group several layers and handle them as one. If you add it to the map, - * any layers added or removed from the group will be added/removed on the map as - * well. Extends `Layer`. - * - * @example - * - * ```js - * L.layerGroup([marker1, marker2]) - * .addLayer(polyline) - * .addTo(map); - * ``` - */ - -var LayerGroup = Layer.extend({ - - initialize: function (layers, options) { - setOptions(this, options); - - this._layers = {}; - - var i, len; - - if (layers) { - for (i = 0, len = layers.length; i < len; i++) { - this.addLayer(layers[i]); - } - } - }, - - // @method addLayer(layer: Layer): this - // Adds the given layer to the group. - addLayer: function (layer) { - var id = this.getLayerId(layer); - - this._layers[id] = layer; - - if (this._map) { - this._map.addLayer(layer); - } - - return this; - }, - - // @method removeLayer(layer: Layer): this - // Removes the given layer from the group. - // @alternative - // @method removeLayer(id: Number): this - // Removes the layer with the given internal ID from the group. - removeLayer: function (layer) { - var id = layer in this._layers ? layer : this.getLayerId(layer); - - if (this._map && this._layers[id]) { - this._map.removeLayer(this._layers[id]); - } - - delete this._layers[id]; - - return this; - }, - - // @method hasLayer(layer: Layer): Boolean - // Returns `true` if the given layer is currently added to the group. - // @alternative - // @method hasLayer(id: Number): Boolean - // Returns `true` if the given internal ID is currently added to the group. - hasLayer: function (layer) { - if (!layer) { return false; } - var layerId = typeof layer === 'number' ? layer : this.getLayerId(layer); - return layerId in this._layers; - }, - - // @method clearLayers(): this - // Removes all the layers from the group. - clearLayers: function () { - return this.eachLayer(this.removeLayer, this); - }, - - // @method invoke(methodName: String, …): this - // Calls `methodName` on every layer contained in this group, passing any - // additional parameters. Has no effect if the layers contained do not - // implement `methodName`. - invoke: function (methodName) { - var args = Array.prototype.slice.call(arguments, 1), - i, layer; - - for (i in this._layers) { - layer = this._layers[i]; - - if (layer[methodName]) { - layer[methodName].apply(layer, args); - } - } - - return this; - }, - - onAdd: function (map) { - this.eachLayer(map.addLayer, map); - }, - - onRemove: function (map) { - this.eachLayer(map.removeLayer, map); - }, - - // @method eachLayer(fn: Function, context?: Object): this - // Iterates over the layers of the group, optionally specifying context of the iterator function. - // ```js - // group.eachLayer(function (layer) { - // layer.bindPopup('Hello'); - // }); - // ``` - eachLayer: function (method, context) { - for (var i in this._layers) { - method.call(context, this._layers[i]); - } - return this; - }, - - // @method getLayer(id: Number): Layer - // Returns the layer with the given internal ID. - getLayer: function (id) { - return this._layers[id]; - }, - - // @method getLayers(): Layer[] - // Returns an array of all the layers added to the group. - getLayers: function () { - var layers = []; - this.eachLayer(layers.push, layers); - return layers; - }, - - // @method setZIndex(zIndex: Number): this - // Calls `setZIndex` on every layer contained in this group, passing the z-index. - setZIndex: function (zIndex) { - return this.invoke('setZIndex', zIndex); - }, - - // @method getLayerId(layer: Layer): Number - // Returns the internal ID for a layer - getLayerId: function (layer) { - return stamp(layer); - } -}); - - -// @factory L.layerGroup(layers?: Layer[], options?: Object) -// Create a layer group, optionally given an initial set of layers and an `options` object. -var layerGroup = function (layers, options) { - return new LayerGroup(layers, options); +/* + * @class LayerGroup + * @aka L.LayerGroup + * @inherits Layer + * + * Used to group several layers and handle them as one. If you add it to the map, + * any layers added or removed from the group will be added/removed on the map as + * well. Extends `Layer`. + * + * @example + * + * ```js + * L.layerGroup([marker1, marker2]) + * .addLayer(polyline) + * .addTo(map); + * ``` + */ + +var LayerGroup = Layer.extend({ + + initialize: function (layers, options) { + setOptions(this, options); + + this._layers = {}; + + var i, len; + + if (layers) { + for (i = 0, len = layers.length; i < len; i++) { + this.addLayer(layers[i]); + } + } + }, + + // @method addLayer(layer: Layer): this + // Adds the given layer to the group. + addLayer: function (layer) { + var id = this.getLayerId(layer); + + this._layers[id] = layer; + + if (this._map) { + this._map.addLayer(layer); + } + + return this; + }, + + // @method removeLayer(layer: Layer): this + // Removes the given layer from the group. + // @alternative + // @method removeLayer(id: Number): this + // Removes the layer with the given internal ID from the group. + removeLayer: function (layer) { + var id = layer in this._layers ? layer : this.getLayerId(layer); + + if (this._map && this._layers[id]) { + this._map.removeLayer(this._layers[id]); + } + + delete this._layers[id]; + + return this; + }, + + // @method hasLayer(layer: Layer): Boolean + // Returns `true` if the given layer is currently added to the group. + // @alternative + // @method hasLayer(id: Number): Boolean + // Returns `true` if the given internal ID is currently added to the group. + hasLayer: function (layer) { + if (!layer) { return false; } + var layerId = typeof layer === 'number' ? layer : this.getLayerId(layer); + return layerId in this._layers; + }, + + // @method clearLayers(): this + // Removes all the layers from the group. + clearLayers: function () { + return this.eachLayer(this.removeLayer, this); + }, + + // @method invoke(methodName: String, …): this + // Calls `methodName` on every layer contained in this group, passing any + // additional parameters. Has no effect if the layers contained do not + // implement `methodName`. + invoke: function (methodName) { + var args = Array.prototype.slice.call(arguments, 1), + i, layer; + + for (i in this._layers) { + layer = this._layers[i]; + + if (layer[methodName]) { + layer[methodName].apply(layer, args); + } + } + + return this; + }, + + onAdd: function (map) { + this.eachLayer(map.addLayer, map); + }, + + onRemove: function (map) { + this.eachLayer(map.removeLayer, map); + }, + + // @method eachLayer(fn: Function, context?: Object): this + // Iterates over the layers of the group, optionally specifying context of the iterator function. + // ```js + // group.eachLayer(function (layer) { + // layer.bindPopup('Hello'); + // }); + // ``` + eachLayer: function (method, context) { + for (var i in this._layers) { + method.call(context, this._layers[i]); + } + return this; + }, + + // @method getLayer(id: Number): Layer + // Returns the layer with the given internal ID. + getLayer: function (id) { + return this._layers[id]; + }, + + // @method getLayers(): Layer[] + // Returns an array of all the layers added to the group. + getLayers: function () { + var layers = []; + this.eachLayer(layers.push, layers); + return layers; + }, + + // @method setZIndex(zIndex: Number): this + // Calls `setZIndex` on every layer contained in this group, passing the z-index. + setZIndex: function (zIndex) { + return this.invoke('setZIndex', zIndex); + }, + + // @method getLayerId(layer: Layer): Number + // Returns the internal ID for a layer + getLayerId: function (layer) { + return stamp(layer); + } +}); + + +// @factory L.layerGroup(layers?: Layer[], options?: Object) +// Create a layer group, optionally given an initial set of layers and an `options` object. +var layerGroup = function (layers, options) { + return new LayerGroup(layers, options); }; -/* - * @class FeatureGroup - * @aka L.FeatureGroup - * @inherits LayerGroup - * - * Extended `LayerGroup` that makes it easier to do the same thing to all its member layers: - * * [`bindPopup`](#layer-bindpopup) binds a popup to all of the layers at once (likewise with [`bindTooltip`](#layer-bindtooltip)) - * * Events are propagated to the `FeatureGroup`, so if the group has an event - * handler, it will handle events from any of the layers. This includes mouse events - * and custom events. - * * Has `layeradd` and `layerremove` events - * - * @example - * - * ```js - * L.featureGroup([marker1, marker2, polyline]) - * .bindPopup('Hello world!') - * .on('click', function() { alert('Clicked on a member of the group!'); }) - * .addTo(map); - * ``` - */ - -var FeatureGroup = LayerGroup.extend({ - - addLayer: function (layer) { - if (this.hasLayer(layer)) { - return this; - } - - layer.addEventParent(this); - - LayerGroup.prototype.addLayer.call(this, layer); - - // @event layeradd: LayerEvent - // Fired when a layer is added to this `FeatureGroup` - return this.fire('layeradd', {layer: layer}); - }, - - removeLayer: function (layer) { - if (!this.hasLayer(layer)) { - return this; - } - if (layer in this._layers) { - layer = this._layers[layer]; - } - - layer.removeEventParent(this); - - LayerGroup.prototype.removeLayer.call(this, layer); - - // @event layerremove: LayerEvent - // Fired when a layer is removed from this `FeatureGroup` - return this.fire('layerremove', {layer: layer}); - }, - - // @method setStyle(style: Path options): this - // Sets the given path options to each layer of the group that has a `setStyle` method. - setStyle: function (style) { - return this.invoke('setStyle', style); - }, - - // @method bringToFront(): this - // Brings the layer group to the top of all other layers - bringToFront: function () { - return this.invoke('bringToFront'); - }, - - // @method bringToBack(): this - // Brings the layer group to the back of all other layers - bringToBack: function () { - return this.invoke('bringToBack'); - }, - - // @method getBounds(): LatLngBounds - // Returns the LatLngBounds of the Feature Group (created from bounds and coordinates of its children). - getBounds: function () { - var bounds = new LatLngBounds(); - - for (var id in this._layers) { - var layer = this._layers[id]; - bounds.extend(layer.getBounds ? layer.getBounds() : layer.getLatLng()); - } - return bounds; - } -}); - -// @factory L.featureGroup(layers?: Layer[], options?: Object) -// Create a feature group, optionally given an initial set of layers and an `options` object. -var featureGroup = function (layers, options) { - return new FeatureGroup(layers, options); +/* + * @class FeatureGroup + * @aka L.FeatureGroup + * @inherits LayerGroup + * + * Extended `LayerGroup` that makes it easier to do the same thing to all its member layers: + * * [`bindPopup`](#layer-bindpopup) binds a popup to all of the layers at once (likewise with [`bindTooltip`](#layer-bindtooltip)) + * * Events are propagated to the `FeatureGroup`, so if the group has an event + * handler, it will handle events from any of the layers. This includes mouse events + * and custom events. + * * Has `layeradd` and `layerremove` events + * + * @example + * + * ```js + * L.featureGroup([marker1, marker2, polyline]) + * .bindPopup('Hello world!') + * .on('click', function() { alert('Clicked on a member of the group!'); }) + * .addTo(map); + * ``` + */ + +var FeatureGroup = LayerGroup.extend({ + + addLayer: function (layer) { + if (this.hasLayer(layer)) { + return this; + } + + layer.addEventParent(this); + + LayerGroup.prototype.addLayer.call(this, layer); + + // @event layeradd: LayerEvent + // Fired when a layer is added to this `FeatureGroup` + return this.fire('layeradd', {layer: layer}); + }, + + removeLayer: function (layer) { + if (!this.hasLayer(layer)) { + return this; + } + if (layer in this._layers) { + layer = this._layers[layer]; + } + + layer.removeEventParent(this); + + LayerGroup.prototype.removeLayer.call(this, layer); + + // @event layerremove: LayerEvent + // Fired when a layer is removed from this `FeatureGroup` + return this.fire('layerremove', {layer: layer}); + }, + + // @method setStyle(style: Path options): this + // Sets the given path options to each layer of the group that has a `setStyle` method. + setStyle: function (style) { + return this.invoke('setStyle', style); + }, + + // @method bringToFront(): this + // Brings the layer group to the top of all other layers + bringToFront: function () { + return this.invoke('bringToFront'); + }, + + // @method bringToBack(): this + // Brings the layer group to the back of all other layers + bringToBack: function () { + return this.invoke('bringToBack'); + }, + + // @method getBounds(): LatLngBounds + // Returns the LatLngBounds of the Feature Group (created from bounds and coordinates of its children). + getBounds: function () { + var bounds = new LatLngBounds(); + + for (var id in this._layers) { + var layer = this._layers[id]; + bounds.extend(layer.getBounds ? layer.getBounds() : layer.getLatLng()); + } + return bounds; + } +}); + +// @factory L.featureGroup(layers?: Layer[], options?: Object) +// Create a feature group, optionally given an initial set of layers and an `options` object. +var featureGroup = function (layers, options) { + return new FeatureGroup(layers, options); }; -/* - * @class Icon - * @aka L.Icon - * - * Represents an icon to provide when creating a marker. - * - * @example - * - * ```js - * var myIcon = L.icon({ - * iconUrl: 'my-icon.png', - * iconRetinaUrl: 'my-icon@2x.png', - * iconSize: [38, 95], - * iconAnchor: [22, 94], - * popupAnchor: [-3, -76], - * shadowUrl: 'my-icon-shadow.png', - * shadowRetinaUrl: 'my-icon-shadow@2x.png', - * shadowSize: [68, 95], - * shadowAnchor: [22, 94] - * }); - * - * L.marker([50.505, 30.57], {icon: myIcon}).addTo(map); - * ``` - * - * `L.Icon.Default` extends `L.Icon` and is the blue icon Leaflet uses for markers by default. - * - */ - -var Icon = Class.extend({ - - /* @section - * @aka Icon options - * - * @option iconUrl: String = null - * **(required)** The URL to the icon image (absolute or relative to your script path). - * - * @option iconRetinaUrl: String = null - * The URL to a retina sized version of the icon image (absolute or relative to your - * script path). Used for Retina screen devices. - * - * @option iconSize: Point = null - * Size of the icon image in pixels. - * - * @option iconAnchor: Point = null - * The coordinates of the "tip" of the icon (relative to its top left corner). The icon - * will be aligned so that this point is at the marker's geographical location. Centered - * by default if size is specified, also can be set in CSS with negative margins. - * - * @option popupAnchor: Point = [0, 0] - * The coordinates of the point from which popups will "open", relative to the icon anchor. - * - * @option tooltipAnchor: Point = [0, 0] - * The coordinates of the point from which tooltips will "open", relative to the icon anchor. - * - * @option shadowUrl: String = null - * The URL to the icon shadow image. If not specified, no shadow image will be created. - * - * @option shadowRetinaUrl: String = null - * - * @option shadowSize: Point = null - * Size of the shadow image in pixels. - * - * @option shadowAnchor: Point = null - * The coordinates of the "tip" of the shadow (relative to its top left corner) (the same - * as iconAnchor if not specified). - * - * @option className: String = '' - * A custom class name to assign to both icon and shadow images. Empty by default. - */ - - options: { - popupAnchor: [0, 0], - tooltipAnchor: [0, 0] - }, - - initialize: function (options) { - setOptions(this, options); - }, - - // @method createIcon(oldIcon?: HTMLElement): HTMLElement - // Called internally when the icon has to be shown, returns a `` HTML element - // styled according to the options. - createIcon: function (oldIcon) { - return this._createIcon('icon', oldIcon); - }, - - // @method createShadow(oldIcon?: HTMLElement): HTMLElement - // As `createIcon`, but for the shadow beneath it. - createShadow: function (oldIcon) { - return this._createIcon('shadow', oldIcon); - }, - - _createIcon: function (name, oldIcon) { - var src = this._getIconUrl(name); - - if (!src) { - if (name === 'icon') { - throw new Error('iconUrl not set in Icon options (see the docs).'); - } - return null; - } - - var img = this._createImg(src, oldIcon && oldIcon.tagName === 'IMG' ? oldIcon : null); - this._setIconStyles(img, name); - - return img; - }, - - _setIconStyles: function (img, name) { - var options = this.options; - var sizeOption = options[name + 'Size']; - - if (typeof sizeOption === 'number') { - sizeOption = [sizeOption, sizeOption]; - } - - var size = toPoint(sizeOption), - anchor = toPoint(name === 'shadow' && options.shadowAnchor || options.iconAnchor || - size && size.divideBy(2, true)); - - img.className = 'leaflet-marker-' + name + ' ' + (options.className || ''); - - if (anchor) { - img.style.marginLeft = (-anchor.x) + 'px'; - img.style.marginTop = (-anchor.y) + 'px'; - } - - if (size) { - img.style.width = size.x + 'px'; - img.style.height = size.y + 'px'; - } - }, - - _createImg: function (src, el) { - el = el || document.createElement('img'); - el.src = src; - return el; - }, - - _getIconUrl: function (name) { - return retina && this.options[name + 'RetinaUrl'] || this.options[name + 'Url']; - } -}); - - -// @factory L.icon(options: Icon options) -// Creates an icon instance with the given options. -function icon(options) { - return new Icon(options); +/* + * @class Icon + * @aka L.Icon + * + * Represents an icon to provide when creating a marker. + * + * @example + * + * ```js + * var myIcon = L.icon({ + * iconUrl: 'my-icon.png', + * iconRetinaUrl: 'my-icon@2x.png', + * iconSize: [38, 95], + * iconAnchor: [22, 94], + * popupAnchor: [-3, -76], + * shadowUrl: 'my-icon-shadow.png', + * shadowRetinaUrl: 'my-icon-shadow@2x.png', + * shadowSize: [68, 95], + * shadowAnchor: [22, 94] + * }); + * + * L.marker([50.505, 30.57], {icon: myIcon}).addTo(map); + * ``` + * + * `L.Icon.Default` extends `L.Icon` and is the blue icon Leaflet uses for markers by default. + * + */ + +var Icon = Class.extend({ + + /* @section + * @aka Icon options + * + * @option iconUrl: String = null + * **(required)** The URL to the icon image (absolute or relative to your script path). + * + * @option iconRetinaUrl: String = null + * The URL to a retina sized version of the icon image (absolute or relative to your + * script path). Used for Retina screen devices. + * + * @option iconSize: Point = null + * Size of the icon image in pixels. + * + * @option iconAnchor: Point = null + * The coordinates of the "tip" of the icon (relative to its top left corner). The icon + * will be aligned so that this point is at the marker's geographical location. Centered + * by default if size is specified, also can be set in CSS with negative margins. + * + * @option popupAnchor: Point = [0, 0] + * The coordinates of the point from which popups will "open", relative to the icon anchor. + * + * @option tooltipAnchor: Point = [0, 0] + * The coordinates of the point from which tooltips will "open", relative to the icon anchor. + * + * @option shadowUrl: String = null + * The URL to the icon shadow image. If not specified, no shadow image will be created. + * + * @option shadowRetinaUrl: String = null + * + * @option shadowSize: Point = null + * Size of the shadow image in pixels. + * + * @option shadowAnchor: Point = null + * The coordinates of the "tip" of the shadow (relative to its top left corner) (the same + * as iconAnchor if not specified). + * + * @option className: String = '' + * A custom class name to assign to both icon and shadow images. Empty by default. + */ + + options: { + popupAnchor: [0, 0], + tooltipAnchor: [0, 0] + }, + + initialize: function (options) { + setOptions(this, options); + }, + + // @method createIcon(oldIcon?: HTMLElement): HTMLElement + // Called internally when the icon has to be shown, returns a `` HTML element + // styled according to the options. + createIcon: function (oldIcon) { + return this._createIcon('icon', oldIcon); + }, + + // @method createShadow(oldIcon?: HTMLElement): HTMLElement + // As `createIcon`, but for the shadow beneath it. + createShadow: function (oldIcon) { + return this._createIcon('shadow', oldIcon); + }, + + _createIcon: function (name, oldIcon) { + var src = this._getIconUrl(name); + + if (!src) { + if (name === 'icon') { + throw new Error('iconUrl not set in Icon options (see the docs).'); + } + return null; + } + + var img = this._createImg(src, oldIcon && oldIcon.tagName === 'IMG' ? oldIcon : null); + this._setIconStyles(img, name); + + return img; + }, + + _setIconStyles: function (img, name) { + var options = this.options; + var sizeOption = options[name + 'Size']; + + if (typeof sizeOption === 'number') { + sizeOption = [sizeOption, sizeOption]; + } + + var size = toPoint(sizeOption), + anchor = toPoint(name === 'shadow' && options.shadowAnchor || options.iconAnchor || + size && size.divideBy(2, true)); + + img.className = 'leaflet-marker-' + name + ' ' + (options.className || ''); + + if (anchor) { + img.style.marginLeft = (-anchor.x) + 'px'; + img.style.marginTop = (-anchor.y) + 'px'; + } + + if (size) { + img.style.width = size.x + 'px'; + img.style.height = size.y + 'px'; + } + }, + + _createImg: function (src, el) { + el = el || document.createElement('img'); + el.src = src; + return el; + }, + + _getIconUrl: function (name) { + return retina && this.options[name + 'RetinaUrl'] || this.options[name + 'Url']; + } +}); + + +// @factory L.icon(options: Icon options) +// Creates an icon instance with the given options. +function icon(options) { + return new Icon(options); } /* @@ -7342,384 +7342,384 @@ var MarkerDrag = Handler.extend({ } }); -/* - * @class Marker - * @inherits Interactive layer - * @aka L.Marker - * L.Marker is used to display clickable/draggable icons on the map. Extends `Layer`. - * - * @example - * - * ```js - * L.marker([50.5, 30.5]).addTo(map); - * ``` - */ - -var Marker = Layer.extend({ - - // @section - // @aka Marker options - options: { - // @option icon: Icon = * - // Icon instance to use for rendering the marker. - // See [Icon documentation](#L.Icon) for details on how to customize the marker icon. - // If not specified, a common instance of `L.Icon.Default` is used. - icon: new IconDefault(), - - // Option inherited from "Interactive layer" abstract class - interactive: true, - - // @option keyboard: Boolean = true - // Whether the marker can be tabbed to with a keyboard and clicked by pressing enter. - keyboard: true, - - // @option title: String = '' - // Text for the browser tooltip that appear on marker hover (no tooltip by default). - title: '', - - // @option alt: String = '' - // Text for the `alt` attribute of the icon image (useful for accessibility). - alt: '', - - // @option zIndexOffset: Number = 0 - // By default, marker images zIndex is set automatically based on its latitude. Use this option if you want to put the marker on top of all others (or below), specifying a high value like `1000` (or high negative value, respectively). - zIndexOffset: 0, - - // @option opacity: Number = 1.0 - // The opacity of the marker. - opacity: 1, - - // @option riseOnHover: Boolean = false - // If `true`, the marker will get on top of others when you hover the mouse over it. - riseOnHover: false, - - // @option riseOffset: Number = 250 - // The z-index offset used for the `riseOnHover` feature. - riseOffset: 250, - - // @option pane: String = 'markerPane' - // `Map pane` where the markers icon will be added. - pane: 'markerPane', - - // @option shadowPane: String = 'shadowPane' - // `Map pane` where the markers shadow will be added. - shadowPane: 'shadowPane', - - // @option bubblingMouseEvents: Boolean = false - // When `true`, a mouse event on this marker will trigger the same event on the map - // (unless [`L.DomEvent.stopPropagation`](#domevent-stoppropagation) is used). - bubblingMouseEvents: false, - - // @section Draggable marker options - // @option draggable: Boolean = false - // Whether the marker is draggable with mouse/touch or not. - draggable: false, - - // @option autoPan: Boolean = false - // Whether to pan the map when dragging this marker near its edge or not. - autoPan: false, - - // @option autoPanPadding: Point = Point(50, 50) - // Distance (in pixels to the left/right and to the top/bottom) of the - // map edge to start panning the map. - autoPanPadding: [50, 50], - - // @option autoPanSpeed: Number = 10 - // Number of pixels the map should pan by. - autoPanSpeed: 10 - }, - - /* @section - * - * In addition to [shared layer methods](#Layer) like `addTo()` and `remove()` and [popup methods](#Popup) like bindPopup() you can also use the following methods: - */ - - initialize: function (latlng, options) { - setOptions(this, options); - this._latlng = toLatLng(latlng); - }, - - onAdd: function (map) { - this._zoomAnimated = this._zoomAnimated && map.options.markerZoomAnimation; - - if (this._zoomAnimated) { - map.on('zoomanim', this._animateZoom, this); - } - - this._initIcon(); - this.update(); - }, - - onRemove: function (map) { - if (this.dragging && this.dragging.enabled()) { - this.options.draggable = true; - this.dragging.removeHooks(); - } - delete this.dragging; - - if (this._zoomAnimated) { - map.off('zoomanim', this._animateZoom, this); - } - - this._removeIcon(); - this._removeShadow(); - }, - - getEvents: function () { - return { - zoom: this.update, - viewreset: this.update - }; - }, - - // @method getLatLng: LatLng - // Returns the current geographical position of the marker. - getLatLng: function () { - return this._latlng; - }, - - // @method setLatLng(latlng: LatLng): this - // Changes the marker position to the given point. - setLatLng: function (latlng) { - var oldLatLng = this._latlng; - this._latlng = toLatLng(latlng); - this.update(); - - // @event move: Event - // Fired when the marker is moved via [`setLatLng`](#marker-setlatlng) or by [dragging](#marker-dragging). Old and new coordinates are included in event arguments as `oldLatLng`, `latlng`. - return this.fire('move', {oldLatLng: oldLatLng, latlng: this._latlng}); - }, - - // @method setZIndexOffset(offset: Number): this - // Changes the [zIndex offset](#marker-zindexoffset) of the marker. - setZIndexOffset: function (offset) { - this.options.zIndexOffset = offset; - return this.update(); - }, - - // @method getIcon: Icon - // Returns the current icon used by the marker - getIcon: function () { - return this.options.icon; - }, - - // @method setIcon(icon: Icon): this - // Changes the marker icon. - setIcon: function (icon) { - - this.options.icon = icon; - - if (this._map) { - this._initIcon(); - this.update(); - } - - if (this._popup) { - this.bindPopup(this._popup, this._popup.options); - } - - return this; - }, - - getElement: function () { - return this._icon; - }, - - update: function () { - - if (this._icon && this._map) { - var pos = this._map.latLngToLayerPoint(this._latlng).round(); - this._setPos(pos); - } - - return this; - }, - - _initIcon: function () { - var options = this.options, - classToAdd = 'leaflet-zoom-' + (this._zoomAnimated ? 'animated' : 'hide'); - - var icon = options.icon.createIcon(this._icon), - addIcon = false; - - // if we're not reusing the icon, remove the old one and init new one - if (icon !== this._icon) { - if (this._icon) { - this._removeIcon(); - } - addIcon = true; - - if (options.title) { - icon.title = options.title; - } - - if (icon.tagName === 'IMG') { - icon.alt = options.alt || ''; - } - } - - addClass(icon, classToAdd); - - if (options.keyboard) { - icon.tabIndex = '0'; - } - - this._icon = icon; - - if (options.riseOnHover) { - this.on({ - mouseover: this._bringToFront, - mouseout: this._resetZIndex - }); - } - - var newShadow = options.icon.createShadow(this._shadow), - addShadow = false; - - if (newShadow !== this._shadow) { - this._removeShadow(); - addShadow = true; - } - - if (newShadow) { - addClass(newShadow, classToAdd); - newShadow.alt = ''; - } - this._shadow = newShadow; - - - if (options.opacity < 1) { - this._updateOpacity(); - } - - - if (addIcon) { - this.getPane().appendChild(this._icon); - } - this._initInteraction(); - if (newShadow && addShadow) { - this.getPane(options.shadowPane).appendChild(this._shadow); - } - }, - - _removeIcon: function () { - if (this.options.riseOnHover) { - this.off({ - mouseover: this._bringToFront, - mouseout: this._resetZIndex - }); - } - - remove(this._icon); - this.removeInteractiveTarget(this._icon); - - this._icon = null; - }, - - _removeShadow: function () { - if (this._shadow) { - remove(this._shadow); - } - this._shadow = null; - }, - - _setPos: function (pos) { - - if (this._icon) { - setPosition(this._icon, pos); - } - - if (this._shadow) { - setPosition(this._shadow, pos); - } - - this._zIndex = pos.y + this.options.zIndexOffset; - - this._resetZIndex(); - }, - - _updateZIndex: function (offset) { - if (this._icon) { - this._icon.style.zIndex = this._zIndex + offset; - } - }, - - _animateZoom: function (opt) { - var pos = this._map._latLngToNewLayerPoint(this._latlng, opt.zoom, opt.center).round(); - - this._setPos(pos); - }, - - _initInteraction: function () { - - if (!this.options.interactive) { return; } - - addClass(this._icon, 'leaflet-interactive'); - - this.addInteractiveTarget(this._icon); - - if (MarkerDrag) { - var draggable = this.options.draggable; - if (this.dragging) { - draggable = this.dragging.enabled(); - this.dragging.disable(); - } - - this.dragging = new MarkerDrag(this); - - if (draggable) { - this.dragging.enable(); - } - } - }, - - // @method setOpacity(opacity: Number): this - // Changes the opacity of the marker. - setOpacity: function (opacity) { - this.options.opacity = opacity; - if (this._map) { - this._updateOpacity(); - } - - return this; - }, - - _updateOpacity: function () { - var opacity = this.options.opacity; - - if (this._icon) { - setOpacity(this._icon, opacity); - } - - if (this._shadow) { - setOpacity(this._shadow, opacity); - } - }, - - _bringToFront: function () { - this._updateZIndex(this.options.riseOffset); - }, - - _resetZIndex: function () { - this._updateZIndex(0); - }, - - _getPopupAnchor: function () { - return this.options.icon.options.popupAnchor; - }, - - _getTooltipAnchor: function () { - return this.options.icon.options.tooltipAnchor; - } -}); - - -// factory L.marker(latlng: LatLng, options? : Marker options) - -// @factory L.marker(latlng: LatLng, options? : Marker options) -// Instantiates a Marker object given a geographical point and optionally an options object. -function marker(latlng, options) { - return new Marker(latlng, options); +/* + * @class Marker + * @inherits Interactive layer + * @aka L.Marker + * L.Marker is used to display clickable/draggable icons on the map. Extends `Layer`. + * + * @example + * + * ```js + * L.marker([50.5, 30.5]).addTo(map); + * ``` + */ + +var Marker = Layer.extend({ + + // @section + // @aka Marker options + options: { + // @option icon: Icon = * + // Icon instance to use for rendering the marker. + // See [Icon documentation](#L.Icon) for details on how to customize the marker icon. + // If not specified, a common instance of `L.Icon.Default` is used. + icon: new IconDefault(), + + // Option inherited from "Interactive layer" abstract class + interactive: true, + + // @option keyboard: Boolean = true + // Whether the marker can be tabbed to with a keyboard and clicked by pressing enter. + keyboard: true, + + // @option title: String = '' + // Text for the browser tooltip that appear on marker hover (no tooltip by default). + title: '', + + // @option alt: String = '' + // Text for the `alt` attribute of the icon image (useful for accessibility). + alt: '', + + // @option zIndexOffset: Number = 0 + // By default, marker images zIndex is set automatically based on its latitude. Use this option if you want to put the marker on top of all others (or below), specifying a high value like `1000` (or high negative value, respectively). + zIndexOffset: 0, + + // @option opacity: Number = 1.0 + // The opacity of the marker. + opacity: 1, + + // @option riseOnHover: Boolean = false + // If `true`, the marker will get on top of others when you hover the mouse over it. + riseOnHover: false, + + // @option riseOffset: Number = 250 + // The z-index offset used for the `riseOnHover` feature. + riseOffset: 250, + + // @option pane: String = 'markerPane' + // `Map pane` where the markers icon will be added. + pane: 'markerPane', + + // @option shadowPane: String = 'shadowPane' + // `Map pane` where the markers shadow will be added. + shadowPane: 'shadowPane', + + // @option bubblingMouseEvents: Boolean = false + // When `true`, a mouse event on this marker will trigger the same event on the map + // (unless [`L.DomEvent.stopPropagation`](#domevent-stoppropagation) is used). + bubblingMouseEvents: false, + + // @section Draggable marker options + // @option draggable: Boolean = false + // Whether the marker is draggable with mouse/touch or not. + draggable: false, + + // @option autoPan: Boolean = false + // Whether to pan the map when dragging this marker near its edge or not. + autoPan: false, + + // @option autoPanPadding: Point = Point(50, 50) + // Distance (in pixels to the left/right and to the top/bottom) of the + // map edge to start panning the map. + autoPanPadding: [50, 50], + + // @option autoPanSpeed: Number = 10 + // Number of pixels the map should pan by. + autoPanSpeed: 10 + }, + + /* @section + * + * In addition to [shared layer methods](#Layer) like `addTo()` and `remove()` and [popup methods](#Popup) like bindPopup() you can also use the following methods: + */ + + initialize: function (latlng, options) { + setOptions(this, options); + this._latlng = toLatLng(latlng); + }, + + onAdd: function (map) { + this._zoomAnimated = this._zoomAnimated && map.options.markerZoomAnimation; + + if (this._zoomAnimated) { + map.on('zoomanim', this._animateZoom, this); + } + + this._initIcon(); + this.update(); + }, + + onRemove: function (map) { + if (this.dragging && this.dragging.enabled()) { + this.options.draggable = true; + this.dragging.removeHooks(); + } + delete this.dragging; + + if (this._zoomAnimated) { + map.off('zoomanim', this._animateZoom, this); + } + + this._removeIcon(); + this._removeShadow(); + }, + + getEvents: function () { + return { + zoom: this.update, + viewreset: this.update + }; + }, + + // @method getLatLng: LatLng + // Returns the current geographical position of the marker. + getLatLng: function () { + return this._latlng; + }, + + // @method setLatLng(latlng: LatLng): this + // Changes the marker position to the given point. + setLatLng: function (latlng) { + var oldLatLng = this._latlng; + this._latlng = toLatLng(latlng); + this.update(); + + // @event move: Event + // Fired when the marker is moved via [`setLatLng`](#marker-setlatlng) or by [dragging](#marker-dragging). Old and new coordinates are included in event arguments as `oldLatLng`, `latlng`. + return this.fire('move', {oldLatLng: oldLatLng, latlng: this._latlng}); + }, + + // @method setZIndexOffset(offset: Number): this + // Changes the [zIndex offset](#marker-zindexoffset) of the marker. + setZIndexOffset: function (offset) { + this.options.zIndexOffset = offset; + return this.update(); + }, + + // @method getIcon: Icon + // Returns the current icon used by the marker + getIcon: function () { + return this.options.icon; + }, + + // @method setIcon(icon: Icon): this + // Changes the marker icon. + setIcon: function (icon) { + + this.options.icon = icon; + + if (this._map) { + this._initIcon(); + this.update(); + } + + if (this._popup) { + this.bindPopup(this._popup, this._popup.options); + } + + return this; + }, + + getElement: function () { + return this._icon; + }, + + update: function () { + + if (this._icon && this._map) { + var pos = this._map.latLngToLayerPoint(this._latlng).round(); + this._setPos(pos); + } + + return this; + }, + + _initIcon: function () { + var options = this.options, + classToAdd = 'leaflet-zoom-' + (this._zoomAnimated ? 'animated' : 'hide'); + + var icon = options.icon.createIcon(this._icon), + addIcon = false; + + // if we're not reusing the icon, remove the old one and init new one + if (icon !== this._icon) { + if (this._icon) { + this._removeIcon(); + } + addIcon = true; + + if (options.title) { + icon.title = options.title; + } + + if (icon.tagName === 'IMG') { + icon.alt = options.alt || ''; + } + } + + addClass(icon, classToAdd); + + if (options.keyboard) { + icon.tabIndex = '0'; + } + + this._icon = icon; + + if (options.riseOnHover) { + this.on({ + mouseover: this._bringToFront, + mouseout: this._resetZIndex + }); + } + + var newShadow = options.icon.createShadow(this._shadow), + addShadow = false; + + if (newShadow !== this._shadow) { + this._removeShadow(); + addShadow = true; + } + + if (newShadow) { + addClass(newShadow, classToAdd); + newShadow.alt = ''; + } + this._shadow = newShadow; + + + if (options.opacity < 1) { + this._updateOpacity(); + } + + + if (addIcon) { + this.getPane().appendChild(this._icon); + } + this._initInteraction(); + if (newShadow && addShadow) { + this.getPane(options.shadowPane).appendChild(this._shadow); + } + }, + + _removeIcon: function () { + if (this.options.riseOnHover) { + this.off({ + mouseover: this._bringToFront, + mouseout: this._resetZIndex + }); + } + + remove(this._icon); + this.removeInteractiveTarget(this._icon); + + this._icon = null; + }, + + _removeShadow: function () { + if (this._shadow) { + remove(this._shadow); + } + this._shadow = null; + }, + + _setPos: function (pos) { + + if (this._icon) { + setPosition(this._icon, pos); + } + + if (this._shadow) { + setPosition(this._shadow, pos); + } + + this._zIndex = pos.y + this.options.zIndexOffset; + + this._resetZIndex(); + }, + + _updateZIndex: function (offset) { + if (this._icon) { + this._icon.style.zIndex = this._zIndex + offset; + } + }, + + _animateZoom: function (opt) { + var pos = this._map._latLngToNewLayerPoint(this._latlng, opt.zoom, opt.center).round(); + + this._setPos(pos); + }, + + _initInteraction: function () { + + if (!this.options.interactive) { return; } + + addClass(this._icon, 'leaflet-interactive'); + + this.addInteractiveTarget(this._icon); + + if (MarkerDrag) { + var draggable = this.options.draggable; + if (this.dragging) { + draggable = this.dragging.enabled(); + this.dragging.disable(); + } + + this.dragging = new MarkerDrag(this); + + if (draggable) { + this.dragging.enable(); + } + } + }, + + // @method setOpacity(opacity: Number): this + // Changes the opacity of the marker. + setOpacity: function (opacity) { + this.options.opacity = opacity; + if (this._map) { + this._updateOpacity(); + } + + return this; + }, + + _updateOpacity: function () { + var opacity = this.options.opacity; + + if (this._icon) { + setOpacity(this._icon, opacity); + } + + if (this._shadow) { + setOpacity(this._shadow, opacity); + } + }, + + _bringToFront: function () { + this._updateZIndex(this.options.riseOffset); + }, + + _resetZIndex: function () { + this._updateZIndex(0); + }, + + _getPopupAnchor: function () { + return this.options.icon.options.popupAnchor; + }, + + _getTooltipAnchor: function () { + return this.options.icon.options.tooltipAnchor; + } +}); + + +// factory L.marker(latlng: LatLng, options? : Marker options) + +// @factory L.marker(latlng: LatLng, options? : Marker options) +// Instantiates a Marker object given a geographical point and optionally an options object. +function marker(latlng, options) { + return new Marker(latlng, options); } /* @@ -8581,792 +8581,792 @@ function polygon(latlngs, options) { return new Polygon(latlngs, options); } -/* - * @class GeoJSON - * @aka L.GeoJSON - * @inherits FeatureGroup - * - * Represents a GeoJSON object or an array of GeoJSON objects. Allows you to parse - * GeoJSON data and display it on the map. Extends `FeatureGroup`. - * - * @example - * - * ```js - * L.geoJSON(data, { - * style: function (feature) { - * return {color: feature.properties.color}; - * } - * }).bindPopup(function (layer) { - * return layer.feature.properties.description; - * }).addTo(map); - * ``` - */ - -var GeoJSON = FeatureGroup.extend({ - - /* @section - * @aka GeoJSON options - * - * @option pointToLayer: Function = * - * A `Function` defining how GeoJSON points spawn Leaflet layers. It is internally - * called when data is added, passing the GeoJSON point feature and its `LatLng`. - * The default is to spawn a default `Marker`: - * ```js - * function(geoJsonPoint, latlng) { - * return L.marker(latlng); - * } - * ``` - * - * @option style: Function = * - * A `Function` defining the `Path options` for styling GeoJSON lines and polygons, - * called internally when data is added. - * The default value is to not override any defaults: - * ```js - * function (geoJsonFeature) { - * return {} - * } - * ``` - * - * @option onEachFeature: Function = * - * A `Function` that will be called once for each created `Feature`, after it has - * been created and styled. Useful for attaching events and popups to features. - * The default is to do nothing with the newly created layers: - * ```js - * function (feature, layer) {} - * ``` - * - * @option filter: Function = * - * A `Function` that will be used to decide whether to include a feature or not. - * The default is to include all features: - * ```js - * function (geoJsonFeature) { - * return true; - * } - * ``` - * Note: dynamically changing the `filter` option will have effect only on newly - * added data. It will _not_ re-evaluate already included features. - * - * @option coordsToLatLng: Function = * - * A `Function` that will be used for converting GeoJSON coordinates to `LatLng`s. - * The default is the `coordsToLatLng` static method. - * - * @option markersInheritOptions: Boolean = false - * Whether default Markers for "Point" type Features inherit from group options. - */ - - initialize: function (geojson, options) { - setOptions(this, options); - - this._layers = {}; - - if (geojson) { - this.addData(geojson); - } - }, - - // @method addData( data ): this - // Adds a GeoJSON object to the layer. - addData: function (geojson) { - var features = isArray(geojson) ? geojson : geojson.features, - i, len, feature; - - if (features) { - for (i = 0, len = features.length; i < len; i++) { - // only add this if geometry or geometries are set and not null - feature = features[i]; - if (feature.geometries || feature.geometry || feature.features || feature.coordinates) { - this.addData(feature); - } - } - return this; - } - - var options = this.options; - - if (options.filter && !options.filter(geojson)) { return this; } - - var layer = geometryToLayer(geojson, options); - if (!layer) { - return this; - } - layer.feature = asFeature(geojson); - - layer.defaultOptions = layer.options; - this.resetStyle(layer); - - if (options.onEachFeature) { - options.onEachFeature(geojson, layer); - } - - return this.addLayer(layer); - }, - - // @method resetStyle( layer? ): this - // Resets the given vector layer's style to the original GeoJSON style, useful for resetting style after hover events. - // If `layer` is omitted, the style of all features in the current layer is reset. - resetStyle: function (layer) { - if (layer === undefined) { - return this.eachLayer(this.resetStyle, this); - } - // reset any custom styles - layer.options = extend({}, layer.defaultOptions); - this._setLayerStyle(layer, this.options.style); - return this; - }, - - // @method setStyle( style ): this - // Changes styles of GeoJSON vector layers with the given style function. - setStyle: function (style) { - return this.eachLayer(function (layer) { - this._setLayerStyle(layer, style); - }, this); - }, - - _setLayerStyle: function (layer, style) { - if (layer.setStyle) { - if (typeof style === 'function') { - style = style(layer.feature); - } - layer.setStyle(style); - } - } -}); - -// @section -// There are several static functions which can be called without instantiating L.GeoJSON: - -// @function geometryToLayer(featureData: Object, options?: GeoJSON options): Layer -// Creates a `Layer` from a given GeoJSON feature. Can use a custom -// [`pointToLayer`](#geojson-pointtolayer) and/or [`coordsToLatLng`](#geojson-coordstolatlng) -// functions if provided as options. -function geometryToLayer(geojson, options) { - - var geometry = geojson.type === 'Feature' ? geojson.geometry : geojson, - coords = geometry ? geometry.coordinates : null, - layers = [], - pointToLayer = options && options.pointToLayer, - _coordsToLatLng = options && options.coordsToLatLng || coordsToLatLng, - latlng, latlngs, i, len; - - if (!coords && !geometry) { - return null; - } - - switch (geometry.type) { - case 'Point': - latlng = _coordsToLatLng(coords); - return _pointToLayer(pointToLayer, geojson, latlng, options); - - case 'MultiPoint': - for (i = 0, len = coords.length; i < len; i++) { - latlng = _coordsToLatLng(coords[i]); - layers.push(_pointToLayer(pointToLayer, geojson, latlng, options)); - } - return new FeatureGroup(layers); - - case 'LineString': - case 'MultiLineString': - latlngs = coordsToLatLngs(coords, geometry.type === 'LineString' ? 0 : 1, _coordsToLatLng); - return new Polyline(latlngs, options); - - case 'Polygon': - case 'MultiPolygon': - latlngs = coordsToLatLngs(coords, geometry.type === 'Polygon' ? 1 : 2, _coordsToLatLng); - return new Polygon(latlngs, options); - - case 'GeometryCollection': - for (i = 0, len = geometry.geometries.length; i < len; i++) { - var layer = geometryToLayer({ - geometry: geometry.geometries[i], - type: 'Feature', - properties: geojson.properties - }, options); - - if (layer) { - layers.push(layer); - } - } - return new FeatureGroup(layers); - - default: - throw new Error('Invalid GeoJSON object.'); - } -} - -function _pointToLayer(pointToLayerFn, geojson, latlng, options) { - return pointToLayerFn ? - pointToLayerFn(geojson, latlng) : - new Marker(latlng, options && options.markersInheritOptions && options); -} - -// @function coordsToLatLng(coords: Array): LatLng -// Creates a `LatLng` object from an array of 2 numbers (longitude, latitude) -// or 3 numbers (longitude, latitude, altitude) used in GeoJSON for points. -function coordsToLatLng(coords) { - return new LatLng(coords[1], coords[0], coords[2]); -} - -// @function coordsToLatLngs(coords: Array, levelsDeep?: Number, coordsToLatLng?: Function): Array -// Creates a multidimensional array of `LatLng`s from a GeoJSON coordinates array. -// `levelsDeep` specifies the nesting level (0 is for an array of points, 1 for an array of arrays of points, etc., 0 by default). -// Can use a custom [`coordsToLatLng`](#geojson-coordstolatlng) function. -function coordsToLatLngs(coords, levelsDeep, _coordsToLatLng) { - var latlngs = []; - - for (var i = 0, len = coords.length, latlng; i < len; i++) { - latlng = levelsDeep ? - coordsToLatLngs(coords[i], levelsDeep - 1, _coordsToLatLng) : - (_coordsToLatLng || coordsToLatLng)(coords[i]); - - latlngs.push(latlng); - } - - return latlngs; -} - -// @function latLngToCoords(latlng: LatLng, precision?: Number): Array -// Reverse of [`coordsToLatLng`](#geojson-coordstolatlng) -function latLngToCoords(latlng, precision) { - precision = typeof precision === 'number' ? precision : 6; - return latlng.alt !== undefined ? - [formatNum(latlng.lng, precision), formatNum(latlng.lat, precision), formatNum(latlng.alt, precision)] : - [formatNum(latlng.lng, precision), formatNum(latlng.lat, precision)]; -} - -// @function latLngsToCoords(latlngs: Array, levelsDeep?: Number, closed?: Boolean): Array -// Reverse of [`coordsToLatLngs`](#geojson-coordstolatlngs) -// `closed` determines whether the first point should be appended to the end of the array to close the feature, only used when `levelsDeep` is 0. False by default. -function latLngsToCoords(latlngs, levelsDeep, closed, precision) { - var coords = []; - - for (var i = 0, len = latlngs.length; i < len; i++) { - coords.push(levelsDeep ? - latLngsToCoords(latlngs[i], levelsDeep - 1, closed, precision) : - latLngToCoords(latlngs[i], precision)); - } - - if (!levelsDeep && closed) { - coords.push(coords[0]); - } - - return coords; -} - -function getFeature(layer, newGeometry) { - return layer.feature ? - extend({}, layer.feature, {geometry: newGeometry}) : - asFeature(newGeometry); -} - -// @function asFeature(geojson: Object): Object -// Normalize GeoJSON geometries/features into GeoJSON features. -function asFeature(geojson) { - if (geojson.type === 'Feature' || geojson.type === 'FeatureCollection') { - return geojson; - } - - return { - type: 'Feature', - properties: {}, - geometry: geojson - }; -} - -var PointToGeoJSON = { - toGeoJSON: function (precision) { - return getFeature(this, { - type: 'Point', - coordinates: latLngToCoords(this.getLatLng(), precision) - }); - } -}; - -// @namespace Marker -// @section Other methods -// @method toGeoJSON(precision?: Number): Object -// `precision` is the number of decimal places for coordinates. -// The default value is 6 places. -// Returns a [`GeoJSON`](http://en.wikipedia.org/wiki/GeoJSON) representation of the marker (as a GeoJSON `Point` Feature). -Marker.include(PointToGeoJSON); - -// @namespace CircleMarker -// @method toGeoJSON(precision?: Number): Object -// `precision` is the number of decimal places for coordinates. -// The default value is 6 places. -// Returns a [`GeoJSON`](http://en.wikipedia.org/wiki/GeoJSON) representation of the circle marker (as a GeoJSON `Point` Feature). -Circle.include(PointToGeoJSON); -CircleMarker.include(PointToGeoJSON); - - -// @namespace Polyline -// @method toGeoJSON(precision?: Number): Object -// `precision` is the number of decimal places for coordinates. -// The default value is 6 places. -// Returns a [`GeoJSON`](http://en.wikipedia.org/wiki/GeoJSON) representation of the polyline (as a GeoJSON `LineString` or `MultiLineString` Feature). -Polyline.include({ - toGeoJSON: function (precision) { - var multi = !isFlat(this._latlngs); - - var coords = latLngsToCoords(this._latlngs, multi ? 1 : 0, false, precision); - - return getFeature(this, { - type: (multi ? 'Multi' : '') + 'LineString', - coordinates: coords - }); - } -}); - -// @namespace Polygon -// @method toGeoJSON(precision?: Number): Object -// `precision` is the number of decimal places for coordinates. -// The default value is 6 places. -// Returns a [`GeoJSON`](http://en.wikipedia.org/wiki/GeoJSON) representation of the polygon (as a GeoJSON `Polygon` or `MultiPolygon` Feature). -Polygon.include({ - toGeoJSON: function (precision) { - var holes = !isFlat(this._latlngs), - multi = holes && !isFlat(this._latlngs[0]); - - var coords = latLngsToCoords(this._latlngs, multi ? 2 : holes ? 1 : 0, true, precision); - - if (!holes) { - coords = [coords]; - } - - return getFeature(this, { - type: (multi ? 'Multi' : '') + 'Polygon', - coordinates: coords - }); - } -}); - - -// @namespace LayerGroup -LayerGroup.include({ - toMultiPoint: function (precision) { - var coords = []; - - this.eachLayer(function (layer) { - coords.push(layer.toGeoJSON(precision).geometry.coordinates); - }); - - return getFeature(this, { - type: 'MultiPoint', - coordinates: coords - }); - }, - - // @method toGeoJSON(precision?: Number): Object - // `precision` is the number of decimal places for coordinates. - // The default value is 6 places. - // Returns a [`GeoJSON`](http://en.wikipedia.org/wiki/GeoJSON) representation of the layer group (as a GeoJSON `FeatureCollection`, `GeometryCollection`, or `MultiPoint`). - toGeoJSON: function (precision) { - - var type = this.feature && this.feature.geometry && this.feature.geometry.type; - - if (type === 'MultiPoint') { - return this.toMultiPoint(precision); - } - - var isGeometryCollection = type === 'GeometryCollection', - jsons = []; - - this.eachLayer(function (layer) { - if (layer.toGeoJSON) { - var json = layer.toGeoJSON(precision); - if (isGeometryCollection) { - jsons.push(json.geometry); - } else { - var feature = asFeature(json); - // Squash nested feature collections - if (feature.type === 'FeatureCollection') { - jsons.push.apply(jsons, feature.features); - } else { - jsons.push(feature); - } - } - } - }); - - if (isGeometryCollection) { - return getFeature(this, { - geometries: jsons, - type: 'GeometryCollection' - }); - } - - return { - type: 'FeatureCollection', - features: jsons - }; - } -}); - -// @namespace GeoJSON -// @factory L.geoJSON(geojson?: Object, options?: GeoJSON options) -// Creates a GeoJSON layer. Optionally accepts an object in -// [GeoJSON format](https://tools.ietf.org/html/rfc7946) to display on the map -// (you can alternatively add it later with `addData` method) and an `options` object. -function geoJSON(geojson, options) { - return new GeoJSON(geojson, options); -} - -// Backward compatibility. +/* + * @class GeoJSON + * @aka L.GeoJSON + * @inherits FeatureGroup + * + * Represents a GeoJSON object or an array of GeoJSON objects. Allows you to parse + * GeoJSON data and display it on the map. Extends `FeatureGroup`. + * + * @example + * + * ```js + * L.geoJSON(data, { + * style: function (feature) { + * return {color: feature.properties.color}; + * } + * }).bindPopup(function (layer) { + * return layer.feature.properties.description; + * }).addTo(map); + * ``` + */ + +var GeoJSON = FeatureGroup.extend({ + + /* @section + * @aka GeoJSON options + * + * @option pointToLayer: Function = * + * A `Function` defining how GeoJSON points spawn Leaflet layers. It is internally + * called when data is added, passing the GeoJSON point feature and its `LatLng`. + * The default is to spawn a default `Marker`: + * ```js + * function(geoJsonPoint, latlng) { + * return L.marker(latlng); + * } + * ``` + * + * @option style: Function = * + * A `Function` defining the `Path options` for styling GeoJSON lines and polygons, + * called internally when data is added. + * The default value is to not override any defaults: + * ```js + * function (geoJsonFeature) { + * return {} + * } + * ``` + * + * @option onEachFeature: Function = * + * A `Function` that will be called once for each created `Feature`, after it has + * been created and styled. Useful for attaching events and popups to features. + * The default is to do nothing with the newly created layers: + * ```js + * function (feature, layer) {} + * ``` + * + * @option filter: Function = * + * A `Function` that will be used to decide whether to include a feature or not. + * The default is to include all features: + * ```js + * function (geoJsonFeature) { + * return true; + * } + * ``` + * Note: dynamically changing the `filter` option will have effect only on newly + * added data. It will _not_ re-evaluate already included features. + * + * @option coordsToLatLng: Function = * + * A `Function` that will be used for converting GeoJSON coordinates to `LatLng`s. + * The default is the `coordsToLatLng` static method. + * + * @option markersInheritOptions: Boolean = false + * Whether default Markers for "Point" type Features inherit from group options. + */ + + initialize: function (geojson, options) { + setOptions(this, options); + + this._layers = {}; + + if (geojson) { + this.addData(geojson); + } + }, + + // @method addData( data ): this + // Adds a GeoJSON object to the layer. + addData: function (geojson) { + var features = isArray(geojson) ? geojson : geojson.features, + i, len, feature; + + if (features) { + for (i = 0, len = features.length; i < len; i++) { + // only add this if geometry or geometries are set and not null + feature = features[i]; + if (feature.geometries || feature.geometry || feature.features || feature.coordinates) { + this.addData(feature); + } + } + return this; + } + + var options = this.options; + + if (options.filter && !options.filter(geojson)) { return this; } + + var layer = geometryToLayer(geojson, options); + if (!layer) { + return this; + } + layer.feature = asFeature(geojson); + + layer.defaultOptions = layer.options; + this.resetStyle(layer); + + if (options.onEachFeature) { + options.onEachFeature(geojson, layer); + } + + return this.addLayer(layer); + }, + + // @method resetStyle( layer? ): this + // Resets the given vector layer's style to the original GeoJSON style, useful for resetting style after hover events. + // If `layer` is omitted, the style of all features in the current layer is reset. + resetStyle: function (layer) { + if (layer === undefined) { + return this.eachLayer(this.resetStyle, this); + } + // reset any custom styles + layer.options = extend({}, layer.defaultOptions); + this._setLayerStyle(layer, this.options.style); + return this; + }, + + // @method setStyle( style ): this + // Changes styles of GeoJSON vector layers with the given style function. + setStyle: function (style) { + return this.eachLayer(function (layer) { + this._setLayerStyle(layer, style); + }, this); + }, + + _setLayerStyle: function (layer, style) { + if (layer.setStyle) { + if (typeof style === 'function') { + style = style(layer.feature); + } + layer.setStyle(style); + } + } +}); + +// @section +// There are several static functions which can be called without instantiating L.GeoJSON: + +// @function geometryToLayer(featureData: Object, options?: GeoJSON options): Layer +// Creates a `Layer` from a given GeoJSON feature. Can use a custom +// [`pointToLayer`](#geojson-pointtolayer) and/or [`coordsToLatLng`](#geojson-coordstolatlng) +// functions if provided as options. +function geometryToLayer(geojson, options) { + + var geometry = geojson.type === 'Feature' ? geojson.geometry : geojson, + coords = geometry ? geometry.coordinates : null, + layers = [], + pointToLayer = options && options.pointToLayer, + _coordsToLatLng = options && options.coordsToLatLng || coordsToLatLng, + latlng, latlngs, i, len; + + if (!coords && !geometry) { + return null; + } + + switch (geometry.type) { + case 'Point': + latlng = _coordsToLatLng(coords); + return _pointToLayer(pointToLayer, geojson, latlng, options); + + case 'MultiPoint': + for (i = 0, len = coords.length; i < len; i++) { + latlng = _coordsToLatLng(coords[i]); + layers.push(_pointToLayer(pointToLayer, geojson, latlng, options)); + } + return new FeatureGroup(layers); + + case 'LineString': + case 'MultiLineString': + latlngs = coordsToLatLngs(coords, geometry.type === 'LineString' ? 0 : 1, _coordsToLatLng); + return new Polyline(latlngs, options); + + case 'Polygon': + case 'MultiPolygon': + latlngs = coordsToLatLngs(coords, geometry.type === 'Polygon' ? 1 : 2, _coordsToLatLng); + return new Polygon(latlngs, options); + + case 'GeometryCollection': + for (i = 0, len = geometry.geometries.length; i < len; i++) { + var layer = geometryToLayer({ + geometry: geometry.geometries[i], + type: 'Feature', + properties: geojson.properties + }, options); + + if (layer) { + layers.push(layer); + } + } + return new FeatureGroup(layers); + + default: + throw new Error('Invalid GeoJSON object.'); + } +} + +function _pointToLayer(pointToLayerFn, geojson, latlng, options) { + return pointToLayerFn ? + pointToLayerFn(geojson, latlng) : + new Marker(latlng, options && options.markersInheritOptions && options); +} + +// @function coordsToLatLng(coords: Array): LatLng +// Creates a `LatLng` object from an array of 2 numbers (longitude, latitude) +// or 3 numbers (longitude, latitude, altitude) used in GeoJSON for points. +function coordsToLatLng(coords) { + return new LatLng(coords[1], coords[0], coords[2]); +} + +// @function coordsToLatLngs(coords: Array, levelsDeep?: Number, coordsToLatLng?: Function): Array +// Creates a multidimensional array of `LatLng`s from a GeoJSON coordinates array. +// `levelsDeep` specifies the nesting level (0 is for an array of points, 1 for an array of arrays of points, etc., 0 by default). +// Can use a custom [`coordsToLatLng`](#geojson-coordstolatlng) function. +function coordsToLatLngs(coords, levelsDeep, _coordsToLatLng) { + var latlngs = []; + + for (var i = 0, len = coords.length, latlng; i < len; i++) { + latlng = levelsDeep ? + coordsToLatLngs(coords[i], levelsDeep - 1, _coordsToLatLng) : + (_coordsToLatLng || coordsToLatLng)(coords[i]); + + latlngs.push(latlng); + } + + return latlngs; +} + +// @function latLngToCoords(latlng: LatLng, precision?: Number): Array +// Reverse of [`coordsToLatLng`](#geojson-coordstolatlng) +function latLngToCoords(latlng, precision) { + precision = typeof precision === 'number' ? precision : 6; + return latlng.alt !== undefined ? + [formatNum(latlng.lng, precision), formatNum(latlng.lat, precision), formatNum(latlng.alt, precision)] : + [formatNum(latlng.lng, precision), formatNum(latlng.lat, precision)]; +} + +// @function latLngsToCoords(latlngs: Array, levelsDeep?: Number, closed?: Boolean): Array +// Reverse of [`coordsToLatLngs`](#geojson-coordstolatlngs) +// `closed` determines whether the first point should be appended to the end of the array to close the feature, only used when `levelsDeep` is 0. False by default. +function latLngsToCoords(latlngs, levelsDeep, closed, precision) { + var coords = []; + + for (var i = 0, len = latlngs.length; i < len; i++) { + coords.push(levelsDeep ? + latLngsToCoords(latlngs[i], levelsDeep - 1, closed, precision) : + latLngToCoords(latlngs[i], precision)); + } + + if (!levelsDeep && closed) { + coords.push(coords[0]); + } + + return coords; +} + +function getFeature(layer, newGeometry) { + return layer.feature ? + extend({}, layer.feature, {geometry: newGeometry}) : + asFeature(newGeometry); +} + +// @function asFeature(geojson: Object): Object +// Normalize GeoJSON geometries/features into GeoJSON features. +function asFeature(geojson) { + if (geojson.type === 'Feature' || geojson.type === 'FeatureCollection') { + return geojson; + } + + return { + type: 'Feature', + properties: {}, + geometry: geojson + }; +} + +var PointToGeoJSON = { + toGeoJSON: function (precision) { + return getFeature(this, { + type: 'Point', + coordinates: latLngToCoords(this.getLatLng(), precision) + }); + } +}; + +// @namespace Marker +// @section Other methods +// @method toGeoJSON(precision?: Number): Object +// `precision` is the number of decimal places for coordinates. +// The default value is 6 places. +// Returns a [`GeoJSON`](http://en.wikipedia.org/wiki/GeoJSON) representation of the marker (as a GeoJSON `Point` Feature). +Marker.include(PointToGeoJSON); + +// @namespace CircleMarker +// @method toGeoJSON(precision?: Number): Object +// `precision` is the number of decimal places for coordinates. +// The default value is 6 places. +// Returns a [`GeoJSON`](http://en.wikipedia.org/wiki/GeoJSON) representation of the circle marker (as a GeoJSON `Point` Feature). +Circle.include(PointToGeoJSON); +CircleMarker.include(PointToGeoJSON); + + +// @namespace Polyline +// @method toGeoJSON(precision?: Number): Object +// `precision` is the number of decimal places for coordinates. +// The default value is 6 places. +// Returns a [`GeoJSON`](http://en.wikipedia.org/wiki/GeoJSON) representation of the polyline (as a GeoJSON `LineString` or `MultiLineString` Feature). +Polyline.include({ + toGeoJSON: function (precision) { + var multi = !isFlat(this._latlngs); + + var coords = latLngsToCoords(this._latlngs, multi ? 1 : 0, false, precision); + + return getFeature(this, { + type: (multi ? 'Multi' : '') + 'LineString', + coordinates: coords + }); + } +}); + +// @namespace Polygon +// @method toGeoJSON(precision?: Number): Object +// `precision` is the number of decimal places for coordinates. +// The default value is 6 places. +// Returns a [`GeoJSON`](http://en.wikipedia.org/wiki/GeoJSON) representation of the polygon (as a GeoJSON `Polygon` or `MultiPolygon` Feature). +Polygon.include({ + toGeoJSON: function (precision) { + var holes = !isFlat(this._latlngs), + multi = holes && !isFlat(this._latlngs[0]); + + var coords = latLngsToCoords(this._latlngs, multi ? 2 : holes ? 1 : 0, true, precision); + + if (!holes) { + coords = [coords]; + } + + return getFeature(this, { + type: (multi ? 'Multi' : '') + 'Polygon', + coordinates: coords + }); + } +}); + + +// @namespace LayerGroup +LayerGroup.include({ + toMultiPoint: function (precision) { + var coords = []; + + this.eachLayer(function (layer) { + coords.push(layer.toGeoJSON(precision).geometry.coordinates); + }); + + return getFeature(this, { + type: 'MultiPoint', + coordinates: coords + }); + }, + + // @method toGeoJSON(precision?: Number): Object + // `precision` is the number of decimal places for coordinates. + // The default value is 6 places. + // Returns a [`GeoJSON`](http://en.wikipedia.org/wiki/GeoJSON) representation of the layer group (as a GeoJSON `FeatureCollection`, `GeometryCollection`, or `MultiPoint`). + toGeoJSON: function (precision) { + + var type = this.feature && this.feature.geometry && this.feature.geometry.type; + + if (type === 'MultiPoint') { + return this.toMultiPoint(precision); + } + + var isGeometryCollection = type === 'GeometryCollection', + jsons = []; + + this.eachLayer(function (layer) { + if (layer.toGeoJSON) { + var json = layer.toGeoJSON(precision); + if (isGeometryCollection) { + jsons.push(json.geometry); + } else { + var feature = asFeature(json); + // Squash nested feature collections + if (feature.type === 'FeatureCollection') { + jsons.push.apply(jsons, feature.features); + } else { + jsons.push(feature); + } + } + } + }); + + if (isGeometryCollection) { + return getFeature(this, { + geometries: jsons, + type: 'GeometryCollection' + }); + } + + return { + type: 'FeatureCollection', + features: jsons + }; + } +}); + +// @namespace GeoJSON +// @factory L.geoJSON(geojson?: Object, options?: GeoJSON options) +// Creates a GeoJSON layer. Optionally accepts an object in +// [GeoJSON format](https://tools.ietf.org/html/rfc7946) to display on the map +// (you can alternatively add it later with `addData` method) and an `options` object. +function geoJSON(geojson, options) { + return new GeoJSON(geojson, options); +} + +// Backward compatibility. var geoJson = geoJSON; -/* - * @class ImageOverlay - * @aka L.ImageOverlay - * @inherits Interactive layer - * - * Used to load and display a single image over specific bounds of the map. Extends `Layer`. - * - * @example - * - * ```js - * var imageUrl = 'http://www.lib.utexas.edu/maps/historical/newark_nj_1922.jpg', - * imageBounds = [[40.712216, -74.22655], [40.773941, -74.12544]]; - * L.imageOverlay(imageUrl, imageBounds).addTo(map); - * ``` - */ - -var ImageOverlay = Layer.extend({ - - // @section - // @aka ImageOverlay options - options: { - // @option opacity: Number = 1.0 - // The opacity of the image overlay. - opacity: 1, - - // @option alt: String = '' - // Text for the `alt` attribute of the image (useful for accessibility). - alt: '', - - // @option interactive: Boolean = false - // If `true`, the image overlay will emit [mouse events](#interactive-layer) when clicked or hovered. - interactive: false, - - // @option crossOrigin: Boolean|String = false - // Whether the crossOrigin attribute will be added to the image. - // If a String is provided, the image will have its crossOrigin attribute set to the String provided. This is needed if you want to access image pixel data. - // Refer to [CORS Settings](https://developer.mozilla.org/en-US/docs/Web/HTML/CORS_settings_attributes) for valid String values. - crossOrigin: false, - - // @option errorOverlayUrl: String = '' - // URL to the overlay image to show in place of the overlay that failed to load. - errorOverlayUrl: '', - - // @option zIndex: Number = 1 - // The explicit [zIndex](https://developer.mozilla.org/docs/Web/CSS/CSS_Positioning/Understanding_z_index) of the overlay layer. - zIndex: 1, - - // @option className: String = '' - // A custom class name to assign to the image. Empty by default. - className: '' - }, - - initialize: function (url, bounds, options) { // (String, LatLngBounds, Object) - this._url = url; - this._bounds = toLatLngBounds(bounds); - - setOptions(this, options); - }, - - onAdd: function () { - if (!this._image) { - this._initImage(); - - if (this.options.opacity < 1) { - this._updateOpacity(); - } - } - - if (this.options.interactive) { - addClass(this._image, 'leaflet-interactive'); - this.addInteractiveTarget(this._image); - } - - this.getPane().appendChild(this._image); - this._reset(); - }, - - onRemove: function () { - remove(this._image); - if (this.options.interactive) { - this.removeInteractiveTarget(this._image); - } - }, - - // @method setOpacity(opacity: Number): this - // Sets the opacity of the overlay. - setOpacity: function (opacity) { - this.options.opacity = opacity; - - if (this._image) { - this._updateOpacity(); - } - return this; - }, - - setStyle: function (styleOpts) { - if (styleOpts.opacity) { - this.setOpacity(styleOpts.opacity); - } - return this; - }, - - // @method bringToFront(): this - // Brings the layer to the top of all overlays. - bringToFront: function () { - if (this._map) { - toFront(this._image); - } - return this; - }, - - // @method bringToBack(): this - // Brings the layer to the bottom of all overlays. - bringToBack: function () { - if (this._map) { - toBack(this._image); - } - return this; - }, - - // @method setUrl(url: String): this - // Changes the URL of the image. - setUrl: function (url) { - this._url = url; - - if (this._image) { - this._image.src = url; - } - return this; - }, - - // @method setBounds(bounds: LatLngBounds): this - // Update the bounds that this ImageOverlay covers - setBounds: function (bounds) { - this._bounds = toLatLngBounds(bounds); - - if (this._map) { - this._reset(); - } - return this; - }, - - getEvents: function () { - var events = { - zoom: this._reset, - viewreset: this._reset - }; - - if (this._zoomAnimated) { - events.zoomanim = this._animateZoom; - } - - return events; - }, - - // @method setZIndex(value: Number): this - // Changes the [zIndex](#imageoverlay-zindex) of the image overlay. - setZIndex: function (value) { - this.options.zIndex = value; - this._updateZIndex(); - return this; - }, - - // @method getBounds(): LatLngBounds - // Get the bounds that this ImageOverlay covers - getBounds: function () { - return this._bounds; - }, - - // @method getElement(): HTMLElement - // Returns the instance of [`HTMLImageElement`](https://developer.mozilla.org/docs/Web/API/HTMLImageElement) - // used by this overlay. - getElement: function () { - return this._image; - }, - - _initImage: function () { - var wasElementSupplied = this._url.tagName === 'IMG'; - var img = this._image = wasElementSupplied ? this._url : create$1('img'); - - addClass(img, 'leaflet-image-layer'); - if (this._zoomAnimated) { addClass(img, 'leaflet-zoom-animated'); } - if (this.options.className) { addClass(img, this.options.className); } - - img.onselectstart = falseFn; - img.onmousemove = falseFn; - - // @event load: Event - // Fired when the ImageOverlay layer has loaded its image - img.onload = bind(this.fire, this, 'load'); - img.onerror = bind(this._overlayOnError, this, 'error'); - - if (this.options.crossOrigin || this.options.crossOrigin === '') { - img.crossOrigin = this.options.crossOrigin === true ? '' : this.options.crossOrigin; - } - - if (this.options.zIndex) { - this._updateZIndex(); - } - - if (wasElementSupplied) { - this._url = img.src; - return; - } - - img.src = this._url; - img.alt = this.options.alt; - }, - - _animateZoom: function (e) { - var scale = this._map.getZoomScale(e.zoom), - offset = this._map._latLngBoundsToNewLayerBounds(this._bounds, e.zoom, e.center).min; - - setTransform(this._image, offset, scale); - }, - - _reset: function () { - var image = this._image, - bounds = new Bounds( - this._map.latLngToLayerPoint(this._bounds.getNorthWest()), - this._map.latLngToLayerPoint(this._bounds.getSouthEast())), - size = bounds.getSize(); - - setPosition(image, bounds.min); - - image.style.width = size.x + 'px'; - image.style.height = size.y + 'px'; - }, - - _updateOpacity: function () { - setOpacity(this._image, this.options.opacity); - }, - - _updateZIndex: function () { - if (this._image && this.options.zIndex !== undefined && this.options.zIndex !== null) { - this._image.style.zIndex = this.options.zIndex; - } - }, - - _overlayOnError: function () { - // @event error: Event - // Fired when the ImageOverlay layer fails to load its image - this.fire('error'); - - var errorUrl = this.options.errorOverlayUrl; - if (errorUrl && this._url !== errorUrl) { - this._url = errorUrl; - this._image.src = errorUrl; - } - } -}); - -// @factory L.imageOverlay(imageUrl: String, bounds: LatLngBounds, options?: ImageOverlay options) -// Instantiates an image overlay object given the URL of the image and the -// geographical bounds it is tied to. -var imageOverlay = function (url, bounds, options) { - return new ImageOverlay(url, bounds, options); +/* + * @class ImageOverlay + * @aka L.ImageOverlay + * @inherits Interactive layer + * + * Used to load and display a single image over specific bounds of the map. Extends `Layer`. + * + * @example + * + * ```js + * var imageUrl = 'http://www.lib.utexas.edu/maps/historical/newark_nj_1922.jpg', + * imageBounds = [[40.712216, -74.22655], [40.773941, -74.12544]]; + * L.imageOverlay(imageUrl, imageBounds).addTo(map); + * ``` + */ + +var ImageOverlay = Layer.extend({ + + // @section + // @aka ImageOverlay options + options: { + // @option opacity: Number = 1.0 + // The opacity of the image overlay. + opacity: 1, + + // @option alt: String = '' + // Text for the `alt` attribute of the image (useful for accessibility). + alt: '', + + // @option interactive: Boolean = false + // If `true`, the image overlay will emit [mouse events](#interactive-layer) when clicked or hovered. + interactive: false, + + // @option crossOrigin: Boolean|String = false + // Whether the crossOrigin attribute will be added to the image. + // If a String is provided, the image will have its crossOrigin attribute set to the String provided. This is needed if you want to access image pixel data. + // Refer to [CORS Settings](https://developer.mozilla.org/en-US/docs/Web/HTML/CORS_settings_attributes) for valid String values. + crossOrigin: false, + + // @option errorOverlayUrl: String = '' + // URL to the overlay image to show in place of the overlay that failed to load. + errorOverlayUrl: '', + + // @option zIndex: Number = 1 + // The explicit [zIndex](https://developer.mozilla.org/docs/Web/CSS/CSS_Positioning/Understanding_z_index) of the overlay layer. + zIndex: 1, + + // @option className: String = '' + // A custom class name to assign to the image. Empty by default. + className: '' + }, + + initialize: function (url, bounds, options) { // (String, LatLngBounds, Object) + this._url = url; + this._bounds = toLatLngBounds(bounds); + + setOptions(this, options); + }, + + onAdd: function () { + if (!this._image) { + this._initImage(); + + if (this.options.opacity < 1) { + this._updateOpacity(); + } + } + + if (this.options.interactive) { + addClass(this._image, 'leaflet-interactive'); + this.addInteractiveTarget(this._image); + } + + this.getPane().appendChild(this._image); + this._reset(); + }, + + onRemove: function () { + remove(this._image); + if (this.options.interactive) { + this.removeInteractiveTarget(this._image); + } + }, + + // @method setOpacity(opacity: Number): this + // Sets the opacity of the overlay. + setOpacity: function (opacity) { + this.options.opacity = opacity; + + if (this._image) { + this._updateOpacity(); + } + return this; + }, + + setStyle: function (styleOpts) { + if (styleOpts.opacity) { + this.setOpacity(styleOpts.opacity); + } + return this; + }, + + // @method bringToFront(): this + // Brings the layer to the top of all overlays. + bringToFront: function () { + if (this._map) { + toFront(this._image); + } + return this; + }, + + // @method bringToBack(): this + // Brings the layer to the bottom of all overlays. + bringToBack: function () { + if (this._map) { + toBack(this._image); + } + return this; + }, + + // @method setUrl(url: String): this + // Changes the URL of the image. + setUrl: function (url) { + this._url = url; + + if (this._image) { + this._image.src = url; + } + return this; + }, + + // @method setBounds(bounds: LatLngBounds): this + // Update the bounds that this ImageOverlay covers + setBounds: function (bounds) { + this._bounds = toLatLngBounds(bounds); + + if (this._map) { + this._reset(); + } + return this; + }, + + getEvents: function () { + var events = { + zoom: this._reset, + viewreset: this._reset + }; + + if (this._zoomAnimated) { + events.zoomanim = this._animateZoom; + } + + return events; + }, + + // @method setZIndex(value: Number): this + // Changes the [zIndex](#imageoverlay-zindex) of the image overlay. + setZIndex: function (value) { + this.options.zIndex = value; + this._updateZIndex(); + return this; + }, + + // @method getBounds(): LatLngBounds + // Get the bounds that this ImageOverlay covers + getBounds: function () { + return this._bounds; + }, + + // @method getElement(): HTMLElement + // Returns the instance of [`HTMLImageElement`](https://developer.mozilla.org/docs/Web/API/HTMLImageElement) + // used by this overlay. + getElement: function () { + return this._image; + }, + + _initImage: function () { + var wasElementSupplied = this._url.tagName === 'IMG'; + var img = this._image = wasElementSupplied ? this._url : create$1('img'); + + addClass(img, 'leaflet-image-layer'); + if (this._zoomAnimated) { addClass(img, 'leaflet-zoom-animated'); } + if (this.options.className) { addClass(img, this.options.className); } + + img.onselectstart = falseFn; + img.onmousemove = falseFn; + + // @event load: Event + // Fired when the ImageOverlay layer has loaded its image + img.onload = bind(this.fire, this, 'load'); + img.onerror = bind(this._overlayOnError, this, 'error'); + + if (this.options.crossOrigin || this.options.crossOrigin === '') { + img.crossOrigin = this.options.crossOrigin === true ? '' : this.options.crossOrigin; + } + + if (this.options.zIndex) { + this._updateZIndex(); + } + + if (wasElementSupplied) { + this._url = img.src; + return; + } + + img.src = this._url; + img.alt = this.options.alt; + }, + + _animateZoom: function (e) { + var scale = this._map.getZoomScale(e.zoom), + offset = this._map._latLngBoundsToNewLayerBounds(this._bounds, e.zoom, e.center).min; + + setTransform(this._image, offset, scale); + }, + + _reset: function () { + var image = this._image, + bounds = new Bounds( + this._map.latLngToLayerPoint(this._bounds.getNorthWest()), + this._map.latLngToLayerPoint(this._bounds.getSouthEast())), + size = bounds.getSize(); + + setPosition(image, bounds.min); + + image.style.width = size.x + 'px'; + image.style.height = size.y + 'px'; + }, + + _updateOpacity: function () { + setOpacity(this._image, this.options.opacity); + }, + + _updateZIndex: function () { + if (this._image && this.options.zIndex !== undefined && this.options.zIndex !== null) { + this._image.style.zIndex = this.options.zIndex; + } + }, + + _overlayOnError: function () { + // @event error: Event + // Fired when the ImageOverlay layer fails to load its image + this.fire('error'); + + var errorUrl = this.options.errorOverlayUrl; + if (errorUrl && this._url !== errorUrl) { + this._url = errorUrl; + this._image.src = errorUrl; + } + } +}); + +// @factory L.imageOverlay(imageUrl: String, bounds: LatLngBounds, options?: ImageOverlay options) +// Instantiates an image overlay object given the URL of the image and the +// geographical bounds it is tied to. +var imageOverlay = function (url, bounds, options) { + return new ImageOverlay(url, bounds, options); }; -/* - * @class VideoOverlay - * @aka L.VideoOverlay - * @inherits ImageOverlay - * - * Used to load and display a video player over specific bounds of the map. Extends `ImageOverlay`. - * - * A video overlay uses the [`