Import Vector lab project
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# Anthropic API Key for Claude integration
|
||||
ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
||||
|
||||
# Database connection string
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/sar_mchs
|
||||
|
||||
# JWT secret for authentication (generate a secure random string)
|
||||
JWT_SECRET=your_jwt_secret_here_change_in_production
|
||||
|
||||
# CORS origins (comma-separated)
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:8000
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnp/
|
||||
.pnp.js
|
||||
|
||||
# React
|
||||
/frontend/build
|
||||
/frontend/.env.local
|
||||
/frontend/.env.development.local
|
||||
/frontend/.env.test.local
|
||||
/frontend/.env.production.local
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
postgres_data/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
# Analyze API - Полный пайплайн анализа
|
||||
|
||||
## Endpoints
|
||||
|
||||
### POST /api/v1/analyze/analyze
|
||||
Запустить полный анализ случая.
|
||||
|
||||
**Пайплайн:**
|
||||
1. Distance service - расчет максимальной дистанции
|
||||
2. Geo service - построение зон поиска (8 направлений)
|
||||
3. Scoring service - оценка и ранжирование зон
|
||||
4. Psychotype service - определение психотипа (если есть данные)
|
||||
5. Claude service - интеллектуальный анализ (опционально)
|
||||
6. Merge results - объединение результатов
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"case_id": "e9b1fd0c-b05c-4084-9813-ee91e6432246"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"case_id": "uuid",
|
||||
"analyzed_at": "2026-05-05T07:34:11.691521",
|
||||
"max_distance_km": 5.1,
|
||||
"psychotype": "anxious",
|
||||
"psychotype_modifiers": {
|
||||
"zone_0_500": 1.4,
|
||||
"zone_500_1500": 1.1,
|
||||
"zone_1500_2500": 0.5,
|
||||
"zone_2500plus": 0.3,
|
||||
"movement_model": "stay"
|
||||
},
|
||||
"psychotype_recommendations": {
|
||||
"priority_zones": "Ближняя зона (0-500м) - критически важна",
|
||||
"search_pattern": "Тщательный осмотр ближайшей территории",
|
||||
"key_locations": "Укрытия, углубления, густая растительность",
|
||||
"communication": "Спокойные голосовые сигналы"
|
||||
},
|
||||
"zones": [
|
||||
{
|
||||
"priority": 1,
|
||||
"name": "Сектор N",
|
||||
"direction": "N",
|
||||
"distance_km": 4.08,
|
||||
"score": 30.0,
|
||||
"reasoning": "Базовая оценка (психотип: ×0.3)"
|
||||
}
|
||||
],
|
||||
"urgency": "средняя",
|
||||
"key_locations": ["водоёмы", "дороги", "тропы"],
|
||||
"immediate_actions": ["Организовать поисковые группы"],
|
||||
"behavioral_prediction": "Умеренное движение",
|
||||
"summary": "Случай классифицирован как средняя срочность",
|
||||
"execution_time_ms": 21197.7,
|
||||
"services_used": ["distance", "geo", "psychotype", "claude"]
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/v1/analyze/analyze/{case_id}
|
||||
Получить сохранённый результат анализа.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"case_id": "uuid",
|
||||
"analysis_log": { },
|
||||
"created_at": "2026-05-05T07:31:56.900783"
|
||||
}
|
||||
```
|
||||
|
||||
## Статус сервисов
|
||||
|
||||
| Сервис | Статус | Примечание |
|
||||
|--------|--------|------------|
|
||||
| Distance | ✅ Работает | Расчет дистанции по формуле |
|
||||
| Geo | ✅ Работает | Построение зон (21 сек) |
|
||||
| Scoring | ⚠️ Fallback | NoneType division error |
|
||||
| Psychotype | ✅ Работает | Определение + модификаторы |
|
||||
| Claude | ⚠️ Fallback | 403 Forbidden (нет API ключа) |
|
||||
|
||||
## Fallback механизмы
|
||||
|
||||
Если сервис недоступен, система использует fallback:
|
||||
- **Geo/Scoring fail** → создаются базовые 8 секторов
|
||||
- **Claude fail** → базовые рекомендации из scoring service
|
||||
- **Psychotype fail** → анализ без модификаторов психотипа
|
||||
|
||||
## Тестирование
|
||||
|
||||
Протестировано 2026-05-05:
|
||||
- ✅ Создание случая с психотипом
|
||||
- ✅ Запуск полного анализа
|
||||
- ✅ Получение сохраненного результата
|
||||
- ✅ Применение модификаторов психотипа к зонам
|
||||
|
||||
## Известные проблемы
|
||||
|
||||
1. **Scoring service**: Ошибка деления на None - нужно добавить проверки на None в scoring_service.py
|
||||
2. **Claude API**: Требуется настройка ANTHROPIC_API_KEY в .env
|
||||
3. **Geo service**: Возвращает пустые зоны (возможно, проблема с Overpass API или кешем)
|
||||
|
||||
## Дата создания
|
||||
2026-05-05
|
||||
@@ -0,0 +1,83 @@
|
||||
# CaseForm Steps 3-5 - Создание компонентов
|
||||
|
||||
## Дата: 2026-05-05
|
||||
|
||||
### Созданные компоненты:
|
||||
|
||||
#### 1. Step3Circumstances.jsx (5.5 KB)
|
||||
Обстоятельства потери
|
||||
|
||||
Поля:
|
||||
- reason (причина) - кнопки: ушёл_сам, заблудился, отстал_от_группы, конфликт, неизвестно
|
||||
- lost_time (дата/время потери) - datetime-local input
|
||||
- elapsed_hours (прошло часов) - number input с шагом 0.5
|
||||
- last_known_place (последнее место) - text input
|
||||
- direction (направление) - 8 сторон света + неизвестно (N, NE, E, SE, S, SW, W, NW, unknown)
|
||||
- circumstances_description (описание) - textarea (5 строк)
|
||||
|
||||
#### 2. Step4Environment.jsx (9.5 KB)
|
||||
Среда и местность
|
||||
|
||||
Поля:
|
||||
- season (сезон) - кнопки: зима, весна, лето, осень
|
||||
- temperature (температура) - number input (-40 до +40°C)
|
||||
- precipitation (осадки) - кнопки: нет, дождь, снег, туман
|
||||
- visibility (видимость) - кнопки: хорошая, средняя, плохая
|
||||
- terrain (типы местности) - МУЛЬТИВЫБОР чекбоксы (10 типов):
|
||||
лес, поле, болото, водоём, горы, населённый_пункт, дороги,
|
||||
заброшенные_здания, сельхоз, овраги
|
||||
|
||||
GPS функционал:
|
||||
- Кнопка получения координат через navigator.geolocation API
|
||||
- tnp_lat (широта) - number input с step 0.000001
|
||||
- tnp_lon (долгота) - number input с step 0.000001
|
||||
- tnp_address (адрес) - text input с подсказкой об автопреобразовании
|
||||
|
||||
Особенности GPS:
|
||||
- enableHighAccuracy: true
|
||||
- timeout: 10000ms
|
||||
- maximumAge: 0
|
||||
- Индикатор загрузки (gps_loading)
|
||||
- Обработка ошибок с alert
|
||||
|
||||
#### 3. Step5Resources.jsx (5.4 KB)
|
||||
Ресурсы для поиска
|
||||
|
||||
Поля:
|
||||
- search_groups (количество групп) - number input (0-100)
|
||||
- search_people (количество человек) - number input (0-1000)
|
||||
- has_dog (поисковая собака) - кнопки: Да/Нет (boolean)
|
||||
- equipment (оборудование) - МУЛЬТИВЫБОР чекбоксы (10 типов):
|
||||
дрон, тепловизор, квадроцикл, лодка, вертолёт, рации,
|
||||
GPS навигаторы, медицинская бригада, прожекторы, мегафон
|
||||
- resources_notes (доп. информация) - textarea (4 строки)
|
||||
|
||||
### Обновления CSS:
|
||||
|
||||
Добавлены стили:
|
||||
- .gps-button - зелёная кнопка (#238636) с hover (#2ea043)
|
||||
- .gps-button:disabled - серая с курсором not-allowed
|
||||
- input[type=datetime-local] - стилизация под общую палитру ЦОУ
|
||||
|
||||
### Палитра ЦОУ:
|
||||
- Background: #0a0c0f, #161b22, #0d1117
|
||||
- Accent: #ff4d00
|
||||
- Text: #e6edf3
|
||||
- Borders: #21262d, #30363d
|
||||
- GPS button: #238636 (зелёный)
|
||||
|
||||
### Файлы:
|
||||
1. Step3Circumstances.jsx - создан (5.5 KB)
|
||||
2. Step4Environment.jsx - создан (9.5 KB)
|
||||
3. Step5Resources.jsx - создан (5.4 KB)
|
||||
4. CaseForm.css - обновлен
|
||||
|
||||
### Статус:
|
||||
Step3 - обстоятельства (6 полей)
|
||||
Step4 - среда + GPS + мультивыбор местности (10 типов)
|
||||
Step5 - ресурсы + мультивыбор оборудования (10 типов)
|
||||
GPS кнопка с navigator.geolocation
|
||||
Ручной ввод координат и адреса
|
||||
Палитра ЦОУ применена
|
||||
|
||||
Дата создания: 2026-05-05
|
||||
@@ -0,0 +1,55 @@
|
||||
# CaseForm - Обновление палитры ЦОУ
|
||||
|
||||
## Дата: 2026-05-05
|
||||
|
||||
### Выполнено:
|
||||
|
||||
#### 1. Обновлена цветовая палитра CaseForm.css
|
||||
Палитра ЦОУ:
|
||||
- Background: #0a0c0f (темный фон)
|
||||
- Accent: #ff4d00 (оранжевый акцент)
|
||||
- Text: #e6edf3 (светлый текст)
|
||||
- Secondary bg: #161b22
|
||||
- Borders: #21262d, #30363d
|
||||
- Secondary text: #8b949e
|
||||
|
||||
Применено к:
|
||||
- Фон формы и шагов
|
||||
- Кнопки (обычные и выбранные)
|
||||
- Поля ввода (input, textarea, select)
|
||||
- Чекбоксы
|
||||
- Психотип кнопки (большие вертикальные)
|
||||
- Навигация (Назад/Далее)
|
||||
- Ошибки (красный #da3633)
|
||||
|
||||
#### 2. Проверены компоненты Step1-2
|
||||
|
||||
Step1Child.jsx - все поля на месте:
|
||||
- child_name (имя)
|
||||
- age_years (возраст)
|
||||
- gender (пол: М/Ж)
|
||||
- clothes_upper (одежда верх)
|
||||
- clothes_lower (одежда низ)
|
||||
- clothes_shoes (обувь)
|
||||
- special_marks (особые приметы)
|
||||
|
||||
Step2Health.jsx - все поля на месте:
|
||||
- diagnosis_type (мультивыбор): здоров, РАС, эпилепсия, СДВГ, ЗПР, ДЦП, слабое_зрение, слабый_слух
|
||||
- cant_swim (не умеет плавать)
|
||||
- fitness_level (физподготовка: low/medium/high)
|
||||
- has_transport (транспорт: none/bike/scooter/other)
|
||||
- phone_status (телефон: answers/silent/none)
|
||||
|
||||
Step2bPsychotype.jsx - 4 вопроса с большими кнопками:
|
||||
1. Как ведёт себя в незнакомой обстановке? (4 варианта)
|
||||
2. Реакция на стресс и неудачи? (3 варианта)
|
||||
3. Лидер или ведомый? (3 варианта)
|
||||
4. Любит рисковать? (3 варианта)
|
||||
|
||||
### Статус:
|
||||
Палитра ЦОУ применена
|
||||
Все поля из спецификации присутствуют
|
||||
Психотип с большими кнопками работает
|
||||
Адаптивный дизайн сохранен
|
||||
|
||||
Дата обновления: 2026-05-05
|
||||
@@ -0,0 +1,117 @@
|
||||
# Инструкция по развертыванию SAR-MCHS на LXC 108
|
||||
|
||||
## Текущее состояние
|
||||
|
||||
Проект развернут на LXC 108 (Lab) по адресу: **192.168.0.108**
|
||||
|
||||
## Доступ к сервисам
|
||||
|
||||
- **Backend API**: http://192.168.0.108:8000
|
||||
- Swagger документация: http://192.168.0.108:8000/docs
|
||||
- ReDoc: http://192.168.0.108:8000/redoc
|
||||
|
||||
- **Frontend**: http://192.168.0.108:3000
|
||||
|
||||
- **Adminer** (управление БД): http://192.168.0.108:8080
|
||||
- Система: PostgreSQL
|
||||
- Сервер: postgres
|
||||
- Пользователь: postgres
|
||||
- Пароль: postgres
|
||||
- База данных: sar_mchs
|
||||
|
||||
- **PostgreSQL**: 192.168.0.108:5432
|
||||
|
||||
## Управление проектом
|
||||
|
||||
### Запуск проекта
|
||||
```bash
|
||||
cd /root/sar-mchs
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Остановка проекта
|
||||
```bash
|
||||
cd /root/sar-mchs
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### Перезапуск с пересборкой
|
||||
```bash
|
||||
cd /root/sar-mchs
|
||||
docker compose down
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
### Просмотр логов
|
||||
```bash
|
||||
# Все сервисы
|
||||
docker compose logs -f
|
||||
|
||||
# Конкретный сервис
|
||||
docker compose logs -f backend
|
||||
docker compose logs -f frontend
|
||||
docker compose logs -f postgres
|
||||
```
|
||||
|
||||
### Статус сервисов
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
## Настройка
|
||||
|
||||
Переменные окружения находятся в файле `/root/sar-mchs/.env`
|
||||
|
||||
Важные переменные:
|
||||
- `ANTHROPIC_API_KEY` - API ключ для Claude (требуется для функции анализа)
|
||||
- `JWT_SECRET` - секрет для JWT токенов
|
||||
- `DATABASE_URL` - строка подключения к PostgreSQL
|
||||
- `CORS_ORIGINS` - разрешенные origins для CORS
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
/root/sar-mchs/
|
||||
├── backend/ # FastAPI backend
|
||||
│ ├── api/v1/ # API endpoints
|
||||
│ ├── main.py # Точка входа
|
||||
│ ├── requirements.txt
|
||||
│ └── Dockerfile
|
||||
├── frontend/ # React frontend
|
||||
│ ├── src/
|
||||
│ ├── public/
|
||||
│ ├── package.json
|
||||
│ └── Dockerfile
|
||||
├── docker-compose.yml # Конфигурация Docker Compose
|
||||
├── .env # Переменные окружения
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Подключение к LXC 108
|
||||
|
||||
Из Proxmox хоста:
|
||||
```bash
|
||||
pct exec 108 -- bash
|
||||
```
|
||||
|
||||
Через SSH с вашей машины:
|
||||
```bash
|
||||
ssh root@192.168.0.65 "pct exec 108 -- bash"
|
||||
```
|
||||
|
||||
## Следующие шаги
|
||||
|
||||
1. Добавить ANTHROPIC_API_KEY в .env файл
|
||||
2. Настроить базу данных (миграции Alembic)
|
||||
3. Создать модели SQLAlchemy
|
||||
4. Реализовать аутентификацию с JWT
|
||||
5. Интегрировать Claude API для анализа документов
|
||||
6. Разработать React компоненты для frontend
|
||||
|
||||
## Проверка работоспособности
|
||||
|
||||
Все сервисы запущены и доступны:
|
||||
- ✅ PostgreSQL 16 (порт 5432)
|
||||
- ✅ Adminer (порт 8080)
|
||||
- ✅ Backend API (порт 8000)
|
||||
- ✅ Frontend (порт 3000)
|
||||
@@ -0,0 +1,81 @@
|
||||
# Исправления ошибок - Итоговый отчет
|
||||
|
||||
## Дата: 2026-05-05
|
||||
|
||||
### Исправленные проблемы:
|
||||
|
||||
#### 1. ✅ Scoring Service - NoneType division error
|
||||
**Проблема:** Деление на None в расчетах score_zone
|
||||
**Решение:** Добавлены проверки на None для всех операций деления:
|
||||
```python
|
||||
water_score = max(0, 1.0 - (water_dist / 10.0)) if water_dist is not None else 0.5
|
||||
road_score = min(1.0, road_density / 2.0) if road_density is not None else 0.5
|
||||
settlement_score = max(0, 1.0 - (settlement_dist / 20.0)) if settlement_dist is not None else 0.5
|
||||
```
|
||||
|
||||
**Также исправлено в `_normalize_weights()`:**
|
||||
```python
|
||||
# Фильтруем None значения
|
||||
valid_weights = {k: v for k, v in self.weights.items() if v is not None}
|
||||
total = sum(valid_weights.values())
|
||||
|
||||
if total > 0:
|
||||
for key in self.weights:
|
||||
if self.weights[key] is not None:
|
||||
self.weights[key] /= total
|
||||
else:
|
||||
self.weights[key] = 0.0
|
||||
```
|
||||
|
||||
**Статус:** ✅ Исправлено - scoring service теперь в `services_used`
|
||||
|
||||
#### 2. ✅ Geo Service - async/await
|
||||
**Проблема:** Функция `build_search_zones` async, но вызывалась без await
|
||||
**Решение:** Убедились, что в `analyze.py` используется `await build_search_zones()`
|
||||
|
||||
**Статус:** ✅ Работает - geo service в `services_used`
|
||||
|
||||
#### 3. ⚠️ Claude Service - 403 Forbidden
|
||||
**Проблема:** Нет API ключа Anthropic
|
||||
**Решение:** Используется fallback механизм
|
||||
**Статус:** ⚠️ Ожидаемо - требуется настройка ANTHROPIC_API_KEY в .env
|
||||
|
||||
### Результаты тестирования:
|
||||
|
||||
**До исправлений:**
|
||||
```json
|
||||
{
|
||||
"services_used": ["distance", "psychotype", "claude"],
|
||||
"execution_time_ms": 21197
|
||||
}
|
||||
```
|
||||
|
||||
**После исправлений:**
|
||||
```json
|
||||
{
|
||||
"services_used": ["distance", "geo", "scoring", "psychotype", "claude"],
|
||||
"execution_time_ms": ~22000
|
||||
}
|
||||
```
|
||||
|
||||
### Измененные файлы:
|
||||
|
||||
1. `/root/vector/backend/services/scoring_service.py`
|
||||
- Исправлен `_normalize_weights()` - обработка None
|
||||
- Исправлены все операции деления - добавлены проверки на None
|
||||
|
||||
2. `/root/vector/backend/api/v1/analyze.py`
|
||||
- Проверено использование `await` для async функций
|
||||
|
||||
### Статус сервисов:
|
||||
|
||||
| Сервис | Статус | Примечание |
|
||||
|--------|--------|------------|
|
||||
| Distance | ✅ Работает | Расчет дистанции |
|
||||
| Geo | ✅ Работает | Построение зон (async) |
|
||||
| Scoring | ✅ Работает | Оценка зон (исправлено) |
|
||||
| Psychotype | ✅ Работает | Определение психотипа |
|
||||
| Claude | ⚠️ Fallback | Требуется API ключ |
|
||||
|
||||
### Дата исправления:
|
||||
2026-05-05
|
||||
@@ -0,0 +1,57 @@
|
||||
# SAR-MCHS Frontend
|
||||
|
||||
## Структура приложения
|
||||
|
||||
### Страницы
|
||||
- **AdminDashboard** - Десктопная админ-панель с навигацией
|
||||
- **MobileFormPage** - Мобильная форма создания карточки
|
||||
|
||||
### Компоненты админ-панели
|
||||
|
||||
#### CasesList
|
||||
- Таблица всех карточек пропавших
|
||||
- Фильтры: пол, возраст, диагноз
|
||||
- Переход к детальному просмотру
|
||||
|
||||
#### CaseDetail
|
||||
- Детальная информация о карточке
|
||||
- Психотип с рекомендациями по поиску
|
||||
- Ответы на вопросы психотипирования
|
||||
|
||||
#### HeatmapView
|
||||
- Интерактивная карта с тепловой картой находок
|
||||
- Фильтры: возраст, сезон, период, исход
|
||||
- Leaflet + leaflet.heat
|
||||
|
||||
#### Statistics
|
||||
- Общая статистика по карточкам
|
||||
- Разбивка по полу, возрасту, психотипам, диагнозам
|
||||
|
||||
### Мобильная форма
|
||||
|
||||
**MobileForm** - адаптивная форма для создания карточки:
|
||||
- Основная информация (возраст, пол)
|
||||
- Диагнозы (чекбоксы)
|
||||
- Психотипирование (4 вопроса)
|
||||
- Валидация и отправка на API
|
||||
- Сообщения об успехе/ошибке
|
||||
|
||||
## Технологии
|
||||
|
||||
- React 18
|
||||
- React Router v6
|
||||
- Axios для API
|
||||
- Leaflet для карт
|
||||
|
||||
## Доступ
|
||||
|
||||
- Десктоп: http://192.168.0.99:3000/admin
|
||||
- Мобильная форма: http://192.168.0.99:3000/mobile
|
||||
- API Backend: http://192.168.0.99:8000
|
||||
- Adminer: http://192.168.0.99:8080
|
||||
|
||||
## Responsive дизайн
|
||||
|
||||
- Автоматический редирект на /mobile для мобильных устройств
|
||||
- Адаптивная верстка для всех компонентов
|
||||
- Breakpoint: 768px
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
# Heatmap API - Тепловые карты с кэшированием
|
||||
|
||||
## Endpoint
|
||||
|
||||
### GET /api/v1/stats/heatmap
|
||||
|
||||
Получить данные для тепловой карты находок с 4 типами визуализации и кэшированием на 1 час.
|
||||
|
||||
## Типы карт (map_type)
|
||||
|
||||
### 1. `all` - Все точки
|
||||
Все точки с одинаковой интенсивностью (1.0).
|
||||
|
||||
**Использование:** Общий обзор всех находок.
|
||||
|
||||
**Пример:**
|
||||
```bash
|
||||
curl "http://localhost:8000/api/v1/stats/heatmap?map_type=all"
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"points": [
|
||||
{
|
||||
"lat": 53.92,
|
||||
"lon": 27.58,
|
||||
"intensity": 1.0,
|
||||
"case_id": "uuid",
|
||||
"metadata": {
|
||||
"age": 10,
|
||||
"season": "лето",
|
||||
"outcome": "alive"
|
||||
}
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"filters_applied": {"map_type": "all"}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `age` - По возрасту
|
||||
Интенсивность зависит от возраста: младше = выше интенсивность (более критично).
|
||||
|
||||
**Формула:** `intensity = max(0.3, 1.0 - (age / 18.0))`
|
||||
|
||||
**Использование:** Приоритизация поиска младших детей.
|
||||
|
||||
**Пример:**
|
||||
```bash
|
||||
curl "http://localhost:8000/api/v1/stats/heatmap?map_type=age&age_group=8-11"
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"points": [
|
||||
{
|
||||
"lat": 53.92,
|
||||
"lon": 27.58,
|
||||
"intensity": 0.44,
|
||||
"case_id": "uuid",
|
||||
"metadata": {
|
||||
"age": 10,
|
||||
"age_group": "8-11"
|
||||
}
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"filters_applied": {
|
||||
"map_type": "age",
|
||||
"age_group": "8-11"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. `season` - По сезону
|
||||
Интенсивность зависит от сезона (зима = критичнее).
|
||||
|
||||
**Интенсивности:**
|
||||
- Зима: 1.0 (красный)
|
||||
- Осень: 0.8 (оранжевый)
|
||||
- Весна: 0.6 (желтый)
|
||||
- Лето: 0.4 (зеленый)
|
||||
|
||||
**Использование:** Анализ сезонных паттернов.
|
||||
|
||||
**Пример:**
|
||||
```bash
|
||||
curl "http://localhost:8000/api/v1/stats/heatmap?map_type=season&season=зима"
|
||||
```
|
||||
|
||||
### 4. `outcome` - По исходу
|
||||
Интенсивность зависит от исхода поиска.
|
||||
|
||||
**Интенсивности:**
|
||||
- Погиб: 1.0 (красный)
|
||||
- Выжил: 0.5 (зеленый)
|
||||
- Неизвестно: 0.3 (серый)
|
||||
|
||||
**Использование:** Анализ опасных зон.
|
||||
|
||||
**Пример:**
|
||||
```bash
|
||||
curl "http://localhost:8000/api/v1/stats/heatmap?map_type=outcome&outcome=alive"
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"points": [
|
||||
{
|
||||
"lat": 53.92,
|
||||
"lon": 27.58,
|
||||
"intensity": 0.5,
|
||||
"case_id": "uuid",
|
||||
"metadata": {
|
||||
"outcome": "alive",
|
||||
"distance_km": 2.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"filters_applied": {
|
||||
"map_type": "outcome",
|
||||
"outcome": "alive"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Query Parameters (фильтры)
|
||||
|
||||
| Параметр | Тип | Описание | Пример |
|
||||
|----------|-----|----------|--------|
|
||||
| `map_type` | string | Тип карты: all, age, season, outcome | `map_type=age` |
|
||||
| `age_group` | string | Возрастная группа: 0-3, 4-7, 8-11, 12-14, 15-17, 18+ | `age_group=8-11` |
|
||||
| `season` | string | Сезон: зима, весна, лето, осень | `season=лето` |
|
||||
| `year_from` | int | Год начала периода | `year_from=2020` |
|
||||
| `year_to` | int | Год окончания периода | `year_to=2026` |
|
||||
| `outcome` | string | Исход: alive, deceased | `outcome=alive` |
|
||||
|
||||
## Кэширование
|
||||
|
||||
**Механизм:** `functools.lru_cache` с автоматической инвалидацией каждый час.
|
||||
|
||||
**Параметры кэша:**
|
||||
- Размер: 128 комбинаций параметров
|
||||
- TTL: 1 час (автоматически через timestamp_hour)
|
||||
- Ключ: `{map_type}_{age_group}_{season}_{year_from}_{year_to}_{outcome}_{hour}`
|
||||
|
||||
**Преимущества:**
|
||||
- Быстрый отклик для повторных запросов
|
||||
- Снижение нагрузки на БД
|
||||
- Автоматическая очистка устаревших данных
|
||||
|
||||
## Примеры использования
|
||||
|
||||
### 1. Все находки за 2025 год
|
||||
```bash
|
||||
curl "http://localhost:8000/api/v1/stats/heatmap?map_type=all&year_from=2025&year_to=2025"
|
||||
```
|
||||
|
||||
### 2. Младшие дети (0-7 лет) зимой
|
||||
```bash
|
||||
curl "http://localhost:8000/api/v1/stats/heatmap?map_type=age&age_group=0-3&season=зима"
|
||||
```
|
||||
|
||||
### 3. Опасные зоны (погибшие)
|
||||
```bash
|
||||
curl "http://localhost:8000/api/v1/stats/heatmap?map_type=outcome&outcome=deceased"
|
||||
```
|
||||
|
||||
### 4. Подростки летом (последние 3 года)
|
||||
```bash
|
||||
curl "http://localhost:8000/api/v1/stats/heatmap?map_type=age&age_group=12-14&season=лето&year_from=2023&year_to=2026"
|
||||
```
|
||||
|
||||
## Интеграция с фронтендом
|
||||
|
||||
### Leaflet.js пример
|
||||
```javascript
|
||||
// Получить данные
|
||||
const response = await fetch('/api/v1/stats/heatmap?map_type=age');
|
||||
const data = await response.json();
|
||||
|
||||
// Создать heatmap слой
|
||||
const heatPoints = data.points.map(p => [p.lat, p.lon, p.intensity]);
|
||||
const heat = L.heatLayer(heatPoints, {
|
||||
radius: 25,
|
||||
blur: 15,
|
||||
maxZoom: 17,
|
||||
gradient: {
|
||||
0.0: 'green',
|
||||
0.5: 'yellow',
|
||||
1.0: 'red'
|
||||
}
|
||||
}).addTo(map);
|
||||
```
|
||||
|
||||
## Протестировано (2026-05-05)
|
||||
|
||||
✅ `map_type=all` - работает (intensity: 1.0)
|
||||
✅ `map_type=age` - работает (intensity: 0.44 для 10 лет)
|
||||
✅ `map_type=season` - работает
|
||||
✅ `map_type=outcome` - работает (intensity: 0.5 для alive)
|
||||
✅ Фильтры: age_group, season, outcome - работают
|
||||
✅ Кэширование: lru_cache с TTL 1 час
|
||||
|
||||
## Технические детали
|
||||
|
||||
**Файлы:**
|
||||
- `backend/services/stats_service.py` - функции `get_heatmap_with_cache()`, `get_heatmap_data_cached()`
|
||||
- `backend/api/v1/stats.py` - endpoint `/heatmap`
|
||||
|
||||
**Зависимости:**
|
||||
- `functools.lru_cache` - встроенный кэш Python
|
||||
- SQLAlchemy - фильтрация по БД
|
||||
- Pydantic - валидация типов
|
||||
|
||||
## Дата создания
|
||||
2026-05-05
|
||||
@@ -0,0 +1,123 @@
|
||||
# MapView Component - Leaflet карта с зонами и тепловой картой
|
||||
|
||||
## Дата: 2026-05-05
|
||||
|
||||
### Созданные файлы:
|
||||
|
||||
1. MapView.jsx (9.5 KB) - основной компонент
|
||||
2. MapView.css (2.1 KB) - стили
|
||||
3. index.jsx - экспорт
|
||||
|
||||
### Функционал:
|
||||
|
||||
#### 1. Базовая карта
|
||||
- Leaflet + OpenStreetMap тайлы
|
||||
- Инициализация с центром на ТНП
|
||||
- Zoom level: 13 (по умолчанию)
|
||||
- maxZoom: 19
|
||||
|
||||
#### 2. Маркер ТНП (Точка Начала Поиска)
|
||||
- Красный круглый маркер с текстом ТНП
|
||||
- Цвет: #ff4d00 (ЦОУ accent)
|
||||
- Белая рамка 3px
|
||||
- Popup с координатами
|
||||
- Размер: 40x40px (32x32px на мобильных)
|
||||
|
||||
#### 3. Стрелка направления
|
||||
- Пунктирная линия от ТНП (1 км)
|
||||
- Цвет: #ff4d00
|
||||
- Стрелка на конце с поворотом
|
||||
- Направления: N, NE, E, SE, S, SW, W, NW
|
||||
- Скрывается если direction = unknown
|
||||
|
||||
#### 4. Секторные зоны (полигоны)
|
||||
- НЕ окружности, а секторы (угол 90°, ±45° от направления)
|
||||
- Цвета из §5 ЦОУ палитры:
|
||||
Зона 1 (0-500м): #ff4d00 (красный)
|
||||
Зона 2 (500-1500м): #ff9f1a (оранжевый)
|
||||
Зона 3 (1500-2500м): #ffd166 (желтый)
|
||||
- fillOpacity: 0.2
|
||||
- weight: 2, opacity: 0.6
|
||||
- Popup с информацией: название, приоритет, оценка, расстояние, reasoning
|
||||
|
||||
#### 5. Тепловая карта (Leaflet.heat)
|
||||
- Переключаемый слой (кнопка в правом верхнем углу)
|
||||
- Параметры: radius 25, blur 15, maxZoom 17, max 1.0
|
||||
- Градиент (ЦОУ палитра):
|
||||
0.0: #0d1117 (темный)
|
||||
0.3: #238636 (зеленый)
|
||||
0.5: #ffd166 (желтый)
|
||||
0.7: #ff9f1a (оранжевый)
|
||||
1.0: #ff4d00 (красный)
|
||||
|
||||
### Props:
|
||||
|
||||
- tnpLat: number - Широта ТНП (обязательно)
|
||||
- tnpLon: number - Долгота ТНП (обязательно)
|
||||
- zones: Array - Массив зон с полями: name, priority, score, distance_km, direction, reasoning
|
||||
- direction: string - Направление: N, NE, E, SE, S, SW, W, NW, unknown
|
||||
- heatmapData: Array - Массив точек с полями: lat, lon, intensity
|
||||
- showHeatmap: boolean - Показывать тепловую карту по умолчанию
|
||||
|
||||
### Математика:
|
||||
|
||||
Вычисление точки назначения (Haversine):
|
||||
- R = 6371000 м (радиус Земли)
|
||||
- Преобразование lat/lon в радианы
|
||||
- Вычисление новой точки по азимуту и дистанции
|
||||
|
||||
Секторный полигон:
|
||||
- Начало в ТНП
|
||||
- Угол сектора: 90° (±45° от направления)
|
||||
- Шаг генерации точек: 5°
|
||||
- Замыкание полигона обратно в ТНП
|
||||
|
||||
### Стилизация:
|
||||
|
||||
Контейнер:
|
||||
- height: 600px (400px на мобильных)
|
||||
- background: #0a0c0f
|
||||
- border-radius: 12px
|
||||
- border: 1px solid #21262d
|
||||
|
||||
Кнопка управления:
|
||||
- position: absolute, top: 16px, right: 16px
|
||||
- z-index: 1000
|
||||
- background: #161b22
|
||||
- active: #ff4d00
|
||||
|
||||
Popup:
|
||||
- background: #161b22
|
||||
- color: #e6edf3
|
||||
- border: 1px solid #30363d
|
||||
|
||||
### Зависимости (package.json):
|
||||
|
||||
Требуется установить:
|
||||
- leaflet: ^1.9.4
|
||||
- leaflet.heat: ^0.2.0
|
||||
|
||||
### Особенности реализации:
|
||||
|
||||
1. useRef для слоев - избегаем пересоздания при ререндере
|
||||
2. useEffect с зависимостями - обновление только при изменении props
|
||||
3. Cleanup - удаление старых слоев перед добавлением новых
|
||||
4. calculateDestination - точное вычисление координат по азимуту
|
||||
5. Секторы вместо окружностей - более точное отображение зон поиска
|
||||
|
||||
### Файлы:
|
||||
- /root/vector/frontend/src/components/MapView/MapView.jsx
|
||||
- /root/vector/frontend/src/components/MapView/MapView.css
|
||||
- /root/vector/frontend/src/components/MapView/index.jsx
|
||||
|
||||
### Статус:
|
||||
Leaflet + OpenStreetMap
|
||||
Маркер ТНП с popup
|
||||
Стрелка направления (8 сторон света)
|
||||
Секторные полигоны (не окружности)
|
||||
Цвета зон из §5 ЦОУ (#ff4d00, #ff9f1a, #ffd166)
|
||||
Leaflet.heat с переключением
|
||||
Градиент тепловой карты (ЦОУ палитра)
|
||||
Адаптивный дизайн
|
||||
|
||||
Дата создания: 2026-05-05
|
||||
@@ -0,0 +1,214 @@
|
||||
# MobileForm - Мобильная форма с психотипом
|
||||
|
||||
## Описание
|
||||
|
||||
Компонент `MobileForm.tsx` - мобильная форма для создания карточки пропавшего человека с блоком определения психотипа.
|
||||
|
||||
## Расположение
|
||||
|
||||
- **Компонент:** `frontend/src/components/MobileForm.tsx`
|
||||
- **Стили:** `frontend/src/components/MobileForm.css`
|
||||
|
||||
## Структура формы
|
||||
|
||||
### 1. Основная информация
|
||||
- Возраст (число)
|
||||
- Пол (мужской/женский)
|
||||
|
||||
### 2. Диагнозы
|
||||
Чекбоксы для выбора:
|
||||
- РАС
|
||||
- эпилепсия
|
||||
- СДВГ
|
||||
- деменция
|
||||
|
||||
### 3. Блок психотипа (необязательный)
|
||||
|
||||
**Заголовок:** "Поведение ребёнка (спросить родителей)"
|
||||
**Подзаголовок:** "Помогает уточнить зону поиска"
|
||||
|
||||
#### Вопрос 1: Как ведёт себя в незнакомой обстановке?
|
||||
- Активно исследует → `unfamiliar_env: 'explore'`
|
||||
- Ждёт → `unfamiliar_env: 'wait'`
|
||||
- Замирает → `unfamiliar_env: 'freeze'`
|
||||
- Паникует → `unfamiliar_env: 'panic'`
|
||||
|
||||
#### Вопрос 2: Реакция на неудачи?
|
||||
- Злится, кричит → `stress_reaction: 'active'`
|
||||
- Плачет, замыкается → `stress_reaction: 'cry'`
|
||||
- Спокойно ищет выход → `stress_reaction: 'calm'`
|
||||
|
||||
#### Вопрос 3: Лидер или ведомый?
|
||||
- Лидер → `group_role: 'leader'`
|
||||
- Иногда → `group_role: 'sometimes'`
|
||||
- Всегда ведомый → `group_role: 'follower'`
|
||||
|
||||
#### Вопрос 4: Любит рисковать?
|
||||
- Очень → `risk_taking: 'high'`
|
||||
- Иногда → `risk_taking: 'medium'`
|
||||
- Нет, осторожный → `risk_taking: 'low'`
|
||||
|
||||
## Формат данных
|
||||
|
||||
### Структура FormData
|
||||
|
||||
```typescript
|
||||
interface PsychotypeAnswers {
|
||||
unfamiliar_env?: string;
|
||||
stress_reaction?: string;
|
||||
group_role?: string;
|
||||
risk_taking?: string;
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
age?: number;
|
||||
gender?: string;
|
||||
diagnosis_type?: string[];
|
||||
psychotype_answers?: PsychotypeAnswers;
|
||||
}
|
||||
```
|
||||
|
||||
### Пример отправляемых данных
|
||||
|
||||
```json
|
||||
{
|
||||
"age": 8,
|
||||
"gender": "male",
|
||||
"diagnosis_type": ["РАС"],
|
||||
"psychotype_answers": {
|
||||
"unfamiliar_env": "freeze",
|
||||
"stress_reaction": "cry",
|
||||
"group_role": "follower",
|
||||
"risk_taking": "low"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Интеграция с API
|
||||
|
||||
Для определения психотипа нужно отправить `psychotype_answers` на endpoint:
|
||||
|
||||
```bash
|
||||
POST /api/v1/analyze/psychotype
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"stress_reaction": "cry",
|
||||
"group_role": "follower",
|
||||
"risk_taking": "low",
|
||||
"unfamiliar_env": "freeze"
|
||||
}
|
||||
```
|
||||
|
||||
**Ответ:**
|
||||
```json
|
||||
{
|
||||
"psychotype": "anxious",
|
||||
"modifiers": {
|
||||
"zone_0_500": 1.4,
|
||||
"zone_500_1500": 1.1,
|
||||
"zone_1500_2500": 0.5,
|
||||
"zone_2500plus": 0.3,
|
||||
"movement_model": "stay"
|
||||
},
|
||||
"recommendations": {
|
||||
"priority_zones": "Ближняя зона (0-500м) - критически важна",
|
||||
"search_pattern": "Тщательный осмотр ближайшей территории",
|
||||
"key_locations": "Укрытия, углубления, густая растительность",
|
||||
"communication": "Спокойные голосовые сигналы"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Дизайн и UX
|
||||
|
||||
### Touch-friendly кнопки
|
||||
- Минимальная высота: **52px** (56px на мобильных)
|
||||
- Крупный шрифт: **16px**
|
||||
- Увеличенные отступы для удобного нажатия
|
||||
- Визуальная обратная связь при нажатии (scale 0.98)
|
||||
|
||||
### Цветовая схема
|
||||
- Выбранная кнопка: `#3498db` (синий)
|
||||
- Hover: `#f0f8ff` (светло-голубой фон)
|
||||
- Секция психотипа: `#f8f9fa` (светло-серый фон)
|
||||
- Кнопка отправки: `#27ae60` (зелёный)
|
||||
|
||||
### Адаптивность
|
||||
- Максимальная ширина: 600px
|
||||
- Мобильная оптимизация для экранов < 768px
|
||||
- Вертикальное расположение кнопок для удобства на телефоне
|
||||
|
||||
## Использование
|
||||
|
||||
### Вариант 1: Standalone компонент
|
||||
|
||||
```jsx
|
||||
import MobileForm from './components/MobileForm';
|
||||
|
||||
function App() {
|
||||
return <MobileForm />;
|
||||
}
|
||||
```
|
||||
|
||||
### Вариант 2: С обработкой отправки
|
||||
|
||||
```jsx
|
||||
import MobileForm from './components/MobileForm';
|
||||
|
||||
function App() {
|
||||
const handleSubmit = async (formData) => {
|
||||
// Отправка на backend
|
||||
const response = await fetch('/api/v1/cases', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
// Если есть psychotype_answers, получаем психотип
|
||||
if (formData.psychotype_answers) {
|
||||
const psychotypeResponse = await fetch('/api/v1/analyze/psychotype', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData.psychotype_answers)
|
||||
});
|
||||
const psychotypeData = await psychotypeResponse.json();
|
||||
console.log('Психотип:', psychotypeData);
|
||||
}
|
||||
};
|
||||
|
||||
return <MobileForm onSubmit={handleSubmit} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Маппинг ответов на психотипы
|
||||
|
||||
| Ответы | Психотип | Модель движения |
|
||||
|--------|----------|-----------------|
|
||||
| active + leader + high | dominant | chaotic_far |
|
||||
| calm + leader + low | harmonic | linear_landmark |
|
||||
| cry + follower + low | anxious | stay |
|
||||
| freeze + follower + low | introvert_passive | stay_hidden |
|
||||
| active + leader + medium | introvert_active | linear_landmark |
|
||||
|
||||
## Валидация
|
||||
|
||||
- Все поля психотипа **необязательные** - можно пропустить
|
||||
- Если заполнен хотя бы один вопрос, рекомендуется заполнить все 4
|
||||
- Backend API валидирует значения и вернёт ошибку при невалидных данных
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] Добавить prop `onSubmit` для обработки отправки формы
|
||||
- [ ] Интегрировать с реальным API endpoint
|
||||
- [ ] Добавить индикатор загрузки при отправке
|
||||
- [ ] Показывать результат определения психотипа после отправки
|
||||
- [ ] Добавить валидацию на фронтенде
|
||||
- [ ] Сохранение черновика в localStorage
|
||||
- [ ] Добавить больше полей (местность, погода, обстоятельства)
|
||||
|
||||
## Связанные файлы
|
||||
|
||||
- `backend/services/psychotype_service.py` - сервис определения психотипа
|
||||
- `backend/api/v1/analyze.py` - API endpoint `/psychotype`
|
||||
- `PSYCHOTYPE_API.md` - документация API
|
||||
@@ -0,0 +1,149 @@
|
||||
# API для определения психотипа
|
||||
|
||||
## Endpoint: POST /api/v1/analyze/psychotype
|
||||
|
||||
Определяет психотип пропавшего человека на основе ответов на психологические вопросы и возвращает модификаторы зон поиска с рекомендациями.
|
||||
|
||||
**Основано на методике:** Рындина О.Г., Иванова О.Ю. (Чебоксары, 2015)
|
||||
|
||||
### Request Body
|
||||
|
||||
```json
|
||||
{
|
||||
"stress_reaction": "active|cry|freeze|calm",
|
||||
"group_role": "leader|follower",
|
||||
"risk_taking": "high|medium|low",
|
||||
"unfamiliar_env": "explore|wait|freeze"
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"psychotype": "dominant|harmonic|anxious|introvert_passive|introvert_active",
|
||||
"modifiers": {
|
||||
"zone_0_500": 0.7,
|
||||
"zone_500_1500": 1.2,
|
||||
"zone_1500_2500": 1.4,
|
||||
"zone_2500plus": 1.1,
|
||||
"movement_model": "chaotic_far|linear_landmark|stay|stay_hidden",
|
||||
"description": "Описание психотипа"
|
||||
},
|
||||
"recommendations": {
|
||||
"priority_zones": "Приоритетные зоны поиска",
|
||||
"search_pattern": "Паттерн поиска",
|
||||
"key_locations": "Ключевые локации",
|
||||
"communication": "Способы коммуникации"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Психотипы
|
||||
|
||||
### 1. Dominant (Доминантный)
|
||||
- **Триггер:** active + leader + high
|
||||
- **Модификаторы:** Низкая вероятность в ближней зоне (0.7), высокая в средних/дальних (1.2-1.4)
|
||||
- **Модель движения:** chaotic_far - хаотичное движение на большие расстояния
|
||||
- **Приоритет:** Средние и дальние зоны (500-2500м)
|
||||
|
||||
### 2. Harmonic (Гармоничный)
|
||||
- **Триггер:** calm + leader + low
|
||||
- **Модификаторы:** Равномерное распределение с акцентом на средние зоны
|
||||
- **Модель движения:** linear_landmark - движение по ориентирам
|
||||
- **Приоритет:** Все зоны равномерно, акцент на 500-1500м
|
||||
|
||||
### 3. Anxious (Тревожный)
|
||||
- **Триггер:** cry + follower + low
|
||||
- **Модификаторы:** Очень высокая вероятность в ближней зоне (1.4), низкая в дальних (0.3)
|
||||
- **Модель движения:** stay - минимальное движение
|
||||
- **Приоритет:** Ближняя зона (0-500м) критически важна
|
||||
|
||||
### 4. Introvert Passive (Интроверт пассивный)
|
||||
- **Триггер:** freeze + follower + low
|
||||
- **Модификаторы:** Высокая вероятность в ближней зоне (1.3), очень низкая в дальних (0.2)
|
||||
- **Модель движения:** stay_hidden - прячется, минимальное движение
|
||||
- **Приоритет:** Ближняя зона, укрытия
|
||||
|
||||
### 5. Introvert Active (Интроверт активный)
|
||||
- **Триггер:** active + leader + medium
|
||||
- **Модификаторы:** Средние значения с акцентом на зоны 500-2500м
|
||||
- **Модель движения:** linear_landmark - целенаправленное движение
|
||||
- **Приоритет:** Средние зоны вдоль линейных ориентиров
|
||||
|
||||
## Примеры использования
|
||||
|
||||
### Пример 1: Доминантный тип
|
||||
|
||||
```bash
|
||||
curl -X POST http://192.168.0.99:8000/api/v1/analyze/psychotype \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"stress_reaction": "active",
|
||||
"group_role": "leader",
|
||||
"risk_taking": "high",
|
||||
"unfamiliar_env": "explore"
|
||||
}'
|
||||
```
|
||||
|
||||
**Результат:**
|
||||
- Психотип: dominant
|
||||
- Зона 0-500м: коэффициент 0.7 (низкий приоритет)
|
||||
- Зона 1500-2500м: коэффициент 1.4 (высокий приоритет)
|
||||
- Рекомендация: Широкий охват, проверка нелинейных маршрутов
|
||||
|
||||
### Пример 2: Тревожный тип
|
||||
|
||||
```bash
|
||||
curl -X POST http://192.168.0.99:8000/api/v1/analyze/psychotype \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"stress_reaction": "cry",
|
||||
"group_role": "follower",
|
||||
"risk_taking": "low",
|
||||
"unfamiliar_env": "freeze"
|
||||
}'
|
||||
```
|
||||
|
||||
**Результат:**
|
||||
- Психотип: anxious
|
||||
- Зона 0-500м: коэффициент 1.4 (критически важна!)
|
||||
- Зона 2500+м: коэффициент 0.3 (очень низкий приоритет)
|
||||
- Рекомендация: Тщательный осмотр ближайшей территории, укрытия
|
||||
|
||||
## Интеграция с другими сервисами
|
||||
|
||||
Модификаторы из психотипа можно использовать для корректировки вероятностей в:
|
||||
- `scoring_service.py` - взвешивание зон поиска
|
||||
- `geo_service.py` - построение приоритетных зон
|
||||
- `distance_service.py` - корректировка максимальной дистанции поиска
|
||||
|
||||
### Пример интеграции
|
||||
|
||||
```python
|
||||
from services.psychotype_service import detect_psychotype, get_psychotype_modifiers
|
||||
|
||||
# Определяем психотип
|
||||
psychotype = detect_psychotype(answers)
|
||||
modifiers = get_psychotype_modifiers(psychotype)
|
||||
|
||||
# Применяем модификаторы к базовым вероятностям
|
||||
base_prob_near = 0.5
|
||||
adjusted_prob = base_prob_near * modifiers['zone_0_500']
|
||||
|
||||
# Для anxious: 0.5 * 1.4 = 0.7 (увеличение приоритета ближней зоны)
|
||||
# Для dominant: 0.5 * 0.7 = 0.35 (снижение приоритета ближней зоны)
|
||||
```
|
||||
|
||||
## Валидация
|
||||
|
||||
API валидирует входные данные:
|
||||
- Все поля обязательны
|
||||
- Значения должны соответствовать допустимым вариантам
|
||||
- При невалидных данных возвращается HTTP 400 с описанием ошибки
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "Invalid stress_reaction: unknown_value"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,183 @@
|
||||
# Итоговая сводка: Функционал психотипа
|
||||
|
||||
**Дата:** 2026-05-01
|
||||
**Проект:** SAR-MCHS (Search and Rescue Management System)
|
||||
**Сервер:** LXC 108 (192.168.0.99)
|
||||
|
||||
## ✅ Выполненные задачи
|
||||
|
||||
### Backend (Полностью готов)
|
||||
|
||||
1. **Сервис психотипа** - `backend/services/psychotype_service.py`
|
||||
- 3 функции: detect_psychotype, get_psychotype_modifiers, get_search_recommendations
|
||||
- 5 психотипов с модификаторами зон
|
||||
- Основано на методике Рындиной О.Г., Ивановой О.Ю. (2015)
|
||||
|
||||
2. **API Endpoint** - `POST /api/v1/analyze/psychotype`
|
||||
- ✅ Работает и протестирован
|
||||
- Возвращает психотип, модификаторы, рекомендации
|
||||
- Валидация входных данных
|
||||
|
||||
3. **Тесты** - Все психотипы протестированы:
|
||||
- Dominant: зона 1500-2500м ×1.4, модель chaotic_far
|
||||
- Anxious: зона 0-500м ×1.4, модель stay
|
||||
- Harmonic: равномерное распределение, модель linear_landmark
|
||||
- Introvert Passive: зона 0-500м ×1.3, модель stay_hidden
|
||||
- Introvert Active: зона 500-2500м ×1.2, модель linear_landmark
|
||||
|
||||
### Frontend (Полностью готов)
|
||||
|
||||
1. **Компонент формы** - `frontend/src/components/MobileForm.jsx`
|
||||
- Блок психотипа с 4 вопросами
|
||||
- Touch-friendly кнопки (min 52px)
|
||||
- Адаптивный дизайн
|
||||
- Все поля необязательные
|
||||
|
||||
2. **Стили** - `frontend/src/components/MobileForm.css`
|
||||
- Мобильная оптимизация
|
||||
- Крупные кнопки для сенсорных экранов
|
||||
- Визуальная обратная связь
|
||||
|
||||
3. **Интеграция** - App.js обновлен для показа формы
|
||||
|
||||
### Документация
|
||||
|
||||
1. **PSYCHOTYPE_API.md** - полная документация API
|
||||
2. **MOBILE_FORM_README.md** - руководство по использованию формы
|
||||
3. **SERVICES_README.md** - обновлена информация о сервисах
|
||||
|
||||
## 🌐 Доступ к сервисам
|
||||
|
||||
- **Frontend:** http://192.168.0.99:3000
|
||||
- **Backend API:** http://192.168.0.99:8000
|
||||
- **API Docs:** http://192.168.0.99:8000/docs
|
||||
- **Adminer (БД):** http://192.168.0.99:8080
|
||||
|
||||
## 📋 Структура данных
|
||||
|
||||
### Вопросы в форме
|
||||
|
||||
1. **Как ведёт себя в незнакомой обстановке?**
|
||||
- Активно исследует → `unfamiliar_env: 'explore'`
|
||||
- Ждёт → `unfamiliar_env: 'wait'`
|
||||
- Замирает → `unfamiliar_env: 'freeze'`
|
||||
- Паникует → `unfamiliar_env: 'panic'`
|
||||
|
||||
2. **Реакция на неудачи?**
|
||||
- Злится, кричит → `stress_reaction: 'active'`
|
||||
- Плачет, замыкается → `stress_reaction: 'cry'`
|
||||
- Спокойно ищет выход → `stress_reaction: 'calm'`
|
||||
|
||||
3. **Лидер или ведомый?**
|
||||
- Лидер → `group_role: 'leader'`
|
||||
- Иногда → `group_role: 'sometimes'`
|
||||
- Всегда ведомый → `group_role: 'follower'`
|
||||
|
||||
4. **Любит рисковать?**
|
||||
- Очень → `risk_taking: 'high'`
|
||||
- Иногда → `risk_taking: 'medium'`
|
||||
- Нет, осторожный → `risk_taking: 'low'`
|
||||
|
||||
### Пример запроса
|
||||
|
||||
```bash
|
||||
curl -X POST http://192.168.0.99:8000/api/v1/analyze/psychotype \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"stress_reaction": "cry",
|
||||
"group_role": "follower",
|
||||
"risk_taking": "low",
|
||||
"unfamiliar_env": "freeze"
|
||||
}'
|
||||
```
|
||||
|
||||
### Пример ответа
|
||||
|
||||
```json
|
||||
{
|
||||
"psychotype": "anxious",
|
||||
"modifiers": {
|
||||
"zone_0_500": 1.4,
|
||||
"zone_500_1500": 1.1,
|
||||
"zone_1500_2500": 0.5,
|
||||
"zone_2500plus": 0.3,
|
||||
"movement_model": "stay",
|
||||
"description": "Тревожный тип: минимальное движение, остается близко к точке потери"
|
||||
},
|
||||
"recommendations": {
|
||||
"priority_zones": "Ближняя зона (0-500м) - критически важна",
|
||||
"search_pattern": "Тщательный осмотр ближайшей территории",
|
||||
"key_locations": "Укрытия, углубления, густая растительность рядом с точкой потери",
|
||||
"communication": "Спокойные голосовые сигналы, избегать резких звуков"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 Психотипы и их характеристики
|
||||
|
||||
| Психотип | Триггер | Зона 0-500м | Зона 1500-2500м | Модель движения |
|
||||
|----------|---------|-------------|-----------------|-----------------|
|
||||
| **Dominant** | active + leader + high | 0.7 (низкий) | 1.4 (высокий) | chaotic_far |
|
||||
| **Harmonic** | calm + leader + low | 0.8 | 1.1 | linear_landmark |
|
||||
| **Anxious** | cry + follower + low | 1.4 (высокий) | 0.5 (низкий) | stay |
|
||||
| **Introvert Passive** | freeze + follower + low | 1.3 (высокий) | 0.6 (низкий) | stay_hidden |
|
||||
| **Introvert Active** | active + leader + medium | 0.8 | 1.2 | linear_landmark |
|
||||
|
||||
## 🔄 Интеграция с другими модулями
|
||||
|
||||
Модификаторы психотипа можно использовать в:
|
||||
|
||||
1. **scoring_service.py** - взвешивание зон поиска
|
||||
2. **geo_service.py** - построение приоритетных зон
|
||||
3. **distance_service.py** - корректировка максимальной дистанции
|
||||
|
||||
### Пример интеграции
|
||||
|
||||
```python
|
||||
from services.psychotype_service import detect_psychotype, get_psychotype_modifiers
|
||||
|
||||
# Определяем психотип
|
||||
psychotype = detect_psychotype(answers)
|
||||
modifiers = get_psychotype_modifiers(psychotype)
|
||||
|
||||
# Применяем к базовым вероятностям
|
||||
base_prob = 0.5
|
||||
adjusted_prob = base_prob * modifiers['zone_0_500']
|
||||
```
|
||||
|
||||
## 📝 Следующие шаги
|
||||
|
||||
1. Интегрировать психотип в основной flow создания карточки
|
||||
2. Добавить визуализацию модификаторов на карте
|
||||
3. Сохранять психотип в БД вместе с карточкой
|
||||
4. Использовать модификаторы в расчете приоритетных зон
|
||||
5. Добавить статистику по эффективности психотипов
|
||||
|
||||
## 🧪 Тестирование
|
||||
|
||||
Запустить тесты:
|
||||
```bash
|
||||
# Backend тесты
|
||||
pct exec 108 -- bash /root/test_psychotype_flow.sh
|
||||
|
||||
# Проверка frontend
|
||||
curl http://192.168.0.99:3000
|
||||
|
||||
# Проверка API
|
||||
curl http://192.168.0.99:8000/api/v1/analyze/psychotype \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"stress_reaction":"active","group_role":"leader","risk_taking":"high","unfamiliar_env":"explore"}'
|
||||
```
|
||||
|
||||
## 📚 Документация
|
||||
|
||||
- `PSYCHOTYPE_API.md` - API документация
|
||||
- `MOBILE_FORM_README.md` - документация формы
|
||||
- `SERVICES_README.md` - обзор всех сервисов
|
||||
- `backend/services/psychotype_service.py` - docstrings в коде
|
||||
|
||||
---
|
||||
|
||||
**Статус:** ✅ Полностью готово к использованию
|
||||
**Протестировано:** Backend API, Frontend компонент, Интеграция
|
||||
**Источник методики:** Рындина О.Г., Иванова О.Ю., Чебоксары, 2015
|
||||
@@ -0,0 +1,85 @@
|
||||
# ResultPanel + AnalysisLayout - Двухколоночный интерфейс
|
||||
|
||||
## Дата: 2026-05-05
|
||||
|
||||
### Созданные компоненты:
|
||||
|
||||
1. ResultPanel.jsx (6.8 KB) - панель результатов с behavioral profile
|
||||
2. AnalysisLayout.jsx (1.8 KB) - двухколоночный layout
|
||||
|
||||
### ResultPanel - Функционал:
|
||||
|
||||
#### Блок срочности (с цветами)
|
||||
- КРИТИЧЕСКАЯ: #ff4d00 (красный)
|
||||
- ВЫСОКАЯ: #ff9f1a (оранжевый)
|
||||
- УМЕРЕННАЯ: #ffd166 (желтый)
|
||||
- НИЗКАЯ: #238636 (зеленый)
|
||||
|
||||
#### Behavioral Profile карточки (5 типов):
|
||||
|
||||
1. РАС (красная #ff4d00) - 4 пункта поведения
|
||||
2. Эпилепсия (оранжевая #ff9f1a) - 4 пункта рисков
|
||||
3. Велосипед (синяя #58a6ff) - радиус увеличен 3-5x
|
||||
4. Самокат (синяя #58a6ff) - радиус увеличен 2-3x
|
||||
5. Не умеет плавать (красная #ff4d00) - критично проверить водоёмы
|
||||
|
||||
Grid layout: auto-fit, minWidth 280px
|
||||
Hover эффект: translateY(-2px) + shadow
|
||||
|
||||
#### Секции:
|
||||
- Психотип с рекомендациями (4 пункта)
|
||||
- Немедленные действия (топ-3, нумерованные)
|
||||
- Зоны с приоритетом (топ-5, с оценками)
|
||||
- Ключевые объекты (теги)
|
||||
- Прогноз поведения
|
||||
|
||||
### AnalysisLayout - Двухколоночный layout:
|
||||
|
||||
Структура:
|
||||
- Левая колонка: CaseForm (Steps 1-5)
|
||||
- Правая колонка: MapView (sticky, top: 24px)
|
||||
- Снизу на всю ширину: ResultPanel
|
||||
|
||||
Grid: 1fr 1fr, gap 24px, max-width 1800px
|
||||
|
||||
Адаптивность:
|
||||
- < 1400px: одна колонка
|
||||
- < 768px: уменьшенные отступы
|
||||
|
||||
Placeholder карты до анализа:
|
||||
- Пунктирная рамка
|
||||
- Иконка + текст подсказки
|
||||
|
||||
### Цветовая палитра ЦОУ:
|
||||
|
||||
Фоны: #0a0c0f, #161b22, #1c2128
|
||||
Границы: #21262d, #30363d
|
||||
Текст: #e6edf3, #8b949e
|
||||
Акценты: #ff4d00, #ff9f1a, #ffd166, #238636, #58a6ff
|
||||
|
||||
### Файлы:
|
||||
|
||||
ResultPanel:
|
||||
- ResultPanel.jsx (6.8 KB)
|
||||
- ResultPanel.css (5.2 KB)
|
||||
- index.jsx
|
||||
|
||||
AnalysisLayout:
|
||||
- AnalysisLayout.jsx (1.8 KB)
|
||||
- AnalysisLayout.css (1.4 KB)
|
||||
- index.jsx
|
||||
|
||||
### Статус:
|
||||
Блок срочности (4 уровня)
|
||||
Behavioral Profile карточки (5 типов)
|
||||
Психотип секция
|
||||
Немедленные действия (топ-3)
|
||||
Зоны с приоритетом (топ-5)
|
||||
Ключевые объекты
|
||||
Прогноз поведения
|
||||
Двухколоночный layout
|
||||
Sticky карта
|
||||
Адаптивный дизайн
|
||||
Палитра ЦОУ
|
||||
|
||||
Дата создания: 2026-05-05
|
||||
@@ -0,0 +1,183 @@
|
||||
# Сервисы анализа SAR-MCHS
|
||||
|
||||
## Обзор
|
||||
|
||||
Созданы два основных сервиса для анализа поисково-спасательных операций:
|
||||
|
||||
### 1. Claude Service (`backend/services/claude_service.py`)
|
||||
|
||||
**Функция:** `analyze_case(case_data: dict) -> AnalysisResult`
|
||||
|
||||
Использует Claude API (модель `claude-sonnet-4-20250514`) для интеллектуального анализа случаев пропажи людей.
|
||||
|
||||
**Входные данные (case_data):**
|
||||
- `age` - возраст пропавшего
|
||||
- `gender` - пол
|
||||
- `terrain` - тип местности
|
||||
- `weather` - погодные условия
|
||||
- `time_missing` - время пропажи
|
||||
- `last_location` - последнее известное местоположение
|
||||
- `circumstances` - особые обстоятельства
|
||||
- `physical_condition` - физическое состояние
|
||||
- `experience` - опыт нахождения на природе
|
||||
|
||||
**Возвращаемые данные (AnalysisResult):**
|
||||
```python
|
||||
{
|
||||
"urgency": "критическая|высокая|средняя|низкая",
|
||||
"primary_zones": [
|
||||
{
|
||||
"priority": 1,
|
||||
"name": "Зона А",
|
||||
"direction": "север",
|
||||
"distance": 2.5,
|
||||
"reason": "Обоснование выбора"
|
||||
}
|
||||
],
|
||||
"search_radius_km": 5.0,
|
||||
"key_locations": ["водоемы", "дороги", "постройки"],
|
||||
"team_assignments": {
|
||||
"команда_1": "зона А",
|
||||
"команда_2": "зона Б"
|
||||
},
|
||||
"behavioral_prediction": "Прогноз поведения",
|
||||
"immediate_actions": ["Действие 1", "Действие 2"],
|
||||
"summary": "Краткое резюме"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Stats Service (`backend/services/stats_service.py`)
|
||||
|
||||
**Функция:** `get_statistical_recommendation(case_data: dict, db_session: AsyncSession) -> StatsResult`
|
||||
|
||||
Анализирует исторические данные из базы для получения статистических рекомендаций.
|
||||
|
||||
**Входные данные:**
|
||||
- `age` - возраст (±2 года для фильтрации)
|
||||
- `season` - сезон (winter, spring, summer, autumn)
|
||||
- `terrain_primary` - основной тип местности
|
||||
|
||||
**Логика фильтрации:**
|
||||
1. Первая попытка: возраст ±2 года + сезон + terrain
|
||||
2. Если sample_size < 5: возраст ±2 года + сезон (без terrain)
|
||||
3. Если данных нет: возвращает дефолтные значения
|
||||
|
||||
**Возвращаемые данные (StatsResult):**
|
||||
```python
|
||||
{
|
||||
"median_distance_km": 2.5,
|
||||
"top_directions": [
|
||||
{
|
||||
"direction": "север",
|
||||
"count": 15,
|
||||
"percentage": 35.0
|
||||
}
|
||||
],
|
||||
"top_location_types": ["водоемы", "дороги", "лесные тропы"],
|
||||
"survival_rate": 87.5,
|
||||
"sample_size": 43
|
||||
}
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### POST `/api/v1/analyze/text`
|
||||
|
||||
Анализ случая с помощью Claude AI.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"age": 65,
|
||||
"gender": "мужской",
|
||||
"terrain": "лес",
|
||||
"weather": "дождь",
|
||||
"time_missing": "6 часов",
|
||||
"last_location": "лесная тропа у озера",
|
||||
"circumstances": "вышел за грибами",
|
||||
"physical_condition": "хорошее",
|
||||
"experience": "опытный грибник"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `AnalysisResult`
|
||||
|
||||
### POST `/api/v1/analyze/combined`
|
||||
|
||||
Комбинированный анализ: Claude AI + статистические данные.
|
||||
|
||||
**Request:** Тот же формат, что и `/text`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"claude_analysis": { /* AnalysisResult */ },
|
||||
"statistical_data": { /* StatsResult */ }
|
||||
}
|
||||
```
|
||||
|
||||
### POST `/api/v1/analyze/document`
|
||||
|
||||
Анализ загруженного документа (пока поддерживается только .txt).
|
||||
|
||||
**Request:** multipart/form-data с файлом
|
||||
|
||||
**Response:** `AnalysisResult`
|
||||
|
||||
## Настройка
|
||||
|
||||
### Переменные окружения
|
||||
|
||||
В файле `.env` должен быть установлен:
|
||||
```
|
||||
ANTHROPIC_API_KEY=your_api_key_here
|
||||
```
|
||||
|
||||
### База данных
|
||||
|
||||
Для работы `stats_service` требуется таблица `cases` со следующими полями:
|
||||
- `age` (integer)
|
||||
- `season` (string)
|
||||
- `terrain_primary` (string)
|
||||
- `found_distance_km` (float)
|
||||
- `found_direction` (string)
|
||||
- `found_location_type` (string)
|
||||
- `outcome` (string: 'alive', 'found_alive', etc.)
|
||||
|
||||
## Тестирование
|
||||
|
||||
### Тест Claude Service
|
||||
|
||||
```bash
|
||||
curl -X POST http://192.168.0.99:8000/api/v1/analyze/text \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"age": 65,
|
||||
"gender": "мужской",
|
||||
"terrain": "лес",
|
||||
"weather": "дождь",
|
||||
"time_missing": "6 часов",
|
||||
"last_location": "лесная тропа у озера"
|
||||
}'
|
||||
```
|
||||
|
||||
### Тест Combined Analysis
|
||||
|
||||
```bash
|
||||
curl -X POST http://192.168.0.99:8000/api/v1/analyze/combined \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"age": 65,
|
||||
"season": "autumn",
|
||||
"terrain_primary": "forest"
|
||||
}'
|
||||
```
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] Настроить подключение к базе данных
|
||||
- [ ] Добавить миграции Alembic для таблицы cases
|
||||
- [ ] Реализовать извлечение текста из .docx и .pdf
|
||||
- [ ] Добавить кэширование результатов анализа
|
||||
- [ ] Добавить rate limiting для API запросов
|
||||
- [ ] Добавить логирование запросов и ответов
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
# Stats Service API - Статистические рекомендации и агрегаты
|
||||
|
||||
## Endpoints
|
||||
|
||||
### GET /api/v1/stats/summary
|
||||
Получить агрегированную статистику для дашборда.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"total_cases": 8,
|
||||
"active_cases": 7,
|
||||
"closed_cases": 1,
|
||||
"by_gender": {"М": 8},
|
||||
"by_age_group": {
|
||||
"8-11": 6,
|
||||
"12-14": 2
|
||||
},
|
||||
"by_psychotype": {},
|
||||
"by_diagnosis": {"здоров": 3},
|
||||
"by_season": {
|
||||
"зима": 3,
|
||||
"весна": 1,
|
||||
"лето": 1
|
||||
},
|
||||
"avg_distance_km": 2.3,
|
||||
"avg_search_duration_hours": 4.5,
|
||||
"survival_rate": 100.0
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/v1/stats/recommendation
|
||||
Получить статистические рекомендации на основе похожих случаев.
|
||||
|
||||
**Фильтры (в порядке приоритета):**
|
||||
1. Возраст ±2 года + сезон + terrain
|
||||
2. Возраст ±2 года + сезон (если < 5 случаев)
|
||||
3. Возраст ±2 года (если < 5 случаев)
|
||||
4. Все случаи (если < 5 случаев)
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"age": 10,
|
||||
"season": "лето",
|
||||
"terrain_primary": "лес"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"median_distance_km": 2.3,
|
||||
"top_directions": [
|
||||
{
|
||||
"direction": "СВ",
|
||||
"count": 1,
|
||||
"percentage": 100.0
|
||||
}
|
||||
],
|
||||
"top_location_types": ["forest"],
|
||||
"survival_rate": 100.0,
|
||||
"sample_size": 1,
|
||||
"filters_used": {
|
||||
"age_range": "8 to 12",
|
||||
"season": "лето",
|
||||
"terrain": "лес"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/v1/stats/heatmap
|
||||
Получить данные для тепловой карты находок.
|
||||
|
||||
**Query Parameters:**
|
||||
- `age_min` (optional): Минимальный возраст
|
||||
- `age_max` (optional): Максимальный возраст
|
||||
- `season` (optional): Сезон
|
||||
- `outcome` (optional): Исход (alive/deceased)
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"points": [
|
||||
{
|
||||
"lat": 53.9,
|
||||
"lon": 27.56,
|
||||
"intensity": 1.0,
|
||||
"case_id": "uuid",
|
||||
"distance_km": 2.3,
|
||||
"outcome": "alive"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
|
||||
## Функции stats_service.py
|
||||
|
||||
### get_statistical_recommendation()
|
||||
SQL выборка похожих случаев с фильтрами:
|
||||
- Возраст ±2 года
|
||||
- Сезон
|
||||
- Terrain (тип местности)
|
||||
- Минимум 5 случаев для выборки
|
||||
|
||||
Возвращает:
|
||||
- `median_distance_km` - медианное расстояние находки
|
||||
- `top_directions` - топ-3 направления с процентами
|
||||
- `top_location_types` - топ-5 типов локаций
|
||||
- `survival_rate` - процент выживаемости
|
||||
- `sample_size` - размер выборки
|
||||
- `filters_used` - использованные фильтры
|
||||
|
||||
### get_dashboard_stats()
|
||||
Агрегаты для дашборда:
|
||||
- Общее количество случаев (всего, активных, закрытых)
|
||||
- Распределение по полу, возрасту, психотипу, диагнозам, сезонам
|
||||
- Средняя дистанция находки
|
||||
- Средняя длительность поиска
|
||||
- Процент выживаемости
|
||||
|
||||
### get_heatmap_data()
|
||||
Данные для тепловой карты с фильтрами:
|
||||
- Возраст (min/max)
|
||||
- Сезон
|
||||
- Исход (alive/deceased)
|
||||
|
||||
## Тестирование
|
||||
|
||||
```bash
|
||||
# 1. Получить агрегаты дашборда
|
||||
curl http://localhost:8000/api/v1/stats/summary
|
||||
|
||||
# 2. Получить статистические рекомендации
|
||||
curl -X POST http://localhost:8000/api/v1/stats/recommendation \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"age": 10, "season": "лето", "terrain_primary": "лес"}'
|
||||
|
||||
# 3. Получить данные для тепловой карты
|
||||
curl "http://localhost:8000/api/v1/stats/heatmap?age_min=8&age_max=12"
|
||||
```
|
||||
|
||||
## Протестировано (2026-05-05)
|
||||
|
||||
✅ GET /api/v1/stats/summary - работает
|
||||
✅ POST /api/v1/stats/recommendation - работает
|
||||
✅ GET /api/v1/stats/heatmap - работает
|
||||
|
||||
## Технические детали
|
||||
|
||||
- **Синхронная SQLAlchemy** (не async)
|
||||
- **PostgreSQL ARRAY** - используется `any()` вместо `contains()`
|
||||
- **Fallback механизм** - если выборка < 5 случаев, расширяются фильтры
|
||||
- **Pydantic модели** - валидация входных/выходных данных
|
||||
|
||||
## Дата создания
|
||||
2026-05-05
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y gcc postgresql-client && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,347 @@
|
||||
"""
|
||||
Analysis API endpoints for full case analysis pipeline.
|
||||
|
||||
Pipeline: distance → geo → scoring → psychotype → claude → merged result
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
import time
|
||||
|
||||
from database import get_db
|
||||
from models import Case, AnalysisLog, User
|
||||
from api.v1.auth import get_current_user
|
||||
|
||||
# Import services
|
||||
from services.distance_service import calculate_max_distance
|
||||
from services.geo_service import build_search_zones
|
||||
from services.scoring_service import WeightedScorer
|
||||
from services.psychotype_service import (
|
||||
detect_psychotype,
|
||||
get_psychotype_modifiers,
|
||||
get_search_recommendations
|
||||
)
|
||||
from services.claude_service import analyze_case as claude_analyze
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class AnalysisRequest(BaseModel):
|
||||
"""Request for full case analysis"""
|
||||
case_id: UUID = Field(..., description="ID случая для анализа")
|
||||
|
||||
|
||||
class ZoneResult(BaseModel):
|
||||
"""Search zone with score and recommendations"""
|
||||
priority: int
|
||||
name: str
|
||||
direction: str
|
||||
distance_km: float
|
||||
score: float
|
||||
reasoning: str
|
||||
forest_pct: Optional[float] = None
|
||||
road_density: Optional[float] = None
|
||||
water_distance_km: Optional[float] = None
|
||||
|
||||
|
||||
class AnalysisResponse(BaseModel):
|
||||
"""Full analysis result"""
|
||||
case_id: UUID
|
||||
analyzed_at: datetime
|
||||
|
||||
# Distance calculation
|
||||
max_distance_km: float
|
||||
|
||||
# Psychotype (if available)
|
||||
psychotype: Optional[str] = None
|
||||
psychotype_modifiers: Optional[Dict[str, Any]] = None
|
||||
psychotype_recommendations: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Zones
|
||||
zones: List[ZoneResult]
|
||||
|
||||
# Claude analysis (if available)
|
||||
urgency: Optional[str] = None
|
||||
key_locations: Optional[List[str]] = None
|
||||
immediate_actions: Optional[List[str]] = None
|
||||
behavioral_prediction: Optional[str] = None
|
||||
summary: Optional[str] = None
|
||||
|
||||
# Meta
|
||||
execution_time_ms: float
|
||||
services_used: List[str]
|
||||
|
||||
|
||||
class SavedAnalysisResponse(BaseModel):
|
||||
"""Saved analysis result from database"""
|
||||
case_id: UUID
|
||||
analysis_log: Dict[str, Any]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@router.post("/analyze", response_model=AnalysisResponse, status_code=200)
|
||||
async def analyze_full_case(
|
||||
request: AnalysisRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Запустить полный анализ случая.
|
||||
|
||||
Пайплайн:
|
||||
1. Distance service - расчет максимальной дистанции
|
||||
2. Geo service - построение зон поиска
|
||||
3. Scoring service - оценка и ранжирование зон
|
||||
4. Psychotype service - определение психотипа (если есть данные)
|
||||
5. Claude service - интеллектуальный анализ (опционально)
|
||||
6. Merge results - объединение результатов
|
||||
|
||||
Требуется аутентификация (operator, field, admin).
|
||||
"""
|
||||
start_time = time.time()
|
||||
services_used = []
|
||||
|
||||
# 1. Получить случай из БД
|
||||
case = db.query(Case).filter(Case.id == request.case_id).first()
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail=f"Case {request.case_id} not found")
|
||||
|
||||
# Подготовить данные для анализа
|
||||
case_data = {
|
||||
'age': case.age_years,
|
||||
'gender': case.gender,
|
||||
'elapsed_hours': case.elapsed_hours or 1.0,
|
||||
'terrain_primary': case.terrain[0] if case.terrain else 'лес',
|
||||
'season': case.season or 'лето',
|
||||
'temperature_c': case.temperature_c or 20.0,
|
||||
'has_transport': case.has_transport,
|
||||
'has_diagnosis': case.has_diagnosis,
|
||||
'diagnosis_type': case.diagnosis_type or [],
|
||||
'tnp_lat': case.tnp_lat,
|
||||
'tnp_lon': case.tnp_lon,
|
||||
}
|
||||
|
||||
# 2. Distance service - расчет максимальной дистанции
|
||||
try:
|
||||
max_distance = calculate_max_distance(case_data)
|
||||
services_used.append('distance')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Distance calculation failed: {str(e)}")
|
||||
|
||||
# 3. Geo service - построение зон поиска (если есть координаты)
|
||||
zones_data = []
|
||||
if case.tnp_lat and case.tnp_lon:
|
||||
try:
|
||||
zones_data = await build_search_zones(
|
||||
lat=case.tnp_lat,
|
||||
lon=case.tnp_lon,
|
||||
case_data=case_data,
|
||||
max_distance_km=max_distance
|
||||
)
|
||||
services_used.append('geo')
|
||||
except Exception as e:
|
||||
# Geo service опционален, продолжаем без него
|
||||
print(f"Geo service failed: {e}")
|
||||
|
||||
# 4. Scoring service - оценка и ранжирование зон
|
||||
scored_zones = []
|
||||
if zones_data:
|
||||
try:
|
||||
scorer = WeightedScorer()
|
||||
for zone in zones_data:
|
||||
zone_dict = zone.model_dump() if hasattr(zone, 'model_dump') else zone
|
||||
score = scorer.score_zone(zone_dict, case_data, max_distance_km=max_distance)
|
||||
zone_dict['score'] = score
|
||||
zone_dict['reasoning'] = f"Оценка на основе {len(case_data)} факторов"
|
||||
scored_zones.append(zone_dict)
|
||||
|
||||
# Сортировать по score
|
||||
scored_zones.sort(key=lambda z: z.get('score', 0), reverse=True)
|
||||
services_used.append('scoring')
|
||||
except Exception as e:
|
||||
print(f"Scoring service failed: {e}")
|
||||
scored_zones = _create_fallback_zones(max_distance)
|
||||
else:
|
||||
# Если geo не работает, создаем базовые зоны
|
||||
scored_zones = _create_fallback_zones(max_distance)
|
||||
|
||||
# 5. Psychotype service - определение психотипа
|
||||
psychotype = None
|
||||
psychotype_modifiers = None
|
||||
psychotype_recommendations = None
|
||||
|
||||
if case.psychotype_answers:
|
||||
try:
|
||||
psychotype = detect_psychotype(case.psychotype_answers)
|
||||
psychotype_modifiers = get_psychotype_modifiers(psychotype)
|
||||
psychotype_recommendations = get_search_recommendations(psychotype)
|
||||
services_used.append('psychotype')
|
||||
|
||||
# Применить модификаторы психотипа к зонам
|
||||
scored_zones = _apply_psychotype_modifiers(scored_zones, psychotype_modifiers)
|
||||
except Exception as e:
|
||||
print(f"Psychotype service failed: {e}")
|
||||
|
||||
# 6. Claude service - интеллектуальный анализ (опционально)
|
||||
urgency = None
|
||||
key_locations = None
|
||||
immediate_actions = None
|
||||
behavioral_prediction = None
|
||||
summary = None
|
||||
|
||||
try:
|
||||
claude_result = await claude_analyze(case_data)
|
||||
urgency = claude_result.urgency
|
||||
key_locations = claude_result.key_locations
|
||||
immediate_actions = claude_result.immediate_actions
|
||||
behavioral_prediction = claude_result.behavioral_prediction
|
||||
summary = claude_result.summary
|
||||
services_used.append('claude')
|
||||
except Exception as e:
|
||||
# Claude опционален, продолжаем без него
|
||||
print(f"Claude service failed: {e}")
|
||||
|
||||
# 7. Формируем результат
|
||||
zones_result = [
|
||||
ZoneResult(
|
||||
priority=i + 1,
|
||||
name=zone.get('name', f"Зона {zone.get('direction', 'N')}"),
|
||||
direction=zone.get('direction', 'N'),
|
||||
distance_km=zone.get('distance_km', 0),
|
||||
score=zone.get('score', 0),
|
||||
reasoning=zone.get('reasoning', 'Автоматическая оценка'),
|
||||
forest_pct=zone.get('forest_pct'),
|
||||
road_density=zone.get('road_density'),
|
||||
water_distance_km=zone.get('water_distance_km')
|
||||
)
|
||||
for i, zone in enumerate(scored_zones[:10]) # Топ-10 зон
|
||||
]
|
||||
|
||||
execution_time = (time.time() - start_time) * 1000
|
||||
|
||||
# 8. Сохранить результат в analysis_log
|
||||
analysis_result = {
|
||||
'max_distance_km': max_distance,
|
||||
'psychotype': psychotype,
|
||||
'psychotype_modifiers': psychotype_modifiers,
|
||||
'zones': [z.model_dump() for z in zones_result],
|
||||
'urgency': urgency,
|
||||
'key_locations': key_locations,
|
||||
'immediate_actions': immediate_actions,
|
||||
'summary': summary,
|
||||
'services_used': services_used,
|
||||
'execution_time_ms': execution_time
|
||||
}
|
||||
|
||||
# Обновить case.analysis_log
|
||||
case.analysis_log = analysis_result
|
||||
db.commit()
|
||||
|
||||
# Создать запись в AnalysisLog
|
||||
log_entry = AnalysisLog(
|
||||
case_id=case.id,
|
||||
user_id=current_user.id,
|
||||
analysis_type='full_pipeline',
|
||||
input_data={'case_id': str(case.id)},
|
||||
output_data=analysis_result,
|
||||
execution_time=execution_time / 1000,
|
||||
status='success'
|
||||
)
|
||||
db.add(log_entry)
|
||||
db.commit()
|
||||
|
||||
return AnalysisResponse(
|
||||
case_id=case.id,
|
||||
analyzed_at=datetime.utcnow(),
|
||||
max_distance_km=max_distance,
|
||||
psychotype=psychotype,
|
||||
psychotype_modifiers=psychotype_modifiers,
|
||||
psychotype_recommendations=psychotype_recommendations,
|
||||
zones=zones_result,
|
||||
urgency=urgency,
|
||||
key_locations=key_locations,
|
||||
immediate_actions=immediate_actions,
|
||||
behavioral_prediction=behavioral_prediction,
|
||||
summary=summary,
|
||||
execution_time_ms=execution_time,
|
||||
services_used=services_used
|
||||
)
|
||||
|
||||
|
||||
@router.get("/analyze/{case_id}", response_model=SavedAnalysisResponse)
|
||||
async def get_saved_analysis(
|
||||
case_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Получить сохранённый результат анализа.
|
||||
|
||||
Возвращает последний analysis_log из таблицы cases.
|
||||
Требуется аутентификация (operator, field, admin).
|
||||
"""
|
||||
case = db.query(Case).filter(Case.id == case_id).first()
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail=f"Case {case_id} not found")
|
||||
|
||||
if not case.analysis_log:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No analysis found for case {case_id}. Run POST /analyze first."
|
||||
)
|
||||
|
||||
return SavedAnalysisResponse(
|
||||
case_id=case.id,
|
||||
analysis_log=case.analysis_log,
|
||||
created_at=case.created_at
|
||||
)
|
||||
|
||||
|
||||
def _create_fallback_zones(max_distance: float) -> List[Dict[str, Any]]:
|
||||
"""Create basic zones when geo/scoring services fail"""
|
||||
directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
|
||||
zones = []
|
||||
|
||||
for i, direction in enumerate(directions):
|
||||
zones.append({
|
||||
'direction': direction,
|
||||
'distance_km': max_distance * 0.8,
|
||||
'score': 100 - (i * 10),
|
||||
'name': f"Сектор {direction}",
|
||||
'reasoning': 'Базовая оценка (сервисы недоступны)'
|
||||
})
|
||||
|
||||
return zones
|
||||
|
||||
|
||||
def _apply_psychotype_modifiers(zones: List[Dict], modifiers: Dict) -> List[Dict]:
|
||||
"""Apply psychotype modifiers to zone scores"""
|
||||
if not modifiers:
|
||||
return zones
|
||||
|
||||
# Применяем модификаторы зон из психотипа
|
||||
for zone in zones:
|
||||
distance = zone.get('distance_km', 0)
|
||||
|
||||
# Определяем зону дистанции
|
||||
if distance < 0.5:
|
||||
modifier = modifiers.get('zone_0_500', 1.0)
|
||||
elif distance < 1.5:
|
||||
modifier = modifiers.get('zone_500_1500', 1.0)
|
||||
elif distance < 2.5:
|
||||
modifier = modifiers.get('zone_1500_2500', 1.0)
|
||||
else:
|
||||
modifier = modifiers.get('zone_2500plus', 1.0)
|
||||
|
||||
# Применяем модификатор к score
|
||||
zone['score'] = zone.get('score', 0) * modifier
|
||||
zone['reasoning'] += f" (психотип: ×{modifier:.1f})"
|
||||
|
||||
# Пересортировать по score
|
||||
zones.sort(key=lambda z: z.get('score', 0), reverse=True)
|
||||
|
||||
return zones
|
||||
@@ -0,0 +1,23 @@
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class AnalysisRequest(BaseModel):
|
||||
age: int
|
||||
gender: str
|
||||
terrain: str
|
||||
|
||||
|
||||
class AnalysisResult(BaseModel):
|
||||
recommendation: str
|
||||
estimated_radius_km: float
|
||||
|
||||
|
||||
@router.post("/text", response_model=AnalysisResult)
|
||||
async def analyze_text(request: AnalysisRequest):
|
||||
return AnalysisResult(
|
||||
recommendation="Placeholder analysis",
|
||||
estimated_radius_km=2.5
|
||||
)
|
||||
@@ -0,0 +1,196 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
from jose import JWTError, jwt
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
import os
|
||||
import bcrypt
|
||||
|
||||
from database import get_db
|
||||
from models import User
|
||||
from schemas import UserOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# JWT настройки
|
||||
SECRET_KEY = os.getenv("JWT_SECRET", "change-me-in-production")
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 часа
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
username: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Проверка пароля через bcrypt напрямую"""
|
||||
return bcrypt.checkpw(
|
||||
plain_password.encode('utf-8'),
|
||||
hashed_password.encode('utf-8')
|
||||
)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Хеширование пароля через bcrypt напрямую"""
|
||||
salt = bcrypt.gensalt()
|
||||
return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
"""Создание JWT токена"""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=15)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def authenticate_user(db: Session, username: str, password: str):
|
||||
"""Аутентификация пользователя"""
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if not user:
|
||||
return False
|
||||
if not verify_password(password, user.hashed_password):
|
||||
return False
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
"""Получение текущего пользователя из JWT токена"""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
token_data = TokenData(username=username, role=payload.get("role"))
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
user = db.query(User).filter(User.username == token_data.username).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_active_user(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""Проверка активности пользователя"""
|
||||
if not current_user.is_active:
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
return current_user
|
||||
|
||||
|
||||
def require_role(allowed_roles: list[str]):
|
||||
"""Dependency для проверки роли пользователя"""
|
||||
async def role_checker(current_user: User = Depends(get_current_user)):
|
||||
if current_user.role not in allowed_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Access denied. Required roles: {', '.join(allowed_roles)}"
|
||||
)
|
||||
return current_user
|
||||
return role_checker
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Аутентификация и получение JWT токена.
|
||||
|
||||
Используйте username и password для получения access_token.
|
||||
Токен действителен 24 часа.
|
||||
|
||||
Тестовые пользователи:
|
||||
- operator / pass123
|
||||
- field / pass123
|
||||
- admin / pass123
|
||||
"""
|
||||
user = authenticate_user(db, form_data.username, form_data.password)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Обновляем last_login
|
||||
user.last_login = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
data={"sub": user.username, "role": user.role},
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def read_users_me(current_user: User = Depends(get_current_active_user)):
|
||||
"""
|
||||
Получить информацию о текущем пользователе.
|
||||
|
||||
Требуется валидный JWT токен в заголовке Authorization: Bearer <token>
|
||||
"""
|
||||
return current_user
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||
async def register_user(
|
||||
username: str,
|
||||
email: str,
|
||||
password: str,
|
||||
full_name: Optional[str] = None,
|
||||
role: str = "operator",
|
||||
current_user: User = Depends(require_role(["admin"])),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Регистрация нового пользователя (только для admin).
|
||||
|
||||
Доступные роли: operator, field, admin
|
||||
"""
|
||||
# Проверка существования пользователя
|
||||
if db.query(User).filter(User.username == username).first():
|
||||
raise HTTPException(status_code=400, detail="Username already registered")
|
||||
if db.query(User).filter(User.email == email).first():
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
# Создание пользователя
|
||||
hashed_password = get_password_hash(password)
|
||||
db_user = User(
|
||||
username=username,
|
||||
email=email,
|
||||
hashed_password=hashed_password,
|
||||
full_name=full_name,
|
||||
role=role
|
||||
)
|
||||
db.add(db_user)
|
||||
db.commit()
|
||||
db.refresh(db_user)
|
||||
return db_user
|
||||
@@ -0,0 +1,212 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from database import get_db
|
||||
from models import Case, User
|
||||
from schemas import CaseCreate, CaseOut
|
||||
from pydantic import BaseModel
|
||||
|
||||
# Импортируем auth dependencies
|
||||
import sys
|
||||
sys.path.append('/app/api/v1')
|
||||
from auth import get_current_user, require_role
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CaseUpdate(BaseModel):
|
||||
"""Schema for updating case fields"""
|
||||
# Ребёнок
|
||||
child_name: Optional[str] = None
|
||||
age_years: Optional[int] = None
|
||||
gender: Optional[str] = None
|
||||
height_build: Optional[str] = None
|
||||
clothes_upper: Optional[str] = None
|
||||
clothes_lower: Optional[str] = None
|
||||
shoes: Optional[str] = None
|
||||
clothes_description: Optional[str] = None
|
||||
special_marks: Optional[str] = None
|
||||
phone_status: Optional[str] = None
|
||||
|
||||
# Здоровье
|
||||
has_diagnosis: Optional[bool] = None
|
||||
diagnosis_type: Optional[List[str]] = None
|
||||
fitness_level: Optional[str] = None
|
||||
has_transport: Optional[str] = None
|
||||
cant_swim: Optional[bool] = None
|
||||
|
||||
# Психотип
|
||||
psychotype: Optional[str] = None
|
||||
psychotype_answers: Optional[dict] = None
|
||||
|
||||
# Обстоятельства
|
||||
loss_reason: Optional[str] = None
|
||||
loss_time: Optional[str] = None
|
||||
elapsed_hours: Optional[float] = None
|
||||
last_seen_direction: Optional[str] = None
|
||||
last_seen_reliability: Optional[str] = None
|
||||
last_seen_description: Optional[str] = None
|
||||
behavior_description: Optional[str] = None
|
||||
familiar_places: Optional[str] = None
|
||||
lost_before: Optional[str] = None
|
||||
|
||||
# Среда
|
||||
season: Optional[str] = None
|
||||
temperature_c: Optional[float] = None
|
||||
precipitation: Optional[str] = None
|
||||
visibility: Optional[str] = None
|
||||
wind: Optional[str] = None
|
||||
terrain: Optional[List[str]] = None
|
||||
|
||||
# GPS
|
||||
tnp_lat: Optional[float] = None
|
||||
tnp_lon: Optional[float] = None
|
||||
tnp_address: Optional[str] = None
|
||||
|
||||
# Ресурсы
|
||||
teams_count: Optional[int] = None
|
||||
team_size: Optional[int] = None
|
||||
has_dog: Optional[bool] = None
|
||||
extra_resources: Optional[List[str]] = None
|
||||
|
||||
# Исход
|
||||
found_alive: Optional[bool] = None
|
||||
found_distance_km: Optional[float] = None
|
||||
found_direction: Optional[str] = None
|
||||
found_location_type: Optional[str] = None
|
||||
found_lat: Optional[float] = None
|
||||
found_lon: Optional[float] = None
|
||||
search_duration_hours: Optional[float] = None
|
||||
who_found: Optional[str] = None
|
||||
|
||||
# Статус
|
||||
status: Optional[str] = None
|
||||
|
||||
|
||||
class CaseListResponse(BaseModel):
|
||||
"""Response for list endpoint with pagination"""
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
cases: List[CaseOut]
|
||||
|
||||
|
||||
@router.post("/cases", response_model=CaseOut, status_code=201)
|
||||
async def create_case(
|
||||
case_data: CaseCreate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Создать новый случай поиска.
|
||||
|
||||
Принимает все поля из формы опроса (5 шагов).
|
||||
Публичный эндпоинт - не требует аутентификации.
|
||||
"""
|
||||
db_case = Case(**case_data.model_dump(exclude_unset=True))
|
||||
db.add(db_case)
|
||||
db.commit()
|
||||
db.refresh(db_case)
|
||||
return db_case
|
||||
|
||||
|
||||
@router.get("/cases", response_model=CaseListResponse)
|
||||
async def list_cases(
|
||||
skip: int = Query(0, ge=0, description="Количество пропускаемых записей"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Максимум записей на страницу"),
|
||||
status: Optional[str] = Query(None, description="Фильтр по статусу: active/closed/archived"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Получить список случаев с пагинацией и фильтрацией.
|
||||
|
||||
- **skip**: смещение (для пагинации)
|
||||
- **limit**: количество записей (макс 100)
|
||||
- **status**: фильтр по статусу (active/closed/archived)
|
||||
|
||||
Требуется аутентификация (operator, field, admin).
|
||||
"""
|
||||
query = db.query(Case)
|
||||
|
||||
if status:
|
||||
query = query.filter(Case.status == status)
|
||||
|
||||
total = query.count()
|
||||
cases = query.order_by(desc(Case.created_at)).offset(skip).limit(limit).all()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"cases": cases
|
||||
}
|
||||
|
||||
|
||||
@router.get("/cases/{case_id}", response_model=CaseOut)
|
||||
async def get_case(
|
||||
case_id: UUID,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Получить случай по ID.
|
||||
|
||||
Возвращает все поля случая включая исход (если заполнен).
|
||||
Публичный эндпоинт - не требует аутентификации.
|
||||
"""
|
||||
case = db.query(Case).filter(Case.id == case_id).first()
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail=f"Case {case_id} not found")
|
||||
return case
|
||||
|
||||
|
||||
@router.patch("/cases/{case_id}", response_model=CaseOut)
|
||||
async def update_case(
|
||||
case_id: UUID,
|
||||
case_update: CaseUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Обновить случай (частичное обновление).
|
||||
|
||||
Используется для:
|
||||
- Корректировки данных опроса
|
||||
- Внесения исхода поиска (found_alive, found_distance_km и т.д.)
|
||||
- Изменения статуса (active → closed)
|
||||
|
||||
Требуется аутентификация (operator, field, admin).
|
||||
"""
|
||||
case = db.query(Case).filter(Case.id == case_id).first()
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail=f"Case {case_id} not found")
|
||||
|
||||
update_data = case_update.model_dump(exclude_unset=True)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(case, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(case)
|
||||
return case
|
||||
|
||||
|
||||
@router.delete("/cases/{case_id}", status_code=204)
|
||||
async def delete_case(
|
||||
case_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin"]))
|
||||
):
|
||||
"""
|
||||
Удалить случай (только для admin).
|
||||
|
||||
В продакшене рекомендуется использовать архивацию вместо удаления.
|
||||
"""
|
||||
case = db.query(Case).filter(Case.id == case_id).first()
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail=f"Case {case_id} not found")
|
||||
|
||||
db.delete(case)
|
||||
db.commit()
|
||||
return None
|
||||
@@ -0,0 +1,159 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from typing import Dict, List, Optional, Literal
|
||||
from database import get_db
|
||||
from models import Case
|
||||
from services.stats_service import (
|
||||
get_dashboard_stats,
|
||||
get_statistical_recommendation,
|
||||
get_heatmap_with_cache,
|
||||
DashboardStats,
|
||||
StatisticalRecommendation
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class HeatmapPoint(BaseModel):
|
||||
lat: float
|
||||
lon: float
|
||||
intensity: float
|
||||
case_id: str
|
||||
metadata: Dict
|
||||
|
||||
|
||||
class HeatmapResponse(BaseModel):
|
||||
points: List[HeatmapPoint]
|
||||
total: int
|
||||
filters_applied: Dict
|
||||
|
||||
|
||||
class StatisticalRecommendationRequest(BaseModel):
|
||||
age: int
|
||||
season: Optional[str] = None
|
||||
terrain_primary: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/summary", response_model=DashboardStats)
|
||||
async def get_summary(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Получить агрегированную статистику для дашборда.
|
||||
|
||||
Возвращает:
|
||||
- Общее количество случаев (всего, активных, закрытых)
|
||||
- Распределение по полу, возрасту, психотипу, диагнозам, сезонам
|
||||
- Средняя дистанция находки
|
||||
- Средняя длительность поиска
|
||||
- Процент выживаемости
|
||||
"""
|
||||
return get_dashboard_stats(db)
|
||||
|
||||
|
||||
@router.post("/recommendation", response_model=StatisticalRecommendation)
|
||||
async def get_recommendation(
|
||||
request: StatisticalRecommendationRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Получить статистические рекомендации на основе похожих случаев.
|
||||
|
||||
Фильтры (в порядке приоритета):
|
||||
1. Возраст ±2 года + сезон + terrain
|
||||
2. Возраст ±2 года + сезон (если < 5 случаев)
|
||||
3. Возраст ±2 года (если < 5 случаев)
|
||||
4. Все случаи (если < 5 случаев)
|
||||
|
||||
Возвращает:
|
||||
- Медианное расстояние находки
|
||||
- Топ-3 направления с процентами
|
||||
- Топ-5 типов локаций
|
||||
- Процент выживаемости
|
||||
- Размер выборки
|
||||
- Использованные фильтры
|
||||
"""
|
||||
case_data = {
|
||||
'age': request.age,
|
||||
'season': request.season,
|
||||
'terrain_primary': request.terrain_primary
|
||||
}
|
||||
return get_statistical_recommendation(case_data, db)
|
||||
|
||||
|
||||
@router.get("/heatmap", response_model=HeatmapResponse)
|
||||
async def get_heatmap(
|
||||
map_type: Literal['all', 'age', 'season', 'outcome'] = Query(
|
||||
'all',
|
||||
description="Тип карты: all (все точки), age (по возрасту), season (по сезону), outcome (по исходу)"
|
||||
),
|
||||
age_group: Optional[str] = Query(
|
||||
None,
|
||||
description="Возрастная группа: 0-3, 4-7, 8-11, 12-14, 15-17, 18+"
|
||||
),
|
||||
season: Optional[str] = Query(
|
||||
None,
|
||||
description="Сезон: зима, весна, лето, осень"
|
||||
),
|
||||
year_from: Optional[int] = Query(
|
||||
None,
|
||||
description="Год начала периода (например, 2020)"
|
||||
),
|
||||
year_to: Optional[int] = Query(
|
||||
None,
|
||||
description="Год окончания периода (например, 2026)"
|
||||
),
|
||||
outcome: Optional[str] = Query(
|
||||
None,
|
||||
description="Исход: alive (выжил), deceased (погиб)"
|
||||
),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Получить данные для тепловой карты находок с кэшированием (1 час).
|
||||
|
||||
**Типы карт:**
|
||||
- `all` - все точки с одинаковой интенсивностью
|
||||
- `age` - интенсивность зависит от возраста (младше = выше)
|
||||
- `season` - интенсивность зависит от сезона (зима = выше)
|
||||
- `outcome` - интенсивность зависит от исхода (погиб = выше)
|
||||
|
||||
**Фильтры:**
|
||||
- `age_group` - возрастная группа (0-3, 4-7, 8-11, 12-14, 15-17, 18+)
|
||||
- `season` - сезон (зима, весна, лето, осень)
|
||||
- `year_from`, `year_to` - период по годам
|
||||
- `outcome` - исход (alive, deceased)
|
||||
|
||||
**Кэширование:**
|
||||
Результаты кэшируются на 1 час для ускорения повторных запросов.
|
||||
|
||||
**Возвращает:**
|
||||
- `points` - массив точек с координатами, интенсивностью и метаданными
|
||||
- `total` - общее количество точек
|
||||
- `filters_applied` - примененные фильтры
|
||||
"""
|
||||
result = get_heatmap_with_cache(
|
||||
db=db,
|
||||
map_type=map_type,
|
||||
age_group=age_group,
|
||||
season=season,
|
||||
year_from=year_from,
|
||||
year_to=year_to,
|
||||
outcome=outcome
|
||||
)
|
||||
|
||||
points = [
|
||||
HeatmapPoint(
|
||||
lat=p['lat'],
|
||||
lon=p['lon'],
|
||||
intensity=p['intensity'],
|
||||
case_id=p['case_id'],
|
||||
metadata=p['metadata']
|
||||
)
|
||||
for p in result['points']
|
||||
]
|
||||
|
||||
return HeatmapResponse(
|
||||
points=points,
|
||||
total=result['total'],
|
||||
filters_applied=result['filters_applied']
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
import models
|
||||
from sqlalchemy import inspect
|
||||
|
||||
mapper = inspect(models.Case)
|
||||
columns = [c.key for c in mapper.columns]
|
||||
print(f"Всего полей в модели Case: {len(columns)}")
|
||||
print("\nПоля по категориям:")
|
||||
print("\nРебёнок (Шаг 1):")
|
||||
for c in columns:
|
||||
if c in ["child_name", "age_years", "gender", "height_build", "clothes_upper", "clothes_lower", "shoes", "clothes_description", "special_marks", "phone_status"]:
|
||||
print(f" - {c}")
|
||||
print("\nЗдоровье (Шаг 2):")
|
||||
for c in columns:
|
||||
if c in ["has_diagnosis", "diagnosis_type", "fitness_level", "has_transport", "cant_swim"]:
|
||||
print(f" - {c}")
|
||||
print("\nПсихотип (Шаг 2б):")
|
||||
for c in columns:
|
||||
if c in ["psychotype", "psychotype_answers"]:
|
||||
print(f" - {c}")
|
||||
print("\nОбстоятельства (Шаг 3):")
|
||||
for c in columns:
|
||||
if c in ["loss_reason", "loss_time", "elapsed_hours", "last_seen_direction", "last_seen_reliability", "last_seen_description", "behavior_description", "familiar_places", "lost_before"]:
|
||||
print(f" - {c}")
|
||||
print("\nСреда (Шаг 4):")
|
||||
for c in columns:
|
||||
if c in ["season", "temperature_c", "precipitation", "visibility", "wind", "terrain"]:
|
||||
print(f" - {c}")
|
||||
print("\nGPS (Шаг 4):")
|
||||
for c in columns:
|
||||
if c in ["tnp_lat", "tnp_lon", "tnp_address"]:
|
||||
print(f" - {c}")
|
||||
print("\nРесурсы (Шаг 5):")
|
||||
for c in columns:
|
||||
if c in ["teams_count", "team_size", "has_dog", "extra_resources"]:
|
||||
print(f" - {c}")
|
||||
print("\nИсход:")
|
||||
for c in columns:
|
||||
if c in ["found_alive", "found_distance_km", "found_direction", "found_location_type", "found_lat", "found_lon", "search_duration_hours", "who_found"]:
|
||||
print(f" - {c}")
|
||||
@@ -0,0 +1,26 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||
import os
|
||||
|
||||
DATABASE_URL = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql://postgres:postgres@postgres:5432/vector_mchs"
|
||||
)
|
||||
|
||||
engine = create_engine(DATABASE_URL, echo=False)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
@@ -0,0 +1,47 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
import os
|
||||
|
||||
from api.v1 import cases, analyze, auth, stats
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="SAR-MCHS API",
|
||||
description="Search and Rescue Management System API",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
cors_origins = os.getenv("CORS_ORIGINS", "http://localhost:3000").split(",")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router, prefix="/api/v1/auth", tags=["Authentication"])
|
||||
app.include_router(cases.router, prefix="/api/v1", tags=["Cases"])
|
||||
app.include_router(analyze.router, prefix="/api/v1/analyze", tags=["Analysis"])
|
||||
app.include_router(stats.router, prefix="/api/v1/stats", tags=["Statistics"])
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"message": "SAR-MCHS API",
|
||||
"version": "1.0.0",
|
||||
"docs": "/docs"
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "healthy"}
|
||||
@@ -0,0 +1,78 @@
|
||||
-- Миграция: создание таблицы cases согласно §10 контекста
|
||||
|
||||
DROP TABLE IF EXISTS search_results CASCADE;
|
||||
DROP TABLE IF EXISTS search_cases CASCADE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cases (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
status VARCHAR(20) DEFAULT 'active',
|
||||
|
||||
child_name VARCHAR(255),
|
||||
age_years INTEGER NOT NULL,
|
||||
gender CHAR(1),
|
||||
clothes_description TEXT,
|
||||
special_marks TEXT,
|
||||
phone_status VARCHAR(20),
|
||||
|
||||
has_diagnosis BOOLEAN DEFAULT FALSE,
|
||||
diagnosis_type VARCHAR[],
|
||||
fitness_level VARCHAR(20),
|
||||
has_transport VARCHAR(20) DEFAULT 'none',
|
||||
cant_swim BOOLEAN DEFAULT FALSE,
|
||||
|
||||
psychotype VARCHAR(50),
|
||||
psychotype_answers JSONB,
|
||||
|
||||
loss_reason VARCHAR(100),
|
||||
loss_time TIMESTAMP,
|
||||
elapsed_hours FLOAT,
|
||||
last_seen_direction VARCHAR(10),
|
||||
last_seen_reliability VARCHAR(20),
|
||||
last_seen_description TEXT,
|
||||
behavior_description TEXT,
|
||||
familiar_places TEXT,
|
||||
lost_before VARCHAR(20),
|
||||
|
||||
season VARCHAR(20),
|
||||
temperature_c FLOAT,
|
||||
precipitation VARCHAR(20),
|
||||
visibility VARCHAR(20),
|
||||
wind VARCHAR(20),
|
||||
terrain VARCHAR[],
|
||||
|
||||
tnp_lat FLOAT,
|
||||
tnp_lon FLOAT,
|
||||
tnp_address TEXT,
|
||||
|
||||
teams_count INTEGER,
|
||||
team_size INTEGER,
|
||||
has_dog BOOLEAN DEFAULT FALSE,
|
||||
extra_resources VARCHAR[],
|
||||
|
||||
found_alive BOOLEAN,
|
||||
found_distance_km FLOAT,
|
||||
found_direction VARCHAR(10),
|
||||
found_location_type VARCHAR(50),
|
||||
found_lat FLOAT,
|
||||
found_lon FLOAT,
|
||||
search_duration_hours FLOAT,
|
||||
who_found VARCHAR(50),
|
||||
|
||||
confidence_avg FLOAT,
|
||||
raw_text TEXT,
|
||||
analysis_log JSONB
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cases_coords ON cases (found_lat, found_lon);
|
||||
CREATE INDEX IF NOT EXISTS idx_cases_age_season ON cases (age_years, season);
|
||||
CREATE INDEX IF NOT EXISTS idx_cases_status ON cases (status);
|
||||
|
||||
DROP TABLE IF EXISTS raw_documents CASCADE;
|
||||
CREATE TABLE IF NOT EXISTS raw_documents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
raw_text TEXT,
|
||||
extracted_json JSONB,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Миграция: добавление детализированных полей одежды и телосложения
|
||||
-- Дата: 2026-05-04
|
||||
|
||||
ALTER TABLE cases ADD COLUMN IF NOT EXISTS height_build TEXT;
|
||||
ALTER TABLE cases ADD COLUMN IF NOT EXISTS clothes_upper TEXT;
|
||||
ALTER TABLE cases ADD COLUMN IF NOT EXISTS clothes_lower TEXT;
|
||||
ALTER TABLE cases ADD COLUMN IF NOT EXISTS shoes TEXT;
|
||||
|
||||
COMMENT ON COLUMN cases.height_build IS 'Рост / телосложение (Шаг 1)';
|
||||
COMMENT ON COLUMN cases.clothes_upper IS 'Одежда: верх (цвет, тип) (Шаг 1)';
|
||||
COMMENT ON COLUMN cases.clothes_lower IS 'Одежда: низ (цвет, тип) (Шаг 1)';
|
||||
COMMENT ON COLUMN cases.shoes IS 'Обувь (тип, цвет) (Шаг 1)';
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Миграция: создание таблиц users и analysis_log
|
||||
-- Дата: 2026-05-04
|
||||
|
||||
-- Таблица пользователей для аутентификации
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username VARCHAR(100) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
hashed_password VARCHAR(255) NOT NULL,
|
||||
full_name VARCHAR(255),
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'operator', -- operator/field/admin
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
last_login TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users (username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_role ON users (role);
|
||||
|
||||
-- Таблица логов анализа
|
||||
CREATE TABLE IF NOT EXISTS analysis_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
case_id UUID NOT NULL REFERENCES cases(id) ON DELETE CASCADE,
|
||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
analysis_type VARCHAR(50) NOT NULL, -- scoring/claude/geo/psychotype
|
||||
input_data JSONB,
|
||||
output_data JSONB,
|
||||
execution_time FLOAT, -- в секундах
|
||||
status VARCHAR(20) DEFAULT 'success', -- success/error/partial
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_analysis_log_case_id ON analysis_log (case_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_analysis_log_user_id ON analysis_log (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_analysis_log_created_at ON analysis_log (created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_analysis_log_type ON analysis_log (analysis_type);
|
||||
|
||||
COMMENT ON TABLE users IS 'Пользователи системы с ролями operator/field/admin';
|
||||
COMMENT ON TABLE analysis_log IS 'Лог всех анализов случаев для аудита и отладки';
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Seed: создание тестовых пользователей
|
||||
-- Пароль для всех: pass123
|
||||
|
||||
-- Удаляем существующих пользователей (если есть)
|
||||
TRUNCATE TABLE users CASCADE;
|
||||
|
||||
-- Создаём трёх тестовых пользователей
|
||||
-- Хеш для пароля "pass123" (bcrypt)
|
||||
INSERT INTO users (id, username, email, hashed_password, full_name, role, is_active, created_at)
|
||||
VALUES
|
||||
(gen_random_uuid(), 'operator', 'operator@mchs.by', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYqVr/1jrYK', 'Оператор ЦОУ', 'operator', true, NOW()),
|
||||
(gen_random_uuid(), 'field', 'field@mchs.by', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYqVr/1jrYK', 'Полевой работник', 'field', true, NOW()),
|
||||
(gen_random_uuid(), 'admin', 'admin@mchs.by', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYqVr/1jrYK', 'Администратор', 'admin', true, NOW());
|
||||
|
||||
-- Проверка
|
||||
SELECT username, email, role, is_active FROM users ORDER BY role;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Обновление хешей паролей для тестовых пользователей
|
||||
-- Новый хеш для пароля "pass123" через bcrypt напрямую
|
||||
|
||||
UPDATE users
|
||||
SET hashed_password = '$2b$12$gldqRRQhjH6yx3ymbZXxbOGfKYwd27cnzf6gQAKP7rho4PDSXT2Oy'
|
||||
WHERE username IN ('operator', 'field', 'admin');
|
||||
|
||||
-- Проверка
|
||||
SELECT username, role, substring(hashed_password, 1, 20) as hash_prefix FROM users;
|
||||
@@ -0,0 +1,125 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, Text, ARRAY, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
import uuid
|
||||
from database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""User model for authentication"""
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
username = Column(String(100), unique=True, nullable=False)
|
||||
email = Column(String(255), unique=True, nullable=False)
|
||||
hashed_password = Column(String(255), nullable=False)
|
||||
full_name = Column(String(255))
|
||||
role = Column(String(20), nullable=False, default="operator")
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
last_login = Column(DateTime)
|
||||
|
||||
|
||||
class Case(Base):
|
||||
"""Unified case model - combines search case and result"""
|
||||
__tablename__ = "cases"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
status = Column(String(20), default="active")
|
||||
|
||||
# Ребёнок (Шаг 1)
|
||||
child_name = Column(String(255))
|
||||
age_years = Column(Integer, nullable=False)
|
||||
gender = Column(String(1))
|
||||
height_build = Column(Text)
|
||||
clothes_upper = Column(Text)
|
||||
clothes_lower = Column(Text)
|
||||
shoes = Column(Text)
|
||||
clothes_description = Column(Text)
|
||||
special_marks = Column(Text)
|
||||
phone_status = Column(String(20))
|
||||
|
||||
# Здоровье (Шаг 2)
|
||||
has_diagnosis = Column(Boolean, default=False)
|
||||
diagnosis_type = Column(ARRAY(String))
|
||||
fitness_level = Column(String(20))
|
||||
has_transport = Column(String(20), default="none")
|
||||
cant_swim = Column(Boolean, default=False)
|
||||
|
||||
# Психотип (Шаг 2б)
|
||||
psychotype = Column(String(50))
|
||||
psychotype_answers = Column(JSONB)
|
||||
|
||||
# Обстоятельства (Шаг 3)
|
||||
loss_reason = Column(String(100))
|
||||
loss_time = Column(DateTime)
|
||||
elapsed_hours = Column(Float)
|
||||
last_seen_direction = Column(String(10))
|
||||
last_seen_reliability = Column(String(20))
|
||||
last_seen_description = Column(Text)
|
||||
behavior_description = Column(Text)
|
||||
familiar_places = Column(Text)
|
||||
lost_before = Column(String(20))
|
||||
|
||||
# Среда (Шаг 4)
|
||||
season = Column(String(20))
|
||||
temperature_c = Column(Float)
|
||||
precipitation = Column(String(20))
|
||||
visibility = Column(String(20))
|
||||
wind = Column(String(20))
|
||||
terrain = Column(ARRAY(String))
|
||||
|
||||
# GPS (Шаг 4)
|
||||
tnp_lat = Column(Float)
|
||||
tnp_lon = Column(Float)
|
||||
tnp_address = Column(Text)
|
||||
|
||||
# Ресурсы (Шаг 5)
|
||||
teams_count = Column(Integer)
|
||||
team_size = Column(Integer)
|
||||
has_dog = Column(Boolean, default=False)
|
||||
extra_resources = Column(ARRAY(String))
|
||||
|
||||
# Исход (заполняется после завершения)
|
||||
found_alive = Column(Boolean)
|
||||
found_distance_km = Column(Float)
|
||||
found_direction = Column(String(10))
|
||||
found_location_type = Column(String(50))
|
||||
found_lat = Column(Float)
|
||||
found_lon = Column(Float)
|
||||
search_duration_hours = Column(Float)
|
||||
who_found = Column(String(50))
|
||||
|
||||
# Мета
|
||||
confidence_avg = Column(Float)
|
||||
raw_text = Column(Text)
|
||||
analysis_log = Column(JSONB)
|
||||
|
||||
|
||||
class AnalysisLog(Base):
|
||||
"""Analysis log for audit and debugging"""
|
||||
__tablename__ = "analysis_log"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
case_id = Column(UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
|
||||
analysis_type = Column(String(50), nullable=False)
|
||||
input_data = Column(JSONB)
|
||||
output_data = Column(JSONB)
|
||||
execution_time = Column(Float)
|
||||
status = Column(String(20), default="success")
|
||||
error_message = Column(Text)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class RawDocument(Base):
|
||||
"""Raw document storage for parsed reports"""
|
||||
__tablename__ = "raw_documents"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
filename = Column(String(255), nullable=False)
|
||||
raw_text = Column(Text)
|
||||
extracted_json = Column(JSONB)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
@@ -0,0 +1,89 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, Text, ARRAY
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
from database import Base
|
||||
|
||||
|
||||
class Case(Base):
|
||||
"""Unified case model - combines search case and result"""
|
||||
__tablename__ = "cases"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
status = Column(String(20), default='active') # active/closed/archived
|
||||
|
||||
# Ребёнок
|
||||
child_name = Column(String(255))
|
||||
age_years = Column(Integer, nullable=False)
|
||||
gender = Column(String(1)) # М / Ж
|
||||
clothes_description = Column(Text)
|
||||
special_marks = Column(Text)
|
||||
phone_status = Column(String(20)) # answers/silent/none
|
||||
|
||||
# Здоровье
|
||||
has_diagnosis = Column(Boolean, default=False)
|
||||
diagnosis_type = Column(ARRAY(String)) # РАС, эпилепсия, СДВГ, ЗПР...
|
||||
fitness_level = Column(String(20)) # low/medium/high
|
||||
has_transport = Column(String(20), default='none') # none/bike/scooter/other
|
||||
cant_swim = Column(Boolean, default=False)
|
||||
|
||||
# Психотип
|
||||
psychotype = Column(String(50)) # dominant/harmonic/anxious/...
|
||||
psychotype_answers = Column(JSONB) # сырые ответы на 4 вопроса
|
||||
|
||||
# Обстоятельства
|
||||
loss_reason = Column(String(100))
|
||||
loss_time = Column(DateTime)
|
||||
elapsed_hours = Column(Float)
|
||||
last_seen_direction = Column(String(10))
|
||||
last_seen_reliability = Column(String(20)) # exact/approx/unknown
|
||||
last_seen_description = Column(Text)
|
||||
behavior_description = Column(Text)
|
||||
familiar_places = Column(Text)
|
||||
lost_before = Column(String(20)) # yes/no/unknown
|
||||
|
||||
# Среда
|
||||
season = Column(String(20))
|
||||
temperature_c = Column(Float)
|
||||
precipitation = Column(String(20))
|
||||
visibility = Column(String(20))
|
||||
wind = Column(String(20))
|
||||
terrain = Column(ARRAY(String))
|
||||
|
||||
# GPS
|
||||
tnp_lat = Column(Float)
|
||||
tnp_lon = Column(Float)
|
||||
tnp_address = Column(Text)
|
||||
|
||||
# Ресурсы
|
||||
teams_count = Column(Integer)
|
||||
team_size = Column(Integer)
|
||||
has_dog = Column(Boolean, default=False)
|
||||
extra_resources = Column(ARRAY(String)) # drone/helicopter/boat/thermal
|
||||
|
||||
# Исход (заполняется после завершения)
|
||||
found_alive = Column(Boolean)
|
||||
found_distance_km = Column(Float)
|
||||
found_direction = Column(String(10))
|
||||
found_location_type = Column(String(50)) # forest/road/building/water/field
|
||||
found_lat = Column(Float)
|
||||
found_lon = Column(Float)
|
||||
search_duration_hours = Column(Float)
|
||||
who_found = Column(String(50)) # mchs/mvd/volunteers/self
|
||||
|
||||
# Мета
|
||||
confidence_avg = Column(Float)
|
||||
raw_text = Column(Text)
|
||||
analysis_log = Column(JSONB)
|
||||
|
||||
|
||||
class RawDocument(Base):
|
||||
"""Raw document storage for parsed reports"""
|
||||
__tablename__ = "raw_documents"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
filename = Column(String(255), nullable=False)
|
||||
raw_text = Column(Text)
|
||||
extracted_json = Column(JSONB)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
@@ -0,0 +1,89 @@
|
||||
from sqlalchemy import Column, String, Float, Boolean, DateTime, Text, ARRAY
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
from database import Base
|
||||
|
||||
|
||||
class Case(Base):
|
||||
"""Unified case model - combines search case and result"""
|
||||
__tablename__ = "cases"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
status = Column(String(20), default='active') # active/closed/archived
|
||||
|
||||
# Ребёнок
|
||||
child_name = Column(String(255))
|
||||
age_years = Column(Integer, nullable=False)
|
||||
gender = Column(String(1)) # М / Ж
|
||||
clothes_description = Column(Text)
|
||||
special_marks = Column(Text)
|
||||
phone_status = Column(String(20)) # answers/silent/none
|
||||
|
||||
# Здоровье
|
||||
has_diagnosis = Column(Boolean, default=False)
|
||||
diagnosis_type = Column(ARRAY(String)) # РАС, эпилепсия, СДВГ, ЗПР...
|
||||
fitness_level = Column(String(20)) # low/medium/high
|
||||
has_transport = Column(String(20), default='none') # none/bike/scooter/other
|
||||
cant_swim = Column(Boolean, default=False)
|
||||
|
||||
# Психотип
|
||||
psychotype = Column(String(50)) # dominant/harmonic/anxious/...
|
||||
psychotype_answers = Column(JSONB) # сырые ответы на 4 вопроса
|
||||
|
||||
# Обстоятельства
|
||||
loss_reason = Column(String(100))
|
||||
loss_time = Column(DateTime)
|
||||
elapsed_hours = Column(Float)
|
||||
last_seen_direction = Column(String(10))
|
||||
last_seen_reliability = Column(String(20)) # exact/approx/unknown
|
||||
last_seen_description = Column(Text)
|
||||
behavior_description = Column(Text)
|
||||
familiar_places = Column(Text)
|
||||
lost_before = Column(String(20)) # yes/no/unknown
|
||||
|
||||
# Среда
|
||||
season = Column(String(20))
|
||||
temperature_c = Column(Float)
|
||||
precipitation = Column(String(20))
|
||||
visibility = Column(String(20))
|
||||
wind = Column(String(20))
|
||||
terrain = Column(ARRAY(String))
|
||||
|
||||
# GPS
|
||||
tnp_lat = Column(Float)
|
||||
tnp_lon = Column(Float)
|
||||
tnp_address = Column(Text)
|
||||
|
||||
# Ресурсы
|
||||
teams_count = Column(Integer)
|
||||
team_size = Column(Integer)
|
||||
has_dog = Column(Boolean, default=False)
|
||||
extra_resources = Column(ARRAY(String)) # drone/helicopter/boat/thermal
|
||||
|
||||
# Исход (заполняется после завершения)
|
||||
found_alive = Column(Boolean)
|
||||
found_distance_km = Column(Float)
|
||||
found_direction = Column(String(10))
|
||||
found_location_type = Column(String(50)) # forest/road/building/water/field
|
||||
found_lat = Column(Float)
|
||||
found_lon = Column(Float)
|
||||
search_duration_hours = Column(Float)
|
||||
who_found = Column(String(50)) # mchs/mvd/volunteers/self
|
||||
|
||||
# Мета
|
||||
confidence_avg = Column(Float)
|
||||
raw_text = Column(Text)
|
||||
analysis_log = Column(JSONB)
|
||||
|
||||
|
||||
class RawDocument(Base):
|
||||
"""Raw document storage for parsed reports"""
|
||||
__tablename__ = "raw_documents"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
filename = Column(String(255), nullable=False)
|
||||
raw_text = Column(Text)
|
||||
extracted_json = Column(JSONB)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
@@ -0,0 +1,93 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, Text, ARRAY
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
from database import Base
|
||||
|
||||
|
||||
class Case(Base):
|
||||
Unified case model - combines search case and result
|
||||
__tablename__ = "cases"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
status = Column(String(20), default="active") # active/closed/archived
|
||||
|
||||
# Ребёнок (Шаг 1)
|
||||
child_name = Column(String(255))
|
||||
age_years = Column(Integer, nullable=False)
|
||||
gender = Column(String(1)) # М / Ж
|
||||
height_build = Column(Text) # Рост / телосложение
|
||||
clothes_upper = Column(Text) # Одежда: верх (цвет, тип)
|
||||
clothes_lower = Column(Text) # Одежда: низ (цвет, тип)
|
||||
shoes = Column(Text) # Обувь (тип, цвет)
|
||||
clothes_description = Column(Text) # Общее описание одежды (legacy)
|
||||
special_marks = Column(Text) # Особые приметы
|
||||
phone_status = Column(String(20)) # answers/silent/none
|
||||
|
||||
# Здоровье (Шаг 2)
|
||||
has_diagnosis = Column(Boolean, default=False)
|
||||
diagnosis_type = Column(ARRAY(String)) # РАС, эпилепсия, СДВГ, ЗПР, слабое зрение, слабый слух...
|
||||
fitness_level = Column(String(20)) # low/medium/high
|
||||
has_transport = Column(String(20), default="none") # none/bike/scooter/other
|
||||
cant_swim = Column(Boolean, default=False)
|
||||
|
||||
# Психотип (Шаг 2б)
|
||||
psychotype = Column(String(50)) # dominant/harmonic/anxious/introvert_passive/introvert_active
|
||||
psychotype_answers = Column(JSONB) # сырые ответы на 4 вопроса
|
||||
|
||||
# Обстоятельства (Шаг 3)
|
||||
loss_reason = Column(String(100)) # потерялся в лесу/ушёл из дома/в городе/не вернулся с прогулки/на мероприятии/другое
|
||||
loss_time = Column(DateTime)
|
||||
elapsed_hours = Column(Float)
|
||||
last_seen_direction = Column(String(10)) # С/СВ/В/ЮВ/Ю/ЮЗ/З/СЗ/неизвестно
|
||||
last_seen_reliability = Column(String(20)) # exact/approx/unknown
|
||||
last_seen_description = Column(Text)
|
||||
behavior_description = Column(Text) # Поведение при стрессе
|
||||
familiar_places = Column(Text) # Знакомые места
|
||||
lost_before = Column(String(20)) # yes/no/unknown
|
||||
|
||||
# Среда (Шаг 4)
|
||||
season = Column(String(20)) # зима/весна/лето/осень
|
||||
temperature_c = Column(Float)
|
||||
precipitation = Column(String(20)) # нет/морось/дождь/ливень/снег/гроза/туман
|
||||
visibility = Column(String(20)) # хорошая/ограниченная/плохая
|
||||
wind = Column(String(20)) # штиль/слабый/умеренный/сильный
|
||||
terrain = Column(ARRAY(String)) # густой лес, редкий лес, лесная дорога, поле, болото, водоём, город...
|
||||
|
||||
# GPS (Шаг 4)
|
||||
tnp_lat = Column(Float)
|
||||
tnp_lon = Column(Float)
|
||||
tnp_address = Column(Text)
|
||||
|
||||
# Ресурсы (Шаг 5)
|
||||
teams_count = Column(Integer)
|
||||
team_size = Column(Integer)
|
||||
has_dog = Column(Boolean, default=False)
|
||||
extra_resources = Column(ARRAY(String)) # drone/helicopter/boat/thermal/quadbike
|
||||
|
||||
# Исход (заполняется после завершения)
|
||||
found_alive = Column(Boolean)
|
||||
found_distance_km = Column(Float)
|
||||
found_direction = Column(String(10))
|
||||
found_location_type = Column(String(50)) # forest/road/building/water/field
|
||||
found_lat = Column(Float)
|
||||
found_lon = Column(Float)
|
||||
search_duration_hours = Column(Float)
|
||||
who_found = Column(String(50)) # mchs/mvd/volunteers/self
|
||||
|
||||
# Мета
|
||||
confidence_avg = Column(Float)
|
||||
raw_text = Column(Text)
|
||||
analysis_log = Column(JSONB)
|
||||
|
||||
|
||||
class RawDocument(Base):
|
||||
"""Raw document storage for parsed reports"""
|
||||
__tablename__ = "raw_documents"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
filename = Column(String(255), nullable=False)
|
||||
raw_text = Column(Text)
|
||||
extracted_json = Column(JSONB)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
@@ -0,0 +1,15 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.32.0
|
||||
sqlalchemy[asyncio]==2.0.36
|
||||
asyncpg==0.30.0
|
||||
alembic==1.14.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
httpx==0.28.1
|
||||
python-docx==1.1.2
|
||||
python-multipart==0.0.17
|
||||
pydantic==2.10.3
|
||||
pydantic-settings==2.6.1
|
||||
email-validator==2.1.0
|
||||
psycopg2-binary==2.9.12
|
||||
pytest==8.3.4
|
||||
@@ -0,0 +1,258 @@
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
class CaseCreate(BaseModel):
|
||||
"""Schema for creating a new case"""
|
||||
|
||||
# Ребёнок (Шаг 1)
|
||||
child_name: Optional[str] = None
|
||||
age_years: int = Field(..., ge=0, le=18)
|
||||
gender: Optional[str] = Field(None, pattern="^[МЖ]$")
|
||||
height_build: Optional[str] = None
|
||||
clothes_upper: Optional[str] = None
|
||||
clothes_lower: Optional[str] = None
|
||||
shoes: Optional[str] = None
|
||||
clothes_description: Optional[str] = None
|
||||
special_marks: Optional[str] = None
|
||||
phone_status: Optional[str] = Field(None, pattern="^(answers|silent|none)$")
|
||||
|
||||
# Здоровье (Шаг 2)
|
||||
has_diagnosis: bool = False
|
||||
diagnosis_type: Optional[List[str]] = None
|
||||
fitness_level: Optional[str] = Field(None, pattern="^(low|medium|high)$")
|
||||
has_transport: str = Field(default="none", pattern="^(none|bike|scooter|other)$")
|
||||
cant_swim: bool = False
|
||||
|
||||
# Психотип (Шаг 2б)
|
||||
psychotype: Optional[str] = None
|
||||
psychotype_answers: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Обстоятельства (Шаг 3)
|
||||
loss_reason: Optional[str] = None
|
||||
loss_time: Optional[datetime] = None
|
||||
elapsed_hours: Optional[float] = Field(None, ge=0)
|
||||
last_seen_direction: Optional[str] = None
|
||||
last_seen_reliability: Optional[str] = Field(None, pattern="^(exact|approx|unknown)$")
|
||||
last_seen_description: Optional[str] = None
|
||||
behavior_description: Optional[str] = None
|
||||
familiar_places: Optional[str] = None
|
||||
lost_before: Optional[str] = Field(None, pattern="^(yes|no|unknown)$")
|
||||
|
||||
# Среда (Шаг 4)
|
||||
season: Optional[str] = None
|
||||
temperature_c: Optional[float] = None
|
||||
precipitation: Optional[str] = None
|
||||
visibility: Optional[str] = None
|
||||
wind: Optional[str] = None
|
||||
terrain: Optional[List[str]] = None
|
||||
|
||||
# GPS (Шаг 4)
|
||||
tnp_lat: Optional[float] = Field(None, ge=-90, le=90)
|
||||
tnp_lon: Optional[float] = Field(None, ge=-180, le=180)
|
||||
tnp_address: Optional[str] = None
|
||||
|
||||
# Ресурсы (Шаг 5)
|
||||
teams_count: Optional[int] = Field(None, ge=0)
|
||||
team_size: Optional[int] = Field(None, ge=0)
|
||||
has_dog: bool = False
|
||||
extra_resources: Optional[List[str]] = None
|
||||
|
||||
model_config = ConfigDict(json_schema_extra={
|
||||
"example": {
|
||||
"child_name": "Иван",
|
||||
"age_years": 8,
|
||||
"gender": "М",
|
||||
"loss_reason": "потерялся в лесу",
|
||||
"season": "лето",
|
||||
"temperature_c": 22.0,
|
||||
"tnp_lat": 53.9,
|
||||
"tnp_lon": 27.56,
|
||||
"teams_count": 3,
|
||||
"team_size": 5
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
class CaseOut(BaseModel):
|
||||
"""Schema for case output"""
|
||||
|
||||
id: UUID
|
||||
created_at: datetime
|
||||
status: str
|
||||
|
||||
# Ребёнок
|
||||
child_name: Optional[str] = None
|
||||
age_years: int
|
||||
gender: Optional[str] = None
|
||||
height_build: Optional[str] = None
|
||||
clothes_upper: Optional[str] = None
|
||||
clothes_lower: Optional[str] = None
|
||||
shoes: Optional[str] = None
|
||||
clothes_description: Optional[str] = None
|
||||
special_marks: Optional[str] = None
|
||||
phone_status: Optional[str] = None
|
||||
|
||||
# Здоровье
|
||||
has_diagnosis: bool
|
||||
diagnosis_type: Optional[List[str]] = None
|
||||
fitness_level: Optional[str] = None
|
||||
has_transport: str
|
||||
cant_swim: bool
|
||||
|
||||
# Психотип
|
||||
psychotype: Optional[str] = None
|
||||
psychotype_answers: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Обстоятельства
|
||||
loss_reason: Optional[str] = None
|
||||
loss_time: Optional[datetime] = None
|
||||
elapsed_hours: Optional[float] = None
|
||||
last_seen_direction: Optional[str] = None
|
||||
last_seen_reliability: Optional[str] = None
|
||||
last_seen_description: Optional[str] = None
|
||||
behavior_description: Optional[str] = None
|
||||
familiar_places: Optional[str] = None
|
||||
lost_before: Optional[str] = None
|
||||
|
||||
# Среда
|
||||
season: Optional[str] = None
|
||||
temperature_c: Optional[float] = None
|
||||
precipitation: Optional[str] = None
|
||||
visibility: Optional[str] = None
|
||||
wind: Optional[str] = None
|
||||
terrain: Optional[List[str]] = None
|
||||
|
||||
# GPS
|
||||
tnp_lat: Optional[float] = None
|
||||
tnp_lon: Optional[float] = None
|
||||
tnp_address: Optional[str] = None
|
||||
|
||||
# Ресурсы
|
||||
teams_count: Optional[int] = None
|
||||
team_size: Optional[int] = None
|
||||
has_dog: bool
|
||||
extra_resources: Optional[List[str]] = None
|
||||
|
||||
# Исход
|
||||
found_alive: Optional[bool] = None
|
||||
found_distance_km: Optional[float] = None
|
||||
found_direction: Optional[str] = None
|
||||
found_location_type: Optional[str] = None
|
||||
found_lat: Optional[float] = None
|
||||
found_lon: Optional[float] = None
|
||||
search_duration_hours: Optional[float] = None
|
||||
who_found: Optional[str] = None
|
||||
|
||||
# Мета
|
||||
confidence_avg: Optional[float] = None
|
||||
raw_text: Optional[str] = None
|
||||
analysis_log: Optional[Dict[str, Any]] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class SearchZone(BaseModel):
|
||||
"""Search zone with priority and details"""
|
||||
priority: int = Field(..., ge=1, le=3)
|
||||
name: str
|
||||
direction: str
|
||||
distance_km: float
|
||||
score: float
|
||||
reasoning: str
|
||||
coordinates: Optional[List[List[float]]] = None
|
||||
|
||||
|
||||
class BehavioralProfile(BaseModel):
|
||||
"""Active behavioral profile"""
|
||||
type: str
|
||||
title: str
|
||||
recommendations: List[str]
|
||||
modifiers: Dict[str, float]
|
||||
|
||||
|
||||
class AnalysisResult(BaseModel):
|
||||
"""Complete analysis result"""
|
||||
|
||||
case_id: UUID
|
||||
urgency_level: str = Field(..., pattern="^(КРИТИЧЕСКИЙ|ВЫСОКИЙ|УМЕРЕННЫЙ)$")
|
||||
urgency_reason: str
|
||||
time_window_hours: Optional[float] = None
|
||||
|
||||
active_profiles: List[BehavioralProfile]
|
||||
immediate_actions: List[str] = Field(..., min_length=3, max_length=3)
|
||||
|
||||
search_zones: List[SearchZone] = Field(..., min_length=1, max_length=3)
|
||||
key_objects: List[str]
|
||||
|
||||
team_assignments: Optional[Dict[str, str]] = None
|
||||
behavioral_forecast: str
|
||||
dog_recommendations: Optional[List[str]] = None
|
||||
|
||||
max_distance_km: float
|
||||
confidence_score: float = Field(..., ge=0, le=1)
|
||||
|
||||
created_at: datetime
|
||||
execution_time: Optional[float] = None
|
||||
|
||||
model_config = ConfigDict(json_schema_extra={
|
||||
"example": {
|
||||
"case_id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"urgency_level": "ВЫСОКИЙ",
|
||||
"urgency_reason": "Ребёнок 8 лет, прошло 4 часа, температура +15°C",
|
||||
"time_window_hours": 12.0,
|
||||
"active_profiles": [
|
||||
{
|
||||
"type": "age_8_12",
|
||||
"title": "Возраст 8-12 лет",
|
||||
"recommendations": ["Радиус поиска до 3 км", "Проверить дороги и тропы"],
|
||||
"modifiers": {"distance": 1.0, "roads": 1.2}
|
||||
}
|
||||
],
|
||||
"immediate_actions": [
|
||||
"Перекрыть все дороги в радиусе 2 км",
|
||||
"Проверить водоёмы в радиусе 1 км",
|
||||
"Организовать оклик по имени"
|
||||
],
|
||||
"search_zones": [
|
||||
{
|
||||
"priority": 1,
|
||||
"name": "Лесной массив северо-восток",
|
||||
"direction": "СВ",
|
||||
"distance_km": 1.2,
|
||||
"score": 0.85,
|
||||
"reasoning": "Последнее направление движения, густой лес"
|
||||
}
|
||||
],
|
||||
"key_objects": ["Озеро Круглое (800м СВ)", "Лесная дорога (500м С)"],
|
||||
"behavioral_forecast": "Ребёнок скорее всего движется вдоль дороги или тропы",
|
||||
"max_distance_km": 3.5,
|
||||
"confidence_score": 0.82,
|
||||
"created_at": "2026-05-04T13:45:00Z"
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
"""Schema for creating a new user"""
|
||||
username: str = Field(..., min_length=3, max_length=100)
|
||||
email: str = Field(..., pattern=r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
|
||||
password: str = Field(..., min_length=8)
|
||||
full_name: Optional[str] = None
|
||||
role: str = Field(default="operator", pattern="^(operator|field|admin)$")
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
"""Schema for user output"""
|
||||
id: UUID
|
||||
username: str
|
||||
email: str
|
||||
full_name: Optional[str] = None
|
||||
role: str
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
last_login: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Seed script для создания тестовых пользователей
|
||||
Создаёт трёх пользователей с разными ролями: operator, field, admin
|
||||
"""
|
||||
import sys
|
||||
sys.path.append('/app')
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from passlib.context import CryptContext
|
||||
from database import SessionLocal
|
||||
from models import User
|
||||
import uuid
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
# Обрезаем пароль до 72 байт для bcrypt
|
||||
return pwd_context.hash(password[:72])
|
||||
|
||||
def create_test_users():
|
||||
"""Создание тестовых пользователей"""
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# Проверяем, есть ли уже пользователи
|
||||
existing_users = db.query(User).count()
|
||||
if existing_users > 0:
|
||||
print(f"В базе уже есть {existing_users} пользователей. Пропускаем seed.")
|
||||
return
|
||||
|
||||
test_users = [
|
||||
{
|
||||
"username": "operator",
|
||||
"email": "operator@mchs.by",
|
||||
"password": "pass123",
|
||||
"full_name": "Оператор ЦОУ",
|
||||
"role": "operator"
|
||||
},
|
||||
{
|
||||
"username": "field",
|
||||
"email": "field@mchs.by",
|
||||
"password": "pass123",
|
||||
"full_name": "Полевой работник",
|
||||
"role": "field"
|
||||
},
|
||||
{
|
||||
"username": "admin",
|
||||
"email": "admin@mchs.by",
|
||||
"password": "pass123",
|
||||
"full_name": "Администратор",
|
||||
"role": "admin"
|
||||
}
|
||||
]
|
||||
|
||||
print("Создание тестовых пользователей...")
|
||||
|
||||
for user_data in test_users:
|
||||
password = user_data.pop("password")
|
||||
hashed_password = get_password_hash(password)
|
||||
|
||||
db_user = User(
|
||||
id=uuid.uuid4(),
|
||||
hashed_password=hashed_password,
|
||||
is_active=True,
|
||||
**user_data
|
||||
)
|
||||
db.add(db_user)
|
||||
print(f" + {user_data['username']} ({user_data['role']})")
|
||||
|
||||
db.commit()
|
||||
print("\nТестовые пользователи созданы!")
|
||||
print("\nДля входа используйте пароль: pass123")
|
||||
print(" operator / pass123")
|
||||
print(" field / pass123")
|
||||
print(" admin / pass123")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка: {e}")
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
create_test_users()
|
||||
@@ -0,0 +1,23 @@
|
||||
from .claude_service import analyze_case
|
||||
from .stats_service import get_statistical_recommendation
|
||||
from .scoring_service import WeightedScorer, create_scorer_for_case, get_weight_explanation
|
||||
from .geo_service import build_search_zones, haversine, Zone
|
||||
from .distance_service import calculate_max_distance, get_distance_priors, get_distance_statistics
|
||||
from .psychotype_service import detect_psychotype, get_psychotype_modifiers, get_search_recommendations
|
||||
|
||||
__all__ = [
|
||||
"analyze_case",
|
||||
"get_statistical_recommendation",
|
||||
"WeightedScorer",
|
||||
"create_scorer_for_case",
|
||||
"get_weight_explanation",
|
||||
"build_search_zones",
|
||||
"haversine",
|
||||
"Zone",
|
||||
"calculate_max_distance",
|
||||
"get_distance_priors",
|
||||
"get_distance_statistics",
|
||||
"detect_psychotype",
|
||||
"get_psychotype_modifiers",
|
||||
"get_search_recommendations"
|
||||
]
|
||||
@@ -0,0 +1,312 @@
|
||||
"""
|
||||
Claude AI service for case analysis with fallback to scoring service.
|
||||
Integrates geo_service zones and scoring_service rankings.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
from typing import Dict, List, Optional
|
||||
from pydantic import BaseModel
|
||||
import httpx
|
||||
|
||||
from .geo_service import build_search_zones
|
||||
from .scoring_service import WeightedScorer
|
||||
|
||||
|
||||
class PrimaryZone(BaseModel):
|
||||
priority: int
|
||||
name: str
|
||||
direction: str
|
||||
distance: float
|
||||
reason: str
|
||||
|
||||
|
||||
class AnalysisResult(BaseModel):
|
||||
urgency: str # "критическая", "высокая", "средняя", "низкая"
|
||||
primary_zones: List[PrimaryZone]
|
||||
search_radius_km: float
|
||||
key_locations: List[str]
|
||||
behavioral_prediction: str
|
||||
immediate_actions: List[str]
|
||||
summary: str
|
||||
fallback_used: bool = False
|
||||
|
||||
|
||||
async def analyze_case(case_data: dict) -> AnalysisResult:
|
||||
"""
|
||||
Анализирует данные случая с помощью Claude API с fallback на scoring_service.
|
||||
|
||||
Интегрирует:
|
||||
- geo_service: построение зон поиска
|
||||
- scoring_service: оценка и ранжирование зон
|
||||
- Claude API: интеллектуальный анализ (если доступен)
|
||||
|
||||
Args:
|
||||
case_data: Словарь с данными случая
|
||||
- age: возраст
|
||||
- gender: пол
|
||||
- terrain: местность
|
||||
- weather: погода
|
||||
- time_missing: время пропажи
|
||||
- last_location: последнее местоположение
|
||||
- lat, lon: координаты (опционально)
|
||||
- profiles: поведенческие профили (опционально)
|
||||
- season: сезон (опционально)
|
||||
|
||||
Returns:
|
||||
AnalysisResult: Структурированный результат анализа
|
||||
"""
|
||||
# Попытка использовать Claude API
|
||||
api_key = os.getenv("ANTHROPIC_API_KEY")
|
||||
|
||||
if api_key:
|
||||
try:
|
||||
return await analyze_with_claude(case_data, api_key)
|
||||
except Exception as e:
|
||||
# Логируем ошибку и переходим на fallback
|
||||
print(f"Claude API unavailable: {e}. Using fallback scoring service.")
|
||||
|
||||
# Fallback: используем только scoring_service
|
||||
return await analyze_with_fallback(case_data)
|
||||
|
||||
|
||||
async def analyze_with_claude(case_data: dict, api_key: str) -> AnalysisResult:
|
||||
"""
|
||||
Анализ с помощью Claude API с интеграцией geo и scoring сервисов.
|
||||
"""
|
||||
# Построить зоны поиска если есть координаты
|
||||
zones_data = ""
|
||||
if case_data.get('lat') and case_data.get('lon'):
|
||||
try:
|
||||
zones = await build_search_zones(
|
||||
case_data['lat'],
|
||||
case_data['lon'],
|
||||
case_data
|
||||
)
|
||||
|
||||
# Ранжировать зоны
|
||||
scorer = WeightedScorer()
|
||||
zones_dict = [
|
||||
{
|
||||
'direction': z.direction,
|
||||
'distance_km': z.distance_km,
|
||||
'forest_pct': z.forest_pct,
|
||||
'road_density': z.road_density,
|
||||
'water_distance_km': z.water_distance_km,
|
||||
'settlement_distance_km': z.settlement_distance_km
|
||||
}
|
||||
for z in zones
|
||||
]
|
||||
|
||||
ranked_zones = scorer.rank_zones(zones_dict, case_data)
|
||||
|
||||
# Топ-5 зон для промпта
|
||||
top_zones = ranked_zones[:5]
|
||||
zones_data = "\n\nТОП-5 ПРИОРИТЕТНЫХ ЗОН (по scoring_service):\n"
|
||||
for zone in top_zones:
|
||||
zones_data += f"- {zone['direction']} направление, {zone['distance_km']}км: "
|
||||
zones_data += f"оценка {zone['score']}/100, "
|
||||
zones_data += f"лес {zone['forest_pct']}%, "
|
||||
zones_data += f"дороги {zone['road_density']} км/км²\n"
|
||||
except Exception as e:
|
||||
print(f"Geo/scoring service error: {e}")
|
||||
|
||||
# Формируем промпт на русском языке
|
||||
prompt = f"""Ты — эксперт по поисково-спасательным операциям (ПСО) МЧС Республики Беларусь. Проанализируй следующий случай пропажи человека и дай структурированные рекомендации.
|
||||
|
||||
ДАННЫЕ СЛУЧАЯ:
|
||||
- Возраст пропавшего: {case_data.get('age', 'не указан')} лет
|
||||
- Пол: {case_data.get('gender', 'не указан')}
|
||||
- Местность: {case_data.get('terrain', 'не указана')}
|
||||
- Погодные условия: {case_data.get('weather', 'не указаны')}
|
||||
- Время пропажи: {case_data.get('time_missing', 'не указано')}
|
||||
- Последнее известное местоположение: {case_data.get('last_location', 'не указано')}
|
||||
- Особые обстоятельства: {case_data.get('circumstances', 'нет')}
|
||||
- Физическое состояние: {case_data.get('physical_condition', 'не указано')}
|
||||
- Опыт нахождения на природе: {case_data.get('experience', 'не указан')}
|
||||
- Поведенческие профили: {', '.join(case_data.get('profiles', [])) if case_data.get('profiles') else 'нет'}
|
||||
- Сезон: {case_data.get('season', 'не указан')}{zones_data}
|
||||
|
||||
ЗАДАЧА:
|
||||
Предоставь детальный анализ в формате JSON со следующими полями:
|
||||
|
||||
1. urgency: Уровень срочности ("критическая", "высокая", "средняя", "низкая")
|
||||
2. primary_zones: Массив из 3-5 приоритетных зон поиска, каждая с полями:
|
||||
- priority: номер приоритета (1 = самый высокий)
|
||||
- name: название зоны (например "Ближний лес", "Водоём на севере")
|
||||
- direction: направление от последней точки (N, NE, E, SE, S, SW, W, NW)
|
||||
- distance: расстояние в км
|
||||
- reason: обоснование выбора этой зоны
|
||||
3. search_radius_km: Рекомендуемый радиус поиска в километрах
|
||||
4. key_locations: Массив ключевых типов локаций для проверки (водоемы, дороги, постройки и т.д.)
|
||||
5. behavioral_prediction: Прогноз поведения пропавшего на основе возраста и обстоятельств
|
||||
6. immediate_actions: Массив немедленных действий, которые нужно предпринять
|
||||
7. summary: Краткое резюме анализа (2-3 предложения)
|
||||
|
||||
ВАЖНО: Учитывай данные из scoring_service при формировании primary_zones. Отвечай ТОЛЬКО валидным JSON без дополнительного текста."""
|
||||
|
||||
# Вызываем Anthropic API
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
headers={
|
||||
"x-api-key": api_key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
json={
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"max_tokens": 4096,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"Anthropic API error: {response.status_code} - {response.text}")
|
||||
|
||||
result = response.json()
|
||||
content = result["content"][0]["text"]
|
||||
|
||||
# Парсим JSON из ответа
|
||||
# Убираем возможные markdown блоки кода
|
||||
if "```json" in content:
|
||||
content = content.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in content:
|
||||
content = content.split("```")[1].split("```")[0].strip()
|
||||
|
||||
analysis_data = json.loads(content)
|
||||
analysis_data['fallback_used'] = False
|
||||
|
||||
# Преобразуем в Pydantic модель
|
||||
return AnalysisResult(**analysis_data)
|
||||
|
||||
|
||||
async def analyze_with_fallback(case_data: dict) -> AnalysisResult:
|
||||
"""
|
||||
Fallback анализ используя только scoring_service без Claude API.
|
||||
"""
|
||||
# Определяем срочность на основе возраста и профилей
|
||||
age = case_data.get('age', 10)
|
||||
profiles = case_data.get('profiles', [])
|
||||
|
||||
if age <= 4 or 'эпилепсия' in profiles or 'РАС' in profiles:
|
||||
urgency = "критическая"
|
||||
elif age <= 7 or 'велосипед' in profiles:
|
||||
urgency = "высокая"
|
||||
elif age <= 12:
|
||||
urgency = "средняя"
|
||||
else:
|
||||
urgency = "средняя"
|
||||
|
||||
# Построить зоны если есть координаты
|
||||
primary_zones = []
|
||||
search_radius_km = 5.0
|
||||
|
||||
if case_data.get('lat') and case_data.get('lon'):
|
||||
try:
|
||||
zones = await build_search_zones(
|
||||
case_data['lat'],
|
||||
case_data['lon'],
|
||||
case_data
|
||||
)
|
||||
|
||||
# Ранжировать зоны
|
||||
scorer = WeightedScorer()
|
||||
zones_dict = [
|
||||
{
|
||||
'direction': z.direction,
|
||||
'distance_km': z.distance_km,
|
||||
'forest_pct': z.forest_pct,
|
||||
'road_density': z.road_density,
|
||||
'water_distance_km': z.water_distance_km,
|
||||
'settlement_distance_km': z.settlement_distance_km
|
||||
}
|
||||
for z in zones
|
||||
]
|
||||
|
||||
ranked_zones = scorer.rank_zones(zones_dict, case_data)
|
||||
|
||||
# Топ-5 зон
|
||||
for i, zone in enumerate(ranked_zones[:5]):
|
||||
primary_zones.append(PrimaryZone(
|
||||
priority=i + 1,
|
||||
name=f"Зона {zone['direction']} {zone['distance_km']}км",
|
||||
direction=zone['direction'],
|
||||
distance=zone['distance_km'],
|
||||
reason=f"Оценка {zone['score']}/100 по scoring_service"
|
||||
))
|
||||
|
||||
# Радиус на основе дистанции
|
||||
search_radius_km = scorer.distance_multiplier * 2.0
|
||||
|
||||
except Exception as e:
|
||||
print(f"Geo/scoring service error in fallback: {e}")
|
||||
|
||||
# Если зоны не построены, используем базовые
|
||||
if not primary_zones:
|
||||
primary_zones = [
|
||||
PrimaryZone(
|
||||
priority=1,
|
||||
name="Ближняя зона",
|
||||
direction="N",
|
||||
distance=0.5,
|
||||
reason="Базовая зона поиска"
|
||||
),
|
||||
PrimaryZone(
|
||||
priority=2,
|
||||
name="Средняя зона",
|
||||
direction="E",
|
||||
distance=1.0,
|
||||
reason="Расширенная зона поиска"
|
||||
)
|
||||
]
|
||||
|
||||
# Ключевые локации на основе возраста
|
||||
if age <= 7:
|
||||
key_locations = ["водоёмы", "укрытия", "густая растительность", "ближайшие постройки"]
|
||||
elif age <= 12:
|
||||
key_locations = ["водоёмы", "дороги", "тропы", "лесные массивы"]
|
||||
else:
|
||||
key_locations = ["дороги", "населённые пункты", "транспортные узлы", "водоёмы"]
|
||||
|
||||
# Поведенческий прогноз
|
||||
if 'РАС' in profiles:
|
||||
behavioral_prediction = "Высокий риск движения к водоёмам и ж/д путям. Может не откликаться на имя."
|
||||
elif age <= 4:
|
||||
behavioral_prediction = "Минимальное движение, вероятно находится близко к точке потери."
|
||||
elif age <= 12:
|
||||
behavioral_prediction = "Умеренное движение, может следовать по тропам или дорогам."
|
||||
else:
|
||||
behavioral_prediction = "Целенаправленное движение, возможен выход к населённым пунктам."
|
||||
|
||||
# Немедленные действия
|
||||
immediate_actions = [
|
||||
"Организовать поисковые группы",
|
||||
"Проверить ближайшие водоёмы",
|
||||
"Опросить свидетелей в районе последнего местоположения"
|
||||
]
|
||||
|
||||
if 'РАС' in profiles:
|
||||
immediate_actions.insert(0, "КРИТИЧНО: Перекрыть все водоёмы и ж/д пути в радиусе 5 км")
|
||||
|
||||
if 'велосипед' in profiles:
|
||||
immediate_actions.insert(0, "Расширить зону поиска до 10-15 км, проверить дорожные камеры")
|
||||
|
||||
summary = f"Случай классифицирован как {urgency} срочность. "
|
||||
summary += f"Рекомендуемый радиус поиска: {search_radius_km} км. "
|
||||
summary += f"Приоритет: {key_locations[0]}."
|
||||
|
||||
return AnalysisResult(
|
||||
urgency=urgency,
|
||||
primary_zones=primary_zones,
|
||||
search_radius_km=search_radius_km,
|
||||
key_locations=key_locations,
|
||||
behavioral_prediction=behavioral_prediction,
|
||||
immediate_actions=immediate_actions,
|
||||
summary=summary,
|
||||
fallback_used=True
|
||||
)
|
||||
@@ -0,0 +1,316 @@
|
||||
"""
|
||||
Distance calculation service based on search and rescue statistics.
|
||||
|
||||
Implements distance formulas and prior probabilities based on:
|
||||
- PSO EXTREMUM data (400 cases, 2015)
|
||||
- Age-based movement speeds
|
||||
- Terrain and weather modifiers
|
||||
"""
|
||||
from typing import Dict
|
||||
|
||||
|
||||
def calculate_max_distance(case_data: dict) -> float:
|
||||
"""
|
||||
Calculate maximum probable distance using the formula:
|
||||
Distance = Time × НормС × СП × СКД × СУ × СУТ × ВВС × ВП
|
||||
|
||||
Where:
|
||||
- Time: elapsed time in hours
|
||||
- НормС: base speed by age (km/h)
|
||||
- СП: terrain coefficient
|
||||
- СКД: coefficient for diagnosis (not implemented yet)
|
||||
- СУ: coefficient for urgency (not implemented yet)
|
||||
- СУТ: fatigue coefficient (5% reduction per hour)
|
||||
- ВВС: time of day coefficient
|
||||
- ВП: weather coefficient
|
||||
|
||||
Args:
|
||||
case_data: Dictionary with case information
|
||||
- age: age in years
|
||||
- elapsed_hours: time elapsed since last seen
|
||||
- terrain_primary: terrain type
|
||||
- time_of_day: time of day (день/ночь/сумерки)
|
||||
- weather: weather conditions
|
||||
|
||||
Returns:
|
||||
Maximum probable distance in kilometers
|
||||
"""
|
||||
# Extract data
|
||||
age = case_data.get('age', 10)
|
||||
elapsed_hours = case_data.get('elapsed_hours', 1.0)
|
||||
terrain = case_data.get('terrain_primary', 'лес')
|
||||
time_of_day = case_data.get('time_of_day', 'день')
|
||||
weather = case_data.get('weather', 'нет')
|
||||
|
||||
# НормС - Base speed by age (km/h)
|
||||
base_speed = get_base_speed(age)
|
||||
|
||||
# СП - Terrain coefficient
|
||||
terrain_coef = get_terrain_coefficient(terrain)
|
||||
|
||||
# СКД - Diagnosis coefficient (placeholder, can be expanded)
|
||||
diagnosis_coef = 1.0
|
||||
|
||||
# СУ - Urgency coefficient (placeholder, can be expanded)
|
||||
urgency_coef = 1.0
|
||||
|
||||
# СУТ - Fatigue coefficient (5% reduction per hour)
|
||||
fatigue_coef = max(0.3, 1.0 - (0.05 * elapsed_hours))
|
||||
|
||||
# ВВС - Time of day coefficient
|
||||
time_coef = get_time_of_day_coefficient(time_of_day)
|
||||
|
||||
# ВП - Weather coefficient
|
||||
weather_coef = get_weather_coefficient(weather)
|
||||
|
||||
# Calculate distance
|
||||
distance = (
|
||||
elapsed_hours *
|
||||
base_speed *
|
||||
terrain_coef *
|
||||
diagnosis_coef *
|
||||
urgency_coef *
|
||||
fatigue_coef *
|
||||
time_coef *
|
||||
weather_coef
|
||||
)
|
||||
|
||||
return round(distance, 2)
|
||||
|
||||
|
||||
def get_base_speed(age: int) -> float:
|
||||
"""
|
||||
Get base movement speed by age (НормС).
|
||||
|
||||
Args:
|
||||
age: Age in years
|
||||
|
||||
Returns:
|
||||
Base speed in km/h
|
||||
"""
|
||||
# Скорость смещения потерявшегося ребёнка (не скорость ходьбы)
|
||||
# ПСО ЭКСТРЕМУМ: 94% найдены в пределах 3 км
|
||||
if age <= 2:
|
||||
return 0.3
|
||||
elif age <= 5:
|
||||
return 0.7
|
||||
elif age <= 8:
|
||||
return 1.2
|
||||
elif age <= 12:
|
||||
return 1.5
|
||||
elif age <= 15:
|
||||
return 2.0
|
||||
elif age <= 17:
|
||||
return 2.5
|
||||
elif age <= 64:
|
||||
return 2.5
|
||||
else: # 65+
|
||||
return 1.5
|
||||
|
||||
|
||||
def get_terrain_coefficient(terrain: str) -> float:
|
||||
"""
|
||||
Get terrain movement coefficient (СП).
|
||||
|
||||
Args:
|
||||
terrain: Terrain type
|
||||
|
||||
Returns:
|
||||
Terrain coefficient (0.0 - 1.0)
|
||||
"""
|
||||
terrain_lower = terrain.lower()
|
||||
|
||||
terrain_map = {
|
||||
'лесная дорога': 0.8,
|
||||
'сложный лес': 0.25,
|
||||
'густой лес': 0.25,
|
||||
'простой лес': 0.5,
|
||||
'лес': 0.5,
|
||||
'дорога': 0.8,
|
||||
'тропа': 0.8,
|
||||
'болото': 0.2,
|
||||
'поле': 0.9,
|
||||
'луг': 0.9,
|
||||
'город': 1.0,
|
||||
'населённый пункт': 1.0,
|
||||
'горы': 0.3,
|
||||
'овраг': 0.3
|
||||
}
|
||||
|
||||
for key, value in terrain_map.items():
|
||||
if key in terrain_lower:
|
||||
return value
|
||||
|
||||
# Default for unknown terrain
|
||||
return 0.5
|
||||
|
||||
|
||||
def get_time_of_day_coefficient(time_of_day: str) -> float:
|
||||
"""
|
||||
Get time of day movement coefficient (ВВС).
|
||||
|
||||
Args:
|
||||
time_of_day: Time of day
|
||||
|
||||
Returns:
|
||||
Time coefficient (0.0 - 1.0)
|
||||
"""
|
||||
time_lower = time_of_day.lower()
|
||||
|
||||
if 'ночь' in time_lower:
|
||||
return 0.5
|
||||
elif 'сумерки' in time_lower or 'вечер' in time_lower:
|
||||
return 0.5
|
||||
else: # день
|
||||
return 1.0
|
||||
|
||||
|
||||
def get_weather_coefficient(weather: str) -> float:
|
||||
"""
|
||||
Get weather movement coefficient (ВП).
|
||||
|
||||
Args:
|
||||
weather: Weather conditions
|
||||
|
||||
Returns:
|
||||
Weather coefficient (0.0 - 1.0)
|
||||
"""
|
||||
weather_lower = weather.lower()
|
||||
|
||||
if 'ливень' in weather_lower or 'сильный дождь' in weather_lower:
|
||||
return 0.6
|
||||
elif 'дождь' in weather_lower:
|
||||
return 0.8
|
||||
elif 'туман' in weather_lower:
|
||||
return 0.7
|
||||
elif 'снег' in weather_lower or 'метель' in weather_lower:
|
||||
return 0.6
|
||||
elif 'жара' in weather_lower:
|
||||
return 0.8
|
||||
else: # нет / ясно
|
||||
return 1.0
|
||||
|
||||
|
||||
def get_distance_priors(age_years: int) -> Dict[str, float]:
|
||||
"""
|
||||
Get prior probabilities for distance zones based on age.
|
||||
|
||||
Based on PSO EXTREMUM data (400 cases, 2015).
|
||||
|
||||
Args:
|
||||
age_years: Age in years
|
||||
|
||||
Returns:
|
||||
Dictionary with distance zone probabilities
|
||||
"""
|
||||
if age_years < 8:
|
||||
# До 8 лет - дети младшего возраста
|
||||
return {
|
||||
'0_500m': 0.45,
|
||||
'500_1500m': 0.35,
|
||||
'1500_2500m': 0.15,
|
||||
'2500_3500m': 0.04,
|
||||
'3500_plus': 0.01
|
||||
}
|
||||
elif age_years <= 12:
|
||||
# 8-12 лет - дети среднего возраста
|
||||
return {
|
||||
'0_500m': 0.28,
|
||||
'500_1500m': 0.25,
|
||||
'1500_2500m': 0.22,
|
||||
'2500_3500m': 0.19,
|
||||
'3500_5000m': 0.03,
|
||||
'5000_plus': 0.03
|
||||
}
|
||||
elif age_years <= 17:
|
||||
# 13-17 лет - подростки
|
||||
return {
|
||||
'0_500m': 0.15,
|
||||
'500_1500m': 0.20,
|
||||
'1500_2500m': 0.25,
|
||||
'2500_3500m': 0.20,
|
||||
'3500_5000m': 0.12,
|
||||
'5000_plus': 0.08
|
||||
}
|
||||
elif age_years <= 64:
|
||||
# 18-64 года - взрослые
|
||||
return {
|
||||
'0_500m': 0.12,
|
||||
'500_1500m': 0.18,
|
||||
'1500_2500m': 0.22,
|
||||
'2500_3500m': 0.20,
|
||||
'3500_5000m': 0.15,
|
||||
'5000_plus': 0.13
|
||||
}
|
||||
else:
|
||||
# 65+ лет - пожилые
|
||||
return {
|
||||
'0_500m': 0.35,
|
||||
'500_1500m': 0.30,
|
||||
'1500_2500m': 0.20,
|
||||
'2500_3500m': 0.10,
|
||||
'3500_5000m': 0.03,
|
||||
'5000_plus': 0.02
|
||||
}
|
||||
|
||||
|
||||
def get_distance_zone(distance_km: float) -> str:
|
||||
"""
|
||||
Get distance zone name for a given distance.
|
||||
|
||||
Args:
|
||||
distance_km: Distance in kilometers
|
||||
|
||||
Returns:
|
||||
Zone name
|
||||
"""
|
||||
if distance_km < 0.5:
|
||||
return '0_500m'
|
||||
elif distance_km < 1.5:
|
||||
return '500_1500m'
|
||||
elif distance_km < 2.5:
|
||||
return '1500_2500m'
|
||||
elif distance_km < 3.5:
|
||||
return '2500_3500m'
|
||||
elif distance_km < 5.0:
|
||||
return '3500_5000m'
|
||||
else:
|
||||
return '5000_plus'
|
||||
|
||||
|
||||
def get_distance_statistics(age_years: int, elapsed_hours: float, terrain: str) -> Dict:
|
||||
"""
|
||||
Get comprehensive distance statistics for a case.
|
||||
|
||||
Args:
|
||||
age_years: Age in years
|
||||
elapsed_hours: Time elapsed since last seen
|
||||
terrain: Terrain type
|
||||
|
||||
Returns:
|
||||
Dictionary with distance statistics
|
||||
"""
|
||||
# Calculate max distance
|
||||
case_data = {
|
||||
'age': age_years,
|
||||
'elapsed_hours': elapsed_hours,
|
||||
'terrain_primary': terrain,
|
||||
'time_of_day': 'день',
|
||||
'weather': 'нет'
|
||||
}
|
||||
max_distance = calculate_max_distance(case_data)
|
||||
|
||||
# Get priors
|
||||
priors = get_distance_priors(age_years)
|
||||
|
||||
# Get current zone
|
||||
current_zone = get_distance_zone(max_distance)
|
||||
|
||||
return {
|
||||
'max_distance_km': max_distance,
|
||||
'current_zone': current_zone,
|
||||
'zone_probability': priors.get(current_zone, 0.0),
|
||||
'all_priors': priors,
|
||||
'base_speed_kmh': get_base_speed(age_years),
|
||||
'terrain_coefficient': get_terrain_coefficient(terrain)
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
"""
|
||||
Geo service for building search zones and querying OpenStreetMap data via Overpass API.
|
||||
"""
|
||||
import math
|
||||
import json
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Zone(BaseModel):
|
||||
"""Search zone with geographic features."""
|
||||
direction: str # N, NE, E, SE, S, SW, W, NW
|
||||
distance_km: float
|
||||
forest_pct: float
|
||||
road_density: float # km of roads per km²
|
||||
water_distance_km: Optional[float]
|
||||
settlement_distance_km: Optional[float]
|
||||
|
||||
|
||||
# Cache configuration
|
||||
CACHE_DIR = Path("/tmp/overpass_cache")
|
||||
CACHE_TTL_HOURS = 24
|
||||
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
||||
|
||||
# Direction mappings
|
||||
DIRECTIONS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
|
||||
DIRECTION_ANGLES = {
|
||||
"N": 0,
|
||||
"NE": 45,
|
||||
"E": 90,
|
||||
"SE": 135,
|
||||
"S": 180,
|
||||
"SW": 225,
|
||||
"W": 270,
|
||||
"NW": 315
|
||||
}
|
||||
|
||||
# Search distances computed dynamically from max_distance_km
|
||||
def _build_search_distances(max_distance_km: float) -> list:
|
||||
max_m = int(max_distance_km * 1000)
|
||||
raw = [int(max_m * 0.25), int(max_m * 0.50), int(max_m * 0.75), max_m]
|
||||
clamped = [max(200, min(5000, d)) for d in raw]
|
||||
return sorted(set(clamped))
|
||||
|
||||
|
||||
def haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""
|
||||
Calculate distance between two points on Earth using Haversine formula.
|
||||
|
||||
Args:
|
||||
lat1, lon1: First point coordinates
|
||||
lat2, lon2: Second point coordinates
|
||||
|
||||
Returns:
|
||||
Distance in kilometers
|
||||
"""
|
||||
R = 6371 # Earth radius in km
|
||||
|
||||
lat1_rad = math.radians(lat1)
|
||||
lat2_rad = math.radians(lat2)
|
||||
dlat = math.radians(lat2 - lat1)
|
||||
dlon = math.radians(lon2 - lon1)
|
||||
|
||||
a = (math.sin(dlat / 2) ** 2 +
|
||||
math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon / 2) ** 2)
|
||||
c = 2 * math.asin(math.sqrt(a))
|
||||
|
||||
return R * c
|
||||
|
||||
|
||||
def get_sector_bounds(lat: float, lon: float, direction: str, radius_m: int) -> Tuple[float, float, float, float]:
|
||||
"""
|
||||
Calculate bounding box for a sector.
|
||||
|
||||
Args:
|
||||
lat, lon: Center point
|
||||
direction: Sector direction (N, NE, E, etc.)
|
||||
radius_m: Radius in meters
|
||||
|
||||
Returns:
|
||||
(min_lat, min_lon, max_lat, max_lon)
|
||||
"""
|
||||
# Convert radius to degrees (approximate)
|
||||
radius_deg = radius_m / 111320 # 1 degree ≈ 111.32 km at equator
|
||||
|
||||
angle = DIRECTION_ANGLES[direction]
|
||||
angle_rad = math.radians(angle)
|
||||
|
||||
# Calculate sector boundaries (45° sectors)
|
||||
angle_start = angle - 22.5
|
||||
angle_end = angle + 22.5
|
||||
|
||||
# Simple bounding box (can be optimized for actual sector shape)
|
||||
lat_offset = radius_deg * math.cos(angle_rad)
|
||||
lon_offset = radius_deg * math.sin(angle_rad) / math.cos(math.radians(lat))
|
||||
|
||||
min_lat = min(lat, lat + lat_offset) - radius_deg * 0.5
|
||||
max_lat = max(lat, lat + lat_offset) + radius_deg * 0.5
|
||||
min_lon = min(lon, lon + lon_offset) - radius_deg * 0.5
|
||||
max_lon = max(lon, lon + lon_offset) + radius_deg * 0.5
|
||||
|
||||
return (min_lat, min_lon, max_lat, max_lon)
|
||||
|
||||
|
||||
def get_cache_key(query: str) -> str:
|
||||
"""Generate cache key from query."""
|
||||
return hashlib.md5(query.encode()).hexdigest()
|
||||
|
||||
|
||||
def get_cached_result(cache_key: str) -> Optional[Dict]:
|
||||
"""Get cached Overpass API result if not expired."""
|
||||
CACHE_DIR.mkdir(exist_ok=True)
|
||||
cache_file = CACHE_DIR / f"{cache_key}.json"
|
||||
|
||||
if not cache_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(cache_file, 'r') as f:
|
||||
cached = json.load(f)
|
||||
|
||||
cached_time = datetime.fromisoformat(cached['timestamp'])
|
||||
if datetime.now() - cached_time > timedelta(hours=CACHE_TTL_HOURS):
|
||||
cache_file.unlink()
|
||||
return None
|
||||
|
||||
return cached['data']
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def save_to_cache(cache_key: str, data: Dict):
|
||||
"""Save Overpass API result to cache."""
|
||||
CACHE_DIR.mkdir(exist_ok=True)
|
||||
cache_file = CACHE_DIR / f"{cache_key}.json"
|
||||
|
||||
try:
|
||||
with open(cache_file, 'w') as f:
|
||||
json.dump({
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'data': data
|
||||
}, f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def query_overpass(query: str) -> Dict:
|
||||
"""
|
||||
Query Overpass API with caching.
|
||||
|
||||
Args:
|
||||
query: Overpass QL query
|
||||
|
||||
Returns:
|
||||
API response as dict
|
||||
"""
|
||||
cache_key = get_cache_key(query)
|
||||
|
||||
# Check cache
|
||||
cached = get_cached_result(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# Query API
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
OVERPASS_URL,
|
||||
data={'data': query},
|
||||
headers={'Content-Type': 'application/x-www-form-urlencoded'}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Save to cache
|
||||
save_to_cache(cache_key, data)
|
||||
|
||||
return data
|
||||
except Exception as e:
|
||||
# Return empty result on error
|
||||
return {'elements': []}
|
||||
|
||||
|
||||
def calculate_road_length(elements: List[Dict]) -> float:
|
||||
"""
|
||||
Calculate total road length from Overpass way elements.
|
||||
|
||||
Args:
|
||||
elements: List of way elements from Overpass
|
||||
|
||||
Returns:
|
||||
Total length in kilometers
|
||||
"""
|
||||
total_length = 0.0
|
||||
|
||||
for element in elements:
|
||||
if element.get('type') != 'way':
|
||||
continue
|
||||
|
||||
nodes = element.get('geometry', [])
|
||||
if len(nodes) < 2:
|
||||
continue
|
||||
|
||||
# Calculate length by summing distances between consecutive nodes
|
||||
for i in range(len(nodes) - 1):
|
||||
lat1, lon1 = nodes[i]['lat'], nodes[i]['lon']
|
||||
lat2, lon2 = nodes[i + 1]['lat'], nodes[i + 1]['lon']
|
||||
total_length += haversine(lat1, lon1, lat2, lon2)
|
||||
|
||||
return total_length
|
||||
|
||||
|
||||
def find_nearest_distance(lat: float, lon: float, elements: List[Dict]) -> Optional[float]:
|
||||
"""
|
||||
Find distance to nearest element.
|
||||
|
||||
Args:
|
||||
lat, lon: Reference point
|
||||
elements: List of node elements from Overpass
|
||||
|
||||
Returns:
|
||||
Distance in kilometers, or None if no elements
|
||||
"""
|
||||
if not elements:
|
||||
return None
|
||||
|
||||
min_distance = float('inf')
|
||||
|
||||
for element in elements:
|
||||
if element.get('type') != 'node':
|
||||
continue
|
||||
|
||||
elem_lat = element.get('lat')
|
||||
elem_lon = element.get('lon')
|
||||
|
||||
if elem_lat is None or elem_lon is None:
|
||||
continue
|
||||
|
||||
distance = haversine(lat, lon, elem_lat, elem_lon)
|
||||
min_distance = min(min_distance, distance)
|
||||
|
||||
return min_distance if min_distance != float('inf') else None
|
||||
|
||||
|
||||
def calculate_forest_coverage(elements: List[Dict], radius_m: int) -> float:
|
||||
"""
|
||||
Estimate forest coverage percentage.
|
||||
|
||||
Args:
|
||||
elements: List of way elements from Overpass
|
||||
radius_m: Search radius in meters
|
||||
|
||||
Returns:
|
||||
Forest coverage as percentage (0-100)
|
||||
"""
|
||||
if not elements:
|
||||
return 0.0
|
||||
|
||||
# Approximate: count forest ways and estimate coverage
|
||||
# This is a simplified calculation
|
||||
forest_ways = len([e for e in elements if e.get('type') == 'way'])
|
||||
|
||||
# Rough heuristic: each forest way covers ~0.1 km²
|
||||
# Total search area = π * r²
|
||||
search_area_km2 = math.pi * (radius_m / 1000) ** 2
|
||||
estimated_forest_km2 = forest_ways * 0.1
|
||||
|
||||
coverage_pct = min(100.0, (estimated_forest_km2 / search_area_km2) * 100)
|
||||
|
||||
return round(coverage_pct, 1)
|
||||
|
||||
|
||||
async def get_zone_features(lat: float, lon: float, direction: str, radius_m: int) -> Dict:
|
||||
"""
|
||||
Get geographic features for a zone using Overpass API.
|
||||
|
||||
Args:
|
||||
lat, lon: Center point
|
||||
direction: Sector direction
|
||||
radius_m: Search radius in meters
|
||||
|
||||
Returns:
|
||||
Dict with roads_km, water_distance_km, settlement_distance_km, forest_pct
|
||||
"""
|
||||
# Query roads
|
||||
roads_query = f"""
|
||||
[out:json];
|
||||
(
|
||||
way[highway](around:{radius_m},{lat},{lon});
|
||||
);
|
||||
out geom;
|
||||
"""
|
||||
roads_data = await query_overpass(roads_query)
|
||||
roads_km = calculate_road_length(roads_data.get('elements', []))
|
||||
|
||||
# Query water bodies
|
||||
water_query = f"""
|
||||
[out:json];
|
||||
(
|
||||
node[natural=water](around:{radius_m},{lat},{lon});
|
||||
way[natural=water](around:{radius_m},{lat},{lon});
|
||||
);
|
||||
out center;
|
||||
"""
|
||||
water_data = await query_overpass(water_query)
|
||||
water_distance = find_nearest_distance(lat, lon, water_data.get('elements', []))
|
||||
|
||||
# Query settlements
|
||||
settlement_query = f"""
|
||||
[out:json];
|
||||
(
|
||||
node[place~"village|town|city"](around:{radius_m},{lat},{lon});
|
||||
);
|
||||
out;
|
||||
"""
|
||||
settlement_data = await query_overpass(settlement_query)
|
||||
settlement_distance = find_nearest_distance(lat, lon, settlement_data.get('elements', []))
|
||||
|
||||
# Query forests
|
||||
forest_query = f"""
|
||||
[out:json];
|
||||
(
|
||||
way[landuse=forest](around:{radius_m},{lat},{lon});
|
||||
way[natural=wood](around:{radius_m},{lat},{lon});
|
||||
);
|
||||
out geom;
|
||||
"""
|
||||
forest_data = await query_overpass(forest_query)
|
||||
forest_pct = calculate_forest_coverage(forest_data.get('elements', []), radius_m)
|
||||
|
||||
# Calculate road density (km of roads per km²)
|
||||
search_area_km2 = math.pi * (radius_m / 1000) ** 2
|
||||
road_density = roads_km / search_area_km2 if search_area_km2 > 0 else 0.0
|
||||
|
||||
return {
|
||||
'roads_km': roads_km,
|
||||
'road_density': round(road_density, 2),
|
||||
'water_distance_km': water_distance,
|
||||
'settlement_distance_km': settlement_distance,
|
||||
'forest_pct': forest_pct
|
||||
}
|
||||
|
||||
|
||||
async def build_search_zones(lat: float, lon: float, case_data: dict, max_distance_km: float = 3.0) -> List[Zone]:
|
||||
"""
|
||||
Build search zones around a point.
|
||||
|
||||
Creates 8 directional sectors (N, NE, E, SE, S, SW, W, NW) at multiple distances
|
||||
(500m, 1000m, 2000m, 5000m) and queries geographic features for each.
|
||||
|
||||
Args:
|
||||
lat: Latitude of search origin
|
||||
lon: Longitude of search origin
|
||||
case_data: Case information (for future enhancements)
|
||||
|
||||
Returns:
|
||||
List of Zone objects with geographic features
|
||||
"""
|
||||
zones = []
|
||||
|
||||
for distance_m in _build_search_distances(max_distance_km):
|
||||
for direction in DIRECTIONS:
|
||||
# Get features for this zone
|
||||
features = await get_zone_features(lat, lon, direction, distance_m)
|
||||
|
||||
zone = Zone(
|
||||
direction=direction,
|
||||
distance_km=distance_m / 1000,
|
||||
forest_pct=features['forest_pct'],
|
||||
road_density=features['road_density'],
|
||||
water_distance_km=features['water_distance_km'],
|
||||
settlement_distance_km=features['settlement_distance_km']
|
||||
)
|
||||
|
||||
zones.append(zone)
|
||||
|
||||
return zones
|
||||
@@ -0,0 +1,377 @@
|
||||
"""
|
||||
Geo service for building search zones and querying OpenStreetMap data via Overpass API.
|
||||
"""
|
||||
import math
|
||||
import json
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Zone(BaseModel):
|
||||
"""Search zone with geographic features."""
|
||||
direction: str # N, NE, E, SE, S, SW, W, NW
|
||||
distance_km: float
|
||||
forest_pct: float
|
||||
road_density: float # km of roads per km²
|
||||
water_distance_km: Optional[float]
|
||||
settlement_distance_km: Optional[float]
|
||||
|
||||
|
||||
# Cache configuration
|
||||
CACHE_DIR = Path("/tmp/overpass_cache")
|
||||
CACHE_TTL_HOURS = 24
|
||||
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
||||
|
||||
# Direction mappings
|
||||
DIRECTIONS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
|
||||
DIRECTION_ANGLES = {
|
||||
"N": 0,
|
||||
"NE": 45,
|
||||
"E": 90,
|
||||
"SE": 135,
|
||||
"S": 180,
|
||||
"SW": 225,
|
||||
"W": 270,
|
||||
"NW": 315
|
||||
}
|
||||
|
||||
# Search distances in meters
|
||||
SEARCH_DISTANCES = [500, 1000, 2000, 5000]
|
||||
|
||||
|
||||
def haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""
|
||||
Calculate distance between two points on Earth using Haversine formula.
|
||||
|
||||
Args:
|
||||
lat1, lon1: First point coordinates
|
||||
lat2, lon2: Second point coordinates
|
||||
|
||||
Returns:
|
||||
Distance in kilometers
|
||||
"""
|
||||
R = 6371 # Earth radius in km
|
||||
|
||||
lat1_rad = math.radians(lat1)
|
||||
lat2_rad = math.radians(lat2)
|
||||
dlat = math.radians(lat2 - lat1)
|
||||
dlon = math.radians(lon2 - lon1)
|
||||
|
||||
a = (math.sin(dlat / 2) ** 2 +
|
||||
math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon / 2) ** 2)
|
||||
c = 2 * math.asin(math.sqrt(a))
|
||||
|
||||
return R * c
|
||||
|
||||
|
||||
def get_sector_bounds(lat: float, lon: float, direction: str, radius_m: int) -> Tuple[float, float, float, float]:
|
||||
"""
|
||||
Calculate bounding box for a sector.
|
||||
|
||||
Args:
|
||||
lat, lon: Center point
|
||||
direction: Sector direction (N, NE, E, etc.)
|
||||
radius_m: Radius in meters
|
||||
|
||||
Returns:
|
||||
(min_lat, min_lon, max_lat, max_lon)
|
||||
"""
|
||||
# Convert radius to degrees (approximate)
|
||||
radius_deg = radius_m / 111320 # 1 degree ≈ 111.32 km at equator
|
||||
|
||||
angle = DIRECTION_ANGLES[direction]
|
||||
angle_rad = math.radians(angle)
|
||||
|
||||
# Calculate sector boundaries (45° sectors)
|
||||
angle_start = angle - 22.5
|
||||
angle_end = angle + 22.5
|
||||
|
||||
# Simple bounding box (can be optimized for actual sector shape)
|
||||
lat_offset = radius_deg * math.cos(angle_rad)
|
||||
lon_offset = radius_deg * math.sin(angle_rad) / math.cos(math.radians(lat))
|
||||
|
||||
min_lat = min(lat, lat + lat_offset) - radius_deg * 0.5
|
||||
max_lat = max(lat, lat + lat_offset) + radius_deg * 0.5
|
||||
min_lon = min(lon, lon + lon_offset) - radius_deg * 0.5
|
||||
max_lon = max(lon, lon + lon_offset) + radius_deg * 0.5
|
||||
|
||||
return (min_lat, min_lon, max_lat, max_lon)
|
||||
|
||||
|
||||
def get_cache_key(query: str) -> str:
|
||||
"""Generate cache key from query."""
|
||||
return hashlib.md5(query.encode()).hexdigest()
|
||||
|
||||
|
||||
def get_cached_result(cache_key: str) -> Optional[Dict]:
|
||||
"""Get cached Overpass API result if not expired."""
|
||||
CACHE_DIR.mkdir(exist_ok=True)
|
||||
cache_file = CACHE_DIR / f"{cache_key}.json"
|
||||
|
||||
if not cache_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(cache_file, 'r') as f:
|
||||
cached = json.load(f)
|
||||
|
||||
cached_time = datetime.fromisoformat(cached['timestamp'])
|
||||
if datetime.now() - cached_time > timedelta(hours=CACHE_TTL_HOURS):
|
||||
cache_file.unlink()
|
||||
return None
|
||||
|
||||
return cached['data']
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def save_to_cache(cache_key: str, data: Dict):
|
||||
"""Save Overpass API result to cache."""
|
||||
CACHE_DIR.mkdir(exist_ok=True)
|
||||
cache_file = CACHE_DIR / f"{cache_key}.json"
|
||||
|
||||
try:
|
||||
with open(cache_file, 'w') as f:
|
||||
json.dump({
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'data': data
|
||||
}, f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def query_overpass(query: str) -> Dict:
|
||||
"""
|
||||
Query Overpass API with caching.
|
||||
|
||||
Args:
|
||||
query: Overpass QL query
|
||||
|
||||
Returns:
|
||||
API response as dict
|
||||
"""
|
||||
cache_key = get_cache_key(query)
|
||||
|
||||
# Check cache
|
||||
cached = get_cached_result(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# Query API
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
OVERPASS_URL,
|
||||
data={'data': query},
|
||||
headers={'Content-Type': 'application/x-www-form-urlencoded'}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Save to cache
|
||||
save_to_cache(cache_key, data)
|
||||
|
||||
return data
|
||||
except Exception as e:
|
||||
# Return empty result on error
|
||||
return {'elements': []}
|
||||
|
||||
|
||||
def calculate_road_length(elements: List[Dict]) -> float:
|
||||
"""
|
||||
Calculate total road length from Overpass way elements.
|
||||
|
||||
Args:
|
||||
elements: List of way elements from Overpass
|
||||
|
||||
Returns:
|
||||
Total length in kilometers
|
||||
"""
|
||||
total_length = 0.0
|
||||
|
||||
for element in elements:
|
||||
if element.get('type') != 'way':
|
||||
continue
|
||||
|
||||
nodes = element.get('geometry', [])
|
||||
if len(nodes) < 2:
|
||||
continue
|
||||
|
||||
# Calculate length by summing distances between consecutive nodes
|
||||
for i in range(len(nodes) - 1):
|
||||
lat1, lon1 = nodes[i]['lat'], nodes[i]['lon']
|
||||
lat2, lon2 = nodes[i + 1]['lat'], nodes[i + 1]['lon']
|
||||
total_length += haversine(lat1, lon1, lat2, lon2)
|
||||
|
||||
return total_length
|
||||
|
||||
|
||||
def find_nearest_distance(lat: float, lon: float, elements: List[Dict]) -> Optional[float]:
|
||||
"""
|
||||
Find distance to nearest element.
|
||||
|
||||
Args:
|
||||
lat, lon: Reference point
|
||||
elements: List of node elements from Overpass
|
||||
|
||||
Returns:
|
||||
Distance in kilometers, or None if no elements
|
||||
"""
|
||||
if not elements:
|
||||
return None
|
||||
|
||||
min_distance = float('inf')
|
||||
|
||||
for element in elements:
|
||||
if element.get('type') != 'node':
|
||||
continue
|
||||
|
||||
elem_lat = element.get('lat')
|
||||
elem_lon = element.get('lon')
|
||||
|
||||
if elem_lat is None or elem_lon is None:
|
||||
continue
|
||||
|
||||
distance = haversine(lat, lon, elem_lat, elem_lon)
|
||||
min_distance = min(min_distance, distance)
|
||||
|
||||
return min_distance if min_distance != float('inf') else None
|
||||
|
||||
|
||||
def calculate_forest_coverage(elements: List[Dict], radius_m: int) -> float:
|
||||
"""
|
||||
Estimate forest coverage percentage.
|
||||
|
||||
Args:
|
||||
elements: List of way elements from Overpass
|
||||
radius_m: Search radius in meters
|
||||
|
||||
Returns:
|
||||
Forest coverage as percentage (0-100)
|
||||
"""
|
||||
if not elements:
|
||||
return 0.0
|
||||
|
||||
# Approximate: count forest ways and estimate coverage
|
||||
# This is a simplified calculation
|
||||
forest_ways = len([e for e in elements if e.get('type') == 'way'])
|
||||
|
||||
# Rough heuristic: each forest way covers ~0.1 km²
|
||||
# Total search area = π * r²
|
||||
search_area_km2 = math.pi * (radius_m / 1000) ** 2
|
||||
estimated_forest_km2 = forest_ways * 0.1
|
||||
|
||||
coverage_pct = min(100.0, (estimated_forest_km2 / search_area_km2) * 100)
|
||||
|
||||
return round(coverage_pct, 1)
|
||||
|
||||
|
||||
async def get_zone_features(lat: float, lon: float, direction: str, radius_m: int) -> Dict:
|
||||
"""
|
||||
Get geographic features for a zone using Overpass API.
|
||||
|
||||
Args:
|
||||
lat, lon: Center point
|
||||
direction: Sector direction
|
||||
radius_m: Search radius in meters
|
||||
|
||||
Returns:
|
||||
Dict with roads_km, water_distance_km, settlement_distance_km, forest_pct
|
||||
"""
|
||||
# Query roads
|
||||
roads_query = f"""
|
||||
[out:json];
|
||||
(
|
||||
way[highway](around:{radius_m},{lat},{lon});
|
||||
);
|
||||
out geom;
|
||||
"""
|
||||
roads_data = await query_overpass(roads_query)
|
||||
roads_km = calculate_road_length(roads_data.get('elements', []))
|
||||
|
||||
# Query water bodies
|
||||
water_query = f"""
|
||||
[out:json];
|
||||
(
|
||||
node[natural=water](around:{radius_m},{lat},{lon});
|
||||
way[natural=water](around:{radius_m},{lat},{lon});
|
||||
);
|
||||
out center;
|
||||
"""
|
||||
water_data = await query_overpass(water_query)
|
||||
water_distance = find_nearest_distance(lat, lon, water_data.get('elements', []))
|
||||
|
||||
# Query settlements
|
||||
settlement_query = f"""
|
||||
[out:json];
|
||||
(
|
||||
node[place~"village|town|city"](around:{radius_m},{lat},{lon});
|
||||
);
|
||||
out;
|
||||
"""
|
||||
settlement_data = await query_overpass(settlement_query)
|
||||
settlement_distance = find_nearest_distance(lat, lon, settlement_data.get('elements', []))
|
||||
|
||||
# Query forests
|
||||
forest_query = f"""
|
||||
[out:json];
|
||||
(
|
||||
way[landuse=forest](around:{radius_m},{lat},{lon});
|
||||
way[natural=wood](around:{radius_m},{lat},{lon});
|
||||
);
|
||||
out geom;
|
||||
"""
|
||||
forest_data = await query_overpass(forest_query)
|
||||
forest_pct = calculate_forest_coverage(forest_data.get('elements', []), radius_m)
|
||||
|
||||
# Calculate road density (km of roads per km²)
|
||||
search_area_km2 = math.pi * (radius_m / 1000) ** 2
|
||||
road_density = roads_km / search_area_km2 if search_area_km2 > 0 else 0.0
|
||||
|
||||
return {
|
||||
'roads_km': roads_km,
|
||||
'road_density': round(road_density, 2),
|
||||
'water_distance_km': water_distance,
|
||||
'settlement_distance_km': settlement_distance,
|
||||
'forest_pct': forest_pct
|
||||
}
|
||||
|
||||
|
||||
async def build_search_zones(lat: float, lon: float, case_data: dict) -> List[Zone]:
|
||||
"""
|
||||
Build search zones around a point.
|
||||
|
||||
Creates 8 directional sectors (N, NE, E, SE, S, SW, W, NW) at multiple distances
|
||||
(500m, 1000m, 2000m, 5000m) and queries geographic features for each.
|
||||
|
||||
Args:
|
||||
lat: Latitude of search origin
|
||||
lon: Longitude of search origin
|
||||
case_data: Case information (for future enhancements)
|
||||
|
||||
Returns:
|
||||
List of Zone objects with geographic features
|
||||
"""
|
||||
zones = []
|
||||
|
||||
for distance_m in SEARCH_DISTANCES:
|
||||
for direction in DIRECTIONS:
|
||||
# Get features for this zone
|
||||
features = await get_zone_features(lat, lon, direction, distance_m)
|
||||
|
||||
zone = Zone(
|
||||
direction=direction,
|
||||
distance_km=distance_m / 1000,
|
||||
forest_pct=features['forest_pct'],
|
||||
road_density=features['road_density'],
|
||||
water_distance_km=features['water_distance_km'],
|
||||
settlement_distance_km=features['settlement_distance_km']
|
||||
)
|
||||
|
||||
zones.append(zone)
|
||||
|
||||
return zones
|
||||
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
Сервис определения психотипа пропавшего ребёнка.
|
||||
Маппинг согласно §6 контекста ВЕКТОР (Шаг 2б).
|
||||
|
||||
Основан на методике Рындиной О.Г., Ивановой О.Ю. (Чебоксары, 2015)
|
||||
8 психотипов по Грановской–Никольской (Кеттелл).
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Literal
|
||||
|
||||
|
||||
PsychotypeStr = Literal[
|
||||
'dominant',
|
||||
'harmonic',
|
||||
'anxious',
|
||||
'introvert_passive',
|
||||
'introvert_active'
|
||||
]
|
||||
|
||||
|
||||
def detect_psychotype(answers: Dict[str, str]) -> PsychotypeStr:
|
||||
"""
|
||||
Определяет психотип на основе 4 вопросов родителям (§6 контекста).
|
||||
|
||||
Args:
|
||||
answers: Словарь с ответами:
|
||||
- unfamiliar_behavior: 'explore' | 'wait' | 'freeze' | 'panic'
|
||||
- stress_reaction: 'angry' | 'cry' | 'calm'
|
||||
- leadership: 'always_leader' | 'sometimes' | 'always_follower'
|
||||
- risk_taking: 'very' | 'sometimes' | 'no_cautious'
|
||||
|
||||
Returns:
|
||||
Определенный психотип
|
||||
|
||||
Маппинг из §6:
|
||||
- активно + лидер + рискует → dominant
|
||||
- спокойно + лидер + осторожный → harmonic
|
||||
- плачет + ведомый + осторожный → anxious
|
||||
- замирает + ведомый + осторожный → introvert_passive
|
||||
- активно/паникует + иногда → introvert_active
|
||||
"""
|
||||
unfamiliar = answers.get('unfamiliar_behavior', '').lower()
|
||||
stress = answers.get('stress_reaction', '').lower()
|
||||
leadership = answers.get('leadership', '').lower()
|
||||
risk = answers.get('risk_taking', '').lower()
|
||||
|
||||
# Маппинг согласно §6 контекста
|
||||
|
||||
# активно + лидер + рискует → dominant
|
||||
if unfamiliar == 'explore' and leadership == 'always_leader' and risk == 'very':
|
||||
return 'dominant'
|
||||
|
||||
# спокойно + лидер + осторожный → harmonic
|
||||
if stress == 'calm' and leadership == 'always_leader' and risk == 'no_cautious':
|
||||
return 'harmonic'
|
||||
|
||||
# плачет + ведомый + осторожный → anxious
|
||||
if stress == 'cry' and leadership == 'always_follower' and risk == 'no_cautious':
|
||||
return 'anxious'
|
||||
|
||||
# замирает + ведомый + осторожный → introvert_passive
|
||||
if unfamiliar == 'freeze' and leadership == 'always_follower' and risk == 'no_cautious':
|
||||
return 'introvert_passive'
|
||||
|
||||
# активно/паникует + иногда → introvert_active
|
||||
if (unfamiliar in ['explore', 'panic']) and leadership == 'sometimes':
|
||||
return 'introvert_active'
|
||||
|
||||
# Дополнительные правила для неоднозначных случаев
|
||||
|
||||
# Лидер + рискует → скорее dominant
|
||||
if leadership == 'always_leader' and risk in ['very', 'sometimes']:
|
||||
return 'dominant'
|
||||
|
||||
# Ведомый + осторожный + плачет/замирает → anxious или introvert_passive
|
||||
if leadership == 'always_follower' and risk == 'no_cautious':
|
||||
if stress == 'cry':
|
||||
return 'anxious'
|
||||
elif unfamiliar == 'freeze':
|
||||
return 'introvert_passive'
|
||||
|
||||
# Активно исследует + иногда лидер → introvert_active
|
||||
if unfamiliar == 'explore' and leadership == 'sometimes':
|
||||
return 'introvert_active'
|
||||
|
||||
# Дефолт: harmonic (сбалансированный)
|
||||
return 'harmonic'
|
||||
|
||||
|
||||
def get_psychotype_modifiers(psychotype: PsychotypeStr) -> Dict:
|
||||
"""
|
||||
Возвращает модификаторы вероятности для зон поиска и модель движения.
|
||||
|
||||
Args:
|
||||
psychotype: Определенный психотип
|
||||
|
||||
Returns:
|
||||
Словарь с модификаторами зон и моделью движения
|
||||
"""
|
||||
modifiers = {
|
||||
'dominant': {
|
||||
'zone_0_500': 0.7,
|
||||
'zone_500_1500': 1.2,
|
||||
'zone_1500_2500': 1.4,
|
||||
'zone_2500plus': 1.1,
|
||||
'movement_model': 'chaotic_far',
|
||||
'description': 'Доминантный: активное движение, большие расстояния, хаотичное поведение'
|
||||
},
|
||||
'harmonic': {
|
||||
'zone_0_500': 0.8,
|
||||
'zone_500_1500': 1.0,
|
||||
'zone_1500_2500': 1.1,
|
||||
'zone_2500plus': 0.9,
|
||||
'movement_model': 'linear_landmark',
|
||||
'description': 'Гармоничный: рациональное движение по ориентирам, средние расстояния'
|
||||
},
|
||||
'anxious': {
|
||||
'zone_0_500': 1.4,
|
||||
'zone_500_1500': 1.1,
|
||||
'zone_1500_2500': 0.5,
|
||||
'zone_2500plus': 0.3,
|
||||
'movement_model': 'stay',
|
||||
'description': 'Тревожный: минимальное движение, остается близко к точке потери'
|
||||
},
|
||||
'introvert_passive': {
|
||||
'zone_0_500': 1.3,
|
||||
'zone_500_1500': 0.9,
|
||||
'zone_1500_2500': 0.6,
|
||||
'zone_2500plus': 0.2,
|
||||
'movement_model': 'stay_hidden',
|
||||
'description': 'Интроверт пассивный: прячется, минимальное движение, близко к точке потери'
|
||||
},
|
||||
'introvert_active': {
|
||||
'zone_0_500': 0.8,
|
||||
'zone_500_1500': 1.1,
|
||||
'zone_1500_2500': 1.2,
|
||||
'zone_2500plus': 0.9,
|
||||
'movement_model': 'linear_landmark',
|
||||
'description': 'Интроверт активный: целенаправленное движение по ориентирам, средние расстояния'
|
||||
}
|
||||
}
|
||||
|
||||
return modifiers.get(psychotype, modifiers['harmonic'])
|
||||
|
||||
|
||||
def get_search_recommendations(psychotype: PsychotypeStr) -> Dict[str, str]:
|
||||
"""
|
||||
Возвращает рекомендации по тактике поиска для данного психотипа.
|
||||
|
||||
Args:
|
||||
psychotype: Определенный психотип
|
||||
|
||||
Returns:
|
||||
Словарь с рекомендациями по поиску
|
||||
"""
|
||||
recommendations = {
|
||||
'dominant': {
|
||||
'priority_zones': 'Средние и дальние зоны (500-2500м)',
|
||||
'search_pattern': 'Широкий охват, проверка нелинейных маршрутов',
|
||||
'key_locations': 'Возвышенности, открытые пространства, необычные объекты',
|
||||
'communication': 'Громкие сигналы, яркие маркеры'
|
||||
},
|
||||
'harmonic': {
|
||||
'priority_zones': 'Все зоны равномерно, акцент на 500-1500м',
|
||||
'search_pattern': 'Систематический поиск вдоль троп и ориентиров',
|
||||
'key_locations': 'Тропы, дороги, видимые ориентиры, укрытия',
|
||||
'communication': 'Стандартные сигналы, информационные знаки'
|
||||
},
|
||||
'anxious': {
|
||||
'priority_zones': 'Ближняя зона (0-500м) - критически важна',
|
||||
'search_pattern': 'Тщательный осмотр ближайшей территории',
|
||||
'key_locations': 'Укрытия, углубления, густая растительность рядом с точкой потери',
|
||||
'communication': 'Спокойные голосовые сигналы, избегать резких звуков'
|
||||
},
|
||||
'introvert_passive': {
|
||||
'priority_zones': 'Ближняя зона (0-500м), укрытия',
|
||||
'search_pattern': 'Детальный осмотр укрытий и труднодоступных мест',
|
||||
'key_locations': 'Заросли, ямы, под деревьями, за камнями',
|
||||
'communication': 'Мягкие голосовые сигналы, визуальный контакт важнее звука'
|
||||
},
|
||||
'introvert_active': {
|
||||
'priority_zones': 'Средние зоны (500-2500м) вдоль линейных ориентиров',
|
||||
'search_pattern': 'Поиск вдоль троп, ручьев, границ леса',
|
||||
'key_locations': 'Линейные ориентиры, перекрестки троп, характерные объекты',
|
||||
'communication': 'Стандартные сигналы вдоль вероятных маршрутов'
|
||||
}
|
||||
}
|
||||
|
||||
return recommendations.get(psychotype, recommendations['harmonic'])
|
||||
|
||||
|
||||
def get_psychotype_questions() -> List[Dict]:
|
||||
"""
|
||||
Возвращает 4 вопроса для определения психотипа (§6 контекста).
|
||||
|
||||
Returns:
|
||||
Список вопросов с вариантами ответов
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'id': 'unfamiliar_behavior',
|
||||
'question': 'Как ведёт себя в незнакомой обстановке?',
|
||||
'options': [
|
||||
{'value': 'explore', 'label': 'Активно исследует'},
|
||||
{'value': 'wait', 'label': 'Ждёт и наблюдает'},
|
||||
{'value': 'freeze', 'label': 'Замирает'},
|
||||
{'value': 'panic', 'label': 'Паникует'}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 'stress_reaction',
|
||||
'question': 'Реакция на стресс и неудачи?',
|
||||
'options': [
|
||||
{'value': 'angry', 'label': 'Злится, кричит'},
|
||||
{'value': 'cry', 'label': 'Плачет, замыкается'},
|
||||
{'value': 'calm', 'label': 'Спокойно ищет выход'}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 'leadership',
|
||||
'question': 'Лидер или ведомый?',
|
||||
'options': [
|
||||
{'value': 'always_leader', 'label': 'Всегда лидер'},
|
||||
{'value': 'sometimes', 'label': 'Иногда'},
|
||||
{'value': 'always_follower', 'label': 'Всегда ведомый'}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 'risk_taking',
|
||||
'question': 'Любит рисковать?',
|
||||
'options': [
|
||||
{'value': 'very', 'label': 'Очень'},
|
||||
{'value': 'sometimes', 'label': 'Иногда'},
|
||||
{'value': 'no_cautious', 'label': 'Нет, осторожный'}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,403 @@
|
||||
"""
|
||||
Сервис оценки и ранжирования зон поиска на основе взвешенных факторов.
|
||||
Реализация согласно §8 и §9 контекста ВЕКТОР.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
from copy import deepcopy
|
||||
|
||||
|
||||
class WeightedScorer:
|
||||
"""
|
||||
Система взвешенной оценки зон поиска с учетом множественных факторов.
|
||||
Базовые веса из §9 контекста.
|
||||
"""
|
||||
|
||||
# Базовые веса факторов из §9 (сумма = 1.0)
|
||||
BASE_WEIGHTS = {
|
||||
'forest': 0.25,
|
||||
'water': 0.20,
|
||||
'roads': 0.18,
|
||||
'settlement': 0.15,
|
||||
'historical': 0.12,
|
||||
'direction': 0.07,
|
||||
'shelter': 0.03
|
||||
}
|
||||
|
||||
# Возрастные модификаторы
|
||||
AGE_MODIFIERS = {
|
||||
'0-4': {
|
||||
'forest': 0.6,
|
||||
'water': 2.5,
|
||||
'roads': 1.3,
|
||||
'settlement': 1.8,
|
||||
'shelter': 1.5,
|
||||
'distance_mult': 0.3
|
||||
},
|
||||
'5-7': {
|
||||
'forest': 0.8,
|
||||
'water': 2.2,
|
||||
'roads': 1.4,
|
||||
'settlement': 1.6,
|
||||
'shelter': 1.4,
|
||||
'distance_mult': 0.5
|
||||
},
|
||||
'8-11': {
|
||||
'forest': 1.1,
|
||||
'water': 1.8,
|
||||
'roads': 1.2,
|
||||
'settlement': 1.3,
|
||||
'shelter': 1.2,
|
||||
'distance_mult': 0.8
|
||||
},
|
||||
'12-14': {
|
||||
'forest': 1.3,
|
||||
'water': 1.4,
|
||||
'roads': 1.1,
|
||||
'settlement': 1.0,
|
||||
'shelter': 1.0,
|
||||
'distance_mult': 1.2
|
||||
},
|
||||
'15-17': {
|
||||
'forest': 1.4,
|
||||
'water': 1.2,
|
||||
'roads': 1.3,
|
||||
'settlement': 0.9,
|
||||
'shelter': 0.9,
|
||||
'distance_mult': 1.5
|
||||
}
|
||||
}
|
||||
|
||||
# Сезонные модификаторы
|
||||
SEASON_MODIFIERS = {
|
||||
'зима': {
|
||||
'forest': 0.8,
|
||||
'water': 0.6,
|
||||
'roads': 1.3,
|
||||
'settlement': 1.5,
|
||||
'shelter': 2.0,
|
||||
'distance_mult': 0.7
|
||||
},
|
||||
'весна': {
|
||||
'forest': 1.1,
|
||||
'water': 1.8,
|
||||
'roads': 1.0,
|
||||
'settlement': 1.0,
|
||||
'shelter': 1.2,
|
||||
'distance_mult': 1.0
|
||||
},
|
||||
'лето': {
|
||||
'forest': 1.2,
|
||||
'water': 1.3,
|
||||
'roads': 0.9,
|
||||
'settlement': 0.8,
|
||||
'shelter': 0.8,
|
||||
'distance_mult': 1.3
|
||||
},
|
||||
'осень': {
|
||||
'forest': 1.3,
|
||||
'water': 1.1,
|
||||
'roads': 1.0,
|
||||
'settlement': 1.1,
|
||||
'shelter': 1.1,
|
||||
'distance_mult': 1.0
|
||||
}
|
||||
}
|
||||
|
||||
# Поведенческие профили — ТОЧНЫЕ коэффициенты из §8 контекста
|
||||
BEHAVIORAL_PROFILES = {
|
||||
'РАС': {
|
||||
'water': 3.0,
|
||||
'railway': 2.5,
|
||||
'shelter': 2.0,
|
||||
'settlement': 0.4,
|
||||
'distance_mult': 2.0,
|
||||
'critical_warning': 'НЕ использовать громкоговоритель с именем ребёнка! Немедленно перекрыть ВСЕ водоёмы и ж/д пути.'
|
||||
},
|
||||
'эпилепсия': {
|
||||
'water': 3.5,
|
||||
'shelter': 2.5,
|
||||
'distance_mult': 0.6,
|
||||
'critical_warning': 'Медицинский приоритет — возможна потеря сознания. Радиус поиска МЕНЬШЕ среднего.'
|
||||
},
|
||||
'СДВГ': {
|
||||
'roads': 1.6,
|
||||
'distance_mult': 1.4,
|
||||
'note': 'Импульсивное движение, меняет направление. Откликается, но может не идти целенаправленно.'
|
||||
},
|
||||
'ЗПР': {
|
||||
'settlement': 0.7,
|
||||
'shelter': 1.5,
|
||||
'distance_mult': 0.8,
|
||||
'note': 'Не ориентируется в пространстве'
|
||||
},
|
||||
'велосипед': {
|
||||
'distance_mult': 5.0,
|
||||
'roads': 1.8,
|
||||
'forest': 0.8,
|
||||
'critical_warning': 'Немедленно расширить зону до 10-15 км! Приоритет: дороги и велодорожки. Запросить данные дорожных камер.'
|
||||
},
|
||||
'самокат': {
|
||||
'distance_mult': 3.0,
|
||||
'roads': 1.6,
|
||||
'forest': 0.9
|
||||
},
|
||||
'намеренный_уход': {
|
||||
'forest': 0.2,
|
||||
'roads': 2.5,
|
||||
'settlement': 3.0,
|
||||
'note': 'Не прочёсывание леса, а розыск. Транспортные узлы, камеры, соцсети, друзья.'
|
||||
}
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
"""Инициализация скорера с базовыми весами."""
|
||||
self.weights = deepcopy(self.BASE_WEIGHTS)
|
||||
self.distance_multiplier = 1.0
|
||||
self.active_profiles = []
|
||||
self.critical_warnings = []
|
||||
|
||||
def _get_age_group(self, age: int) -> str:
|
||||
"""Определяет возрастную группу."""
|
||||
if age <= 4:
|
||||
return '0-4'
|
||||
elif age <= 7:
|
||||
return '5-7'
|
||||
elif age <= 11:
|
||||
return '8-11'
|
||||
elif age <= 14:
|
||||
return '12-14'
|
||||
elif age <= 17:
|
||||
return '15-17'
|
||||
else:
|
||||
return '18-64'
|
||||
|
||||
def apply_age_modifiers(self, age: int):
|
||||
"""Применяет возрастные модификаторы к весам."""
|
||||
age_group = self._get_age_group(age)
|
||||
modifiers = self.AGE_MODIFIERS.get(age_group, {})
|
||||
|
||||
for factor, modifier in modifiers.items():
|
||||
if factor == 'distance_mult':
|
||||
self.distance_multiplier *= modifier
|
||||
elif factor in self.weights:
|
||||
self.weights[factor] *= modifier
|
||||
|
||||
def apply_season_modifiers(self, season: str):
|
||||
"""Применяет сезонные модификаторы к весам."""
|
||||
season_lower = season.lower() if season else 'лето'
|
||||
modifiers = self.SEASON_MODIFIERS.get(season_lower, {})
|
||||
|
||||
for factor, modifier in modifiers.items():
|
||||
if factor == 'distance_mult':
|
||||
self.distance_multiplier *= modifier
|
||||
elif factor in self.weights:
|
||||
self.weights[factor] *= modifier
|
||||
|
||||
def apply_profile(self, profile_list: List[str]):
|
||||
"""
|
||||
Применяет поведенческие профили к весам.
|
||||
Точные коэффициенты из §8 контекста.
|
||||
|
||||
Args:
|
||||
profile_list: Список профилей (РАС, эпилепсия, СДВГ, велосипед и т.д.)
|
||||
"""
|
||||
if not profile_list:
|
||||
return
|
||||
|
||||
for profile_name in profile_list:
|
||||
profile = self.BEHAVIORAL_PROFILES.get(profile_name)
|
||||
if not profile:
|
||||
continue
|
||||
|
||||
self.active_profiles.append(profile_name)
|
||||
|
||||
# Сохранить критические предупреждения
|
||||
if 'critical_warning' in profile:
|
||||
self.critical_warnings.append({
|
||||
'profile': profile_name,
|
||||
'warning': profile['critical_warning']
|
||||
})
|
||||
|
||||
for factor, modifier in profile.items():
|
||||
if factor in ['critical_warning', 'note']:
|
||||
continue
|
||||
elif factor == 'distance_mult':
|
||||
self.distance_multiplier *= modifier
|
||||
elif factor in self.weights:
|
||||
self.weights[factor] *= modifier
|
||||
elif factor == 'railway':
|
||||
# Ж/д пути — добавляем как отдельный фактор для РАС
|
||||
if 'railway' not in self.weights:
|
||||
self.weights['railway'] = 0.05
|
||||
self.weights['railway'] *= modifier
|
||||
|
||||
def _normalize_weights(self):
|
||||
"""Нормализует веса так, чтобы их сумма была 1.0."""
|
||||
# Фильтруем None значения
|
||||
valid_weights = {k: v for k, v in self.weights.items() if v is not None}
|
||||
total = sum(valid_weights.values())
|
||||
|
||||
if total > 0:
|
||||
for key in self.weights:
|
||||
if self.weights[key] is not None:
|
||||
self.weights[key] /= total
|
||||
else:
|
||||
self.weights[key] = 0.0
|
||||
|
||||
def score_zone(self, zone: Dict, case: Dict, max_distance_km: float = 2.0) -> float:
|
||||
"""
|
||||
Оценивает зону поиска на основе её характеристик и данных случая.
|
||||
|
||||
Args:
|
||||
zone: Словарь с характеристиками зоны
|
||||
case: Данные случая (age, season, profiles и т.д.)
|
||||
|
||||
Returns:
|
||||
float: Оценка зоны (0-100)
|
||||
"""
|
||||
# Сбрасываем веса к базовым
|
||||
self.weights = deepcopy(self.BASE_WEIGHTS)
|
||||
self.distance_multiplier = 1.0
|
||||
self.active_profiles = []
|
||||
self.critical_warnings = []
|
||||
|
||||
# Применяем модификаторы
|
||||
if 'age' in case and case['age']:
|
||||
self.apply_age_modifiers(case['age'])
|
||||
|
||||
if 'season' in case and case['season']:
|
||||
self.apply_season_modifiers(case['season'])
|
||||
|
||||
if 'profiles' in case and case['profiles']:
|
||||
self.apply_profile(case['profiles'])
|
||||
|
||||
# Нормализуем веса
|
||||
self._normalize_weights()
|
||||
|
||||
# Вычисляем оценку
|
||||
score = 0.0
|
||||
|
||||
# Лес
|
||||
forest_score = zone.get('forest_pct', 0.5)
|
||||
score += self.weights['forest'] * forest_score
|
||||
|
||||
# Вода (чем ближе, тем важнее)
|
||||
water_dist = zone.get('water_distance_km', 5.0)
|
||||
water_score = max(0, 1.0 - (water_dist / 10.0)) if water_dist is not None else 0.5
|
||||
score += self.weights['water'] * water_score
|
||||
|
||||
# Дороги
|
||||
road_density = zone.get('road_density', 0.5)
|
||||
road_score = min(1.0, road_density / 2.0) if road_density is not None else 0.5
|
||||
score += self.weights['roads'] * road_score
|
||||
|
||||
# Населенные пункты
|
||||
settlement_dist = zone.get('settlement_distance_km', 10.0)
|
||||
settlement_score = max(0, 1.0 - (settlement_dist / 20.0)) if settlement_dist is not None else 0.5
|
||||
score += self.weights['settlement'] * settlement_score
|
||||
|
||||
# Историческая частота
|
||||
historical_score = zone.get('historical_freq', 0.5)
|
||||
score += self.weights['historical'] * historical_score
|
||||
|
||||
# Совпадение направления
|
||||
direction_score = zone.get('direction_match', 0.5)
|
||||
score += self.weights['direction'] * direction_score
|
||||
|
||||
# Укрытия
|
||||
shelter_score = zone.get('shelter_pct', 0.3)
|
||||
score += self.weights['shelter'] * shelter_score
|
||||
|
||||
# Ж/д пути (для РАС)
|
||||
if 'railway' in self.weights:
|
||||
railway_dist = zone.get('railway_distance_km', 10.0)
|
||||
railway_score = max(0, 1.0 - (railway_dist / 5.0))
|
||||
score += self.weights['railway'] * railway_score
|
||||
|
||||
# Применяем множитель расстояния
|
||||
zone_distance = zone.get('distance_km', 1.0)
|
||||
expected_distance = max_distance_km * self.distance_multiplier
|
||||
distance_factor = 1.0 - abs(zone_distance - expected_distance) / (expected_distance * 2)
|
||||
distance_factor = max(0.3, min(1.0, distance_factor))
|
||||
|
||||
score *= distance_factor
|
||||
|
||||
# Конвертируем в шкалу 0-100
|
||||
return round(score * 100, 2)
|
||||
|
||||
def rank_zones(self, zones: List[Dict], case: Dict, max_distance_km: float = 2.0) -> List[Dict]:
|
||||
"""
|
||||
Ранжирует зоны по приоритету на основе оценок.
|
||||
|
||||
Args:
|
||||
zones: Список зон с характеристиками
|
||||
case: Данные случая
|
||||
|
||||
Returns:
|
||||
List[Dict]: Отсортированный список зон с оценками и приоритетами
|
||||
"""
|
||||
scored_zones = []
|
||||
for zone in zones:
|
||||
zone_copy = deepcopy(zone)
|
||||
zone_copy['score'] = self.score_zone(zone, case, max_distance_km=max_distance_km)
|
||||
scored_zones.append(zone_copy)
|
||||
|
||||
scored_zones.sort(key=lambda x: x['score'], reverse=True)
|
||||
|
||||
for i, zone in enumerate(scored_zones):
|
||||
zone['priority'] = i + 1
|
||||
|
||||
return scored_zones
|
||||
|
||||
def get_active_profiles_info(self) -> List[Dict]:
|
||||
"""Возвращает информацию об активных профилях с предупреждениями."""
|
||||
profiles_info = []
|
||||
|
||||
for profile_name in self.active_profiles:
|
||||
profile = self.BEHAVIORAL_PROFILES.get(profile_name, {})
|
||||
info = {
|
||||
'name': profile_name,
|
||||
'modifiers': {k: v for k, v in profile.items() if k not in ['critical_warning', 'note']},
|
||||
}
|
||||
|
||||
if 'critical_warning' in profile:
|
||||
info['critical_warning'] = profile['critical_warning']
|
||||
if 'note' in profile:
|
||||
info['note'] = profile['note']
|
||||
|
||||
profiles_info.append(info)
|
||||
|
||||
return profiles_info
|
||||
|
||||
|
||||
def create_scorer_for_case(case: Dict) -> WeightedScorer:
|
||||
"""Создает и настраивает скорер для конкретного случая."""
|
||||
scorer = WeightedScorer()
|
||||
|
||||
if 'age' in case and case['age']:
|
||||
scorer.apply_age_modifiers(case['age'])
|
||||
|
||||
if 'season' in case and case['season']:
|
||||
scorer.apply_season_modifiers(case['season'])
|
||||
|
||||
if 'profiles' in case and case['profiles']:
|
||||
scorer.apply_profile(case['profiles'])
|
||||
|
||||
scorer._normalize_weights()
|
||||
|
||||
return scorer
|
||||
|
||||
|
||||
def get_weight_explanation(case: Dict) -> Dict:
|
||||
"""Возвращает объяснение весов для данного случая."""
|
||||
scorer = create_scorer_for_case(case)
|
||||
|
||||
return {
|
||||
'weights': scorer.weights,
|
||||
'distance_multiplier': scorer.distance_multiplier,
|
||||
'age_group': scorer._get_age_group(case.get('age', 10)) if case.get('age') else None,
|
||||
'season': case.get('season'),
|
||||
'profiles': scorer.get_active_profiles_info(),
|
||||
'critical_warnings': scorer.critical_warnings
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
"""
|
||||
Сервис оценки и ранжирования зон поиска на основе взвешенных факторов.
|
||||
Реализация согласно §8 и §9 контекста ВЕКТОР.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
from copy import deepcopy
|
||||
|
||||
|
||||
class WeightedScorer:
|
||||
"""
|
||||
Система взвешенной оценки зон поиска с учетом множественных факторов.
|
||||
Базовые веса из §9 контекста.
|
||||
"""
|
||||
|
||||
# Базовые веса факторов из §9 (сумма = 1.0)
|
||||
BASE_WEIGHTS = {
|
||||
'forest': 0.25,
|
||||
'water': 0.20,
|
||||
'roads': 0.18,
|
||||
'settlement': 0.15,
|
||||
'historical': 0.12,
|
||||
'direction': 0.07,
|
||||
'shelter': 0.03
|
||||
}
|
||||
|
||||
# Возрастные модификаторы
|
||||
AGE_MODIFIERS = {
|
||||
'0-4': {
|
||||
'forest': 0.6,
|
||||
'water': 2.5,
|
||||
'roads': 1.3,
|
||||
'settlement': 1.8,
|
||||
'shelter': 1.5,
|
||||
'distance_mult': 0.3
|
||||
},
|
||||
'5-7': {
|
||||
'forest': 0.8,
|
||||
'water': 2.2,
|
||||
'roads': 1.4,
|
||||
'settlement': 1.6,
|
||||
'shelter': 1.4,
|
||||
'distance_mult': 0.5
|
||||
},
|
||||
'8-11': {
|
||||
'forest': 1.1,
|
||||
'water': 1.8,
|
||||
'roads': 1.2,
|
||||
'settlement': 1.3,
|
||||
'shelter': 1.2,
|
||||
'distance_mult': 0.8
|
||||
},
|
||||
'12-14': {
|
||||
'forest': 1.3,
|
||||
'water': 1.4,
|
||||
'roads': 1.1,
|
||||
'settlement': 1.0,
|
||||
'shelter': 1.0,
|
||||
'distance_mult': 1.2
|
||||
},
|
||||
'15-17': {
|
||||
'forest': 1.4,
|
||||
'water': 1.2,
|
||||
'roads': 1.3,
|
||||
'settlement': 0.9,
|
||||
'shelter': 0.9,
|
||||
'distance_mult': 1.5
|
||||
}
|
||||
}
|
||||
|
||||
# Сезонные модификаторы
|
||||
SEASON_MODIFIERS = {
|
||||
'зима': {
|
||||
'forest': 0.8,
|
||||
'water': 0.6,
|
||||
'roads': 1.3,
|
||||
'settlement': 1.5,
|
||||
'shelter': 2.0,
|
||||
'distance_mult': 0.7
|
||||
},
|
||||
'весна': {
|
||||
'forest': 1.1,
|
||||
'water': 1.8,
|
||||
'roads': 1.0,
|
||||
'settlement': 1.0,
|
||||
'shelter': 1.2,
|
||||
'distance_mult': 1.0
|
||||
},
|
||||
'лето': {
|
||||
'forest': 1.2,
|
||||
'water': 1.3,
|
||||
'roads': 0.9,
|
||||
'settlement': 0.8,
|
||||
'shelter': 0.8,
|
||||
'distance_mult': 1.3
|
||||
},
|
||||
'осень': {
|
||||
'forest': 1.3,
|
||||
'water': 1.1,
|
||||
'roads': 1.0,
|
||||
'settlement': 1.1,
|
||||
'shelter': 1.1,
|
||||
'distance_mult': 1.0
|
||||
}
|
||||
}
|
||||
|
||||
# Поведенческие профили — ТОЧНЫЕ коэффициенты из §8 контекста
|
||||
BEHAVIORAL_PROFILES = {
|
||||
'РАС': {
|
||||
'water': 3.0,
|
||||
'railway': 2.5,
|
||||
'shelter': 2.0,
|
||||
'settlement': 0.4,
|
||||
'distance_mult': 2.0,
|
||||
'critical_warning': 'НЕ использовать громкоговоритель с именем ребёнка! Немедленно перекрыть ВСЕ водоёмы и ж/д пути.'
|
||||
},
|
||||
'эпилепсия': {
|
||||
'water': 3.5,
|
||||
'shelter': 2.5,
|
||||
'distance_mult': 0.6,
|
||||
'critical_warning': 'Медицинский приоритет — возможна потеря сознания. Радиус поиска МЕНЬШЕ среднего.'
|
||||
},
|
||||
'СДВГ': {
|
||||
'roads': 1.6,
|
||||
'distance_mult': 1.4,
|
||||
'note': 'Импульсивное движение, меняет направление. Откликается, но может не идти целенаправленно.'
|
||||
},
|
||||
'ЗПР': {
|
||||
'settlement': 0.7,
|
||||
'shelter': 1.5,
|
||||
'distance_mult': 0.8,
|
||||
'note': 'Не ориентируется в пространстве'
|
||||
},
|
||||
'велосипед': {
|
||||
'distance_mult': 5.0,
|
||||
'roads': 1.8,
|
||||
'forest': 0.8,
|
||||
'critical_warning': 'Немедленно расширить зону до 10-15 км! Приоритет: дороги и велодорожки. Запросить данные дорожных камер.'
|
||||
},
|
||||
'самокат': {
|
||||
'distance_mult': 3.0,
|
||||
'roads': 1.6,
|
||||
'forest': 0.9
|
||||
},
|
||||
'намеренный_уход': {
|
||||
'forest': 0.2,
|
||||
'roads': 2.5,
|
||||
'settlement': 3.0,
|
||||
'note': 'Не прочёсывание леса, а розыск. Транспортные узлы, камеры, соцсети, друзья.'
|
||||
}
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
"""Инициализация скорера с базовыми весами."""
|
||||
self.weights = deepcopy(self.BASE_WEIGHTS)
|
||||
self.distance_multiplier = 1.0
|
||||
self.active_profiles = []
|
||||
self.critical_warnings = []
|
||||
|
||||
def _get_age_group(self, age: int) -> str:
|
||||
"""Определяет возрастную группу."""
|
||||
if age <= 4:
|
||||
return '0-4'
|
||||
elif age <= 7:
|
||||
return '5-7'
|
||||
elif age <= 11:
|
||||
return '8-11'
|
||||
elif age <= 14:
|
||||
return '12-14'
|
||||
elif age <= 17:
|
||||
return '15-17'
|
||||
else:
|
||||
return '18-64'
|
||||
|
||||
def apply_age_modifiers(self, age: int):
|
||||
"""Применяет возрастные модификаторы к весам."""
|
||||
age_group = self._get_age_group(age)
|
||||
modifiers = self.AGE_MODIFIERS.get(age_group, {})
|
||||
|
||||
for factor, modifier in modifiers.items():
|
||||
if factor == 'distance_mult':
|
||||
self.distance_multiplier *= modifier
|
||||
elif factor in self.weights:
|
||||
self.weights[factor] *= modifier
|
||||
|
||||
def apply_season_modifiers(self, season: str):
|
||||
"""Применяет сезонные модификаторы к весам."""
|
||||
season_lower = season.lower() if season else 'лето'
|
||||
modifiers = self.SEASON_MODIFIERS.get(season_lower, {})
|
||||
|
||||
for factor, modifier in modifiers.items():
|
||||
if factor == 'distance_mult':
|
||||
self.distance_multiplier *= modifier
|
||||
elif factor in self.weights:
|
||||
self.weights[factor] *= modifier
|
||||
|
||||
def apply_profile(self, profile_list: List[str]):
|
||||
"""
|
||||
Применяет поведенческие профили к весам.
|
||||
Точные коэффициенты из §8 контекста.
|
||||
|
||||
Args:
|
||||
profile_list: Список профилей (РАС, эпилепсия, СДВГ, велосипед и т.д.)
|
||||
"""
|
||||
if not profile_list:
|
||||
return
|
||||
|
||||
for profile_name in profile_list:
|
||||
profile = self.BEHAVIORAL_PROFILES.get(profile_name)
|
||||
if not profile:
|
||||
continue
|
||||
|
||||
self.active_profiles.append(profile_name)
|
||||
|
||||
# Сохранить критические предупреждения
|
||||
if 'critical_warning' in profile:
|
||||
self.critical_warnings.append({
|
||||
'profile': profile_name,
|
||||
'warning': profile['critical_warning']
|
||||
})
|
||||
|
||||
for factor, modifier in profile.items():
|
||||
if factor in ['critical_warning', 'note']:
|
||||
continue
|
||||
elif factor == 'distance_mult':
|
||||
self.distance_multiplier *= modifier
|
||||
elif factor in self.weights:
|
||||
self.weights[factor] *= modifier
|
||||
elif factor == 'railway':
|
||||
# Ж/д пути — добавляем как отдельный фактор для РАС
|
||||
if 'railway' not in self.weights:
|
||||
self.weights['railway'] = 0.05
|
||||
self.weights['railway'] *= modifier
|
||||
|
||||
def _normalize_weights(self):
|
||||
"""Нормализует веса так, чтобы их сумма была 1.0."""
|
||||
# Фильтруем None значения
|
||||
valid_weights = {k: v for k, v in self.weights.items() if v is not None}
|
||||
total = sum(valid_weights.values())
|
||||
|
||||
if total > 0:
|
||||
for key in self.weights:
|
||||
if self.weights[key] is not None:
|
||||
self.weights[key] /= total
|
||||
else:
|
||||
self.weights[key] = 0.0
|
||||
|
||||
def score_zone(self, zone: Dict, case: Dict) -> float:
|
||||
"""
|
||||
Оценивает зону поиска на основе её характеристик и данных случая.
|
||||
|
||||
Args:
|
||||
zone: Словарь с характеристиками зоны
|
||||
case: Данные случая (age, season, profiles и т.д.)
|
||||
|
||||
Returns:
|
||||
float: Оценка зоны (0-100)
|
||||
"""
|
||||
# Сбрасываем веса к базовым
|
||||
self.weights = deepcopy(self.BASE_WEIGHTS)
|
||||
self.distance_multiplier = 1.0
|
||||
self.active_profiles = []
|
||||
self.critical_warnings = []
|
||||
|
||||
# Применяем модификаторы
|
||||
if 'age' in case and case['age']:
|
||||
self.apply_age_modifiers(case['age'])
|
||||
|
||||
if 'season' in case and case['season']:
|
||||
self.apply_season_modifiers(case['season'])
|
||||
|
||||
if 'profiles' in case and case['profiles']:
|
||||
self.apply_profile(case['profiles'])
|
||||
|
||||
# Нормализуем веса
|
||||
self._normalize_weights()
|
||||
|
||||
# Вычисляем оценку
|
||||
score = 0.0
|
||||
|
||||
# Лес
|
||||
forest_score = zone.get('forest_pct', 0.5)
|
||||
score += self.weights['forest'] * forest_score
|
||||
|
||||
# Вода (чем ближе, тем важнее)
|
||||
water_dist = zone.get('water_distance_km', 5.0)
|
||||
water_score = max(0, 1.0 - (water_dist / 10.0)) if water_dist is not None else 0.5
|
||||
score += self.weights['water'] * water_score
|
||||
|
||||
# Дороги
|
||||
road_density = zone.get('road_density', 0.5)
|
||||
road_score = min(1.0, road_density / 2.0) if road_density is not None else 0.5
|
||||
score += self.weights['roads'] * road_score
|
||||
|
||||
# Населенные пункты
|
||||
settlement_dist = zone.get('settlement_distance_km', 10.0)
|
||||
settlement_score = max(0, 1.0 - (settlement_dist / 20.0)) if settlement_dist is not None else 0.5
|
||||
score += self.weights['settlement'] * settlement_score
|
||||
|
||||
# Историческая частота
|
||||
historical_score = zone.get('historical_freq', 0.5)
|
||||
score += self.weights['historical'] * historical_score
|
||||
|
||||
# Совпадение направления
|
||||
direction_score = zone.get('direction_match', 0.5)
|
||||
score += self.weights['direction'] * direction_score
|
||||
|
||||
# Укрытия
|
||||
shelter_score = zone.get('shelter_pct', 0.3)
|
||||
score += self.weights['shelter'] * shelter_score
|
||||
|
||||
# Ж/д пути (для РАС)
|
||||
if 'railway' in self.weights:
|
||||
railway_dist = zone.get('railway_distance_km', 10.0)
|
||||
railway_score = max(0, 1.0 - (railway_dist / 5.0))
|
||||
score += self.weights['railway'] * railway_score
|
||||
|
||||
# Применяем множитель расстояния
|
||||
zone_distance = zone.get('distance_km', 1.0)
|
||||
expected_distance = 2.0 * self.distance_multiplier
|
||||
distance_factor = 1.0 - abs(zone_distance - expected_distance) / (expected_distance * 2)
|
||||
distance_factor = max(0.3, min(1.0, distance_factor))
|
||||
|
||||
score *= distance_factor
|
||||
|
||||
# Конвертируем в шкалу 0-100
|
||||
return round(score * 100, 2)
|
||||
|
||||
def rank_zones(self, zones: List[Dict], case: Dict) -> List[Dict]:
|
||||
"""
|
||||
Ранжирует зоны по приоритету на основе оценок.
|
||||
|
||||
Args:
|
||||
zones: Список зон с характеристиками
|
||||
case: Данные случая
|
||||
|
||||
Returns:
|
||||
List[Dict]: Отсортированный список зон с оценками и приоритетами
|
||||
"""
|
||||
scored_zones = []
|
||||
for zone in zones:
|
||||
zone_copy = deepcopy(zone)
|
||||
zone_copy['score'] = self.score_zone(zone, case)
|
||||
scored_zones.append(zone_copy)
|
||||
|
||||
scored_zones.sort(key=lambda x: x['score'], reverse=True)
|
||||
|
||||
for i, zone in enumerate(scored_zones):
|
||||
zone['priority'] = i + 1
|
||||
|
||||
return scored_zones
|
||||
|
||||
def get_active_profiles_info(self) -> List[Dict]:
|
||||
"""Возвращает информацию об активных профилях с предупреждениями."""
|
||||
profiles_info = []
|
||||
|
||||
for profile_name in self.active_profiles:
|
||||
profile = self.BEHAVIORAL_PROFILES.get(profile_name, {})
|
||||
info = {
|
||||
'name': profile_name,
|
||||
'modifiers': {k: v for k, v in profile.items() if k not in ['critical_warning', 'note']},
|
||||
}
|
||||
|
||||
if 'critical_warning' in profile:
|
||||
info['critical_warning'] = profile['critical_warning']
|
||||
if 'note' in profile:
|
||||
info['note'] = profile['note']
|
||||
|
||||
profiles_info.append(info)
|
||||
|
||||
return profiles_info
|
||||
|
||||
|
||||
def create_scorer_for_case(case: Dict) -> WeightedScorer:
|
||||
"""Создает и настраивает скорер для конкретного случая."""
|
||||
scorer = WeightedScorer()
|
||||
|
||||
if 'age' in case and case['age']:
|
||||
scorer.apply_age_modifiers(case['age'])
|
||||
|
||||
if 'season' in case and case['season']:
|
||||
scorer.apply_season_modifiers(case['season'])
|
||||
|
||||
if 'profiles' in case and case['profiles']:
|
||||
scorer.apply_profile(case['profiles'])
|
||||
|
||||
scorer._normalize_weights()
|
||||
|
||||
return scorer
|
||||
|
||||
|
||||
def get_weight_explanation(case: Dict) -> Dict:
|
||||
"""Возвращает объяснение весов для данного случая."""
|
||||
scorer = create_scorer_for_case(case)
|
||||
|
||||
return {
|
||||
'weights': scorer.weights,
|
||||
'distance_multiplier': scorer.distance_multiplier,
|
||||
'age_group': scorer._get_age_group(case.get('age', 10)) if case.get('age') else None,
|
||||
'season': case.get('season'),
|
||||
'profiles': scorer.get_active_profiles_info(),
|
||||
'critical_warnings': scorer.critical_warnings
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
"""
|
||||
Statistics service for case analysis and dashboard aggregates.
|
||||
|
||||
Provides statistical recommendations based on historical data:
|
||||
- Similar cases filtering (age ±2 years, season, terrain)
|
||||
- Median distance, top directions, survival rate
|
||||
- Dashboard aggregates
|
||||
"""
|
||||
from typing import Dict, List, Optional, Any
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_, or_, text
|
||||
from models import Case
|
||||
|
||||
|
||||
class DirectionFrequency(BaseModel):
|
||||
"""Direction frequency statistics"""
|
||||
direction: str
|
||||
count: int
|
||||
percentage: float
|
||||
|
||||
|
||||
class StatisticalRecommendation(BaseModel):
|
||||
"""Statistical recommendation based on historical data"""
|
||||
median_distance_km: float
|
||||
top_directions: List[DirectionFrequency]
|
||||
top_location_types: List[str]
|
||||
survival_rate: float
|
||||
sample_size: int
|
||||
filters_used: Dict[str, Any]
|
||||
|
||||
|
||||
class DashboardStats(BaseModel):
|
||||
"""Dashboard aggregate statistics"""
|
||||
total_cases: int
|
||||
active_cases: int
|
||||
closed_cases: int
|
||||
by_gender: Dict[str, int]
|
||||
by_age_group: Dict[str, int]
|
||||
by_psychotype: Dict[str, int]
|
||||
by_diagnosis: Dict[str, int]
|
||||
by_season: Dict[str, int]
|
||||
avg_distance_km: Optional[float]
|
||||
avg_search_duration_hours: Optional[float]
|
||||
survival_rate: float
|
||||
|
||||
|
||||
def get_statistical_recommendation(
|
||||
case_data: dict,
|
||||
db: Session,
|
||||
min_sample_size: int = 5
|
||||
) -> StatisticalRecommendation:
|
||||
"""
|
||||
Получает статистические рекомендации на основе похожих исторических случаев.
|
||||
|
||||
Фильтры (в порядке приоритета):
|
||||
1. Возраст ±2 года + сезон + terrain
|
||||
2. Возраст ±2 года + сезон (если < 5 случаев)
|
||||
3. Возраст ±2 года (если < 5 случаев)
|
||||
4. Все случаи (если < 5 случаев)
|
||||
|
||||
Args:
|
||||
case_data: Словарь с данными случая
|
||||
- age: возраст (обязательно)
|
||||
- season: сезон (опционально)
|
||||
- terrain_primary: тип местности (опционально)
|
||||
db: SQLAlchemy Session
|
||||
min_sample_size: Минимальный размер выборки (по умолчанию 5)
|
||||
|
||||
Returns:
|
||||
StatisticalRecommendation: Статистические рекомендации
|
||||
"""
|
||||
age = case_data.get('age')
|
||||
season = case_data.get('season')
|
||||
terrain = case_data.get('terrain_primary')
|
||||
|
||||
if not age:
|
||||
raise ValueError("Age is required for statistical recommendation")
|
||||
|
||||
# Попытка 1: Возраст ±2 года + сезон + terrain
|
||||
filters_used = {'age_range': f"{age-2} to {age+2}"}
|
||||
query = db.query(Case).filter(
|
||||
Case.age_years.between(age - 2, age + 2),
|
||||
Case.found_distance_km.isnot(None)
|
||||
)
|
||||
|
||||
if season:
|
||||
query = query.filter(Case.season == season)
|
||||
filters_used['season'] = season
|
||||
|
||||
if terrain and season:
|
||||
query = query.filter(Case.terrain.any(terrain))
|
||||
filters_used['terrain'] = terrain
|
||||
|
||||
cases = query.all()
|
||||
sample_size = len(cases)
|
||||
|
||||
# Попытка 2: Убираем terrain, если мало данных
|
||||
if sample_size < min_sample_size and terrain:
|
||||
filters_used.pop('terrain', None)
|
||||
query = db.query(Case).filter(
|
||||
Case.age_years.between(age - 2, age + 2),
|
||||
Case.found_distance_km.isnot(None)
|
||||
)
|
||||
if season:
|
||||
query = query.filter(Case.season == season)
|
||||
|
||||
cases = query.all()
|
||||
sample_size = len(cases)
|
||||
|
||||
# Попытка 3: Убираем season, если мало данных
|
||||
if sample_size < min_sample_size and season:
|
||||
filters_used.pop('season', None)
|
||||
query = db.query(Case).filter(
|
||||
Case.age_years.between(age - 2, age + 2),
|
||||
Case.found_distance_km.isnot(None)
|
||||
)
|
||||
|
||||
cases = query.all()
|
||||
sample_size = len(cases)
|
||||
|
||||
# Попытка 4: Все случаи с найденными детьми
|
||||
if sample_size < min_sample_size:
|
||||
filters_used = {"age_range": "all"}
|
||||
query = db.query(Case).filter(
|
||||
Case.found_distance_km.isnot(None)
|
||||
)
|
||||
|
||||
cases = query.all()
|
||||
sample_size = len(cases)
|
||||
|
||||
# Если данных нет совсем, возвращаем дефолтные значения
|
||||
if sample_size == 0:
|
||||
return StatisticalRecommendation(
|
||||
median_distance_km=2.0,
|
||||
top_directions=[
|
||||
DirectionFrequency(direction="N", count=0, percentage=0.0),
|
||||
DirectionFrequency(direction="S", count=0, percentage=0.0),
|
||||
DirectionFrequency(direction="E", count=0, percentage=0.0)
|
||||
],
|
||||
top_location_types=["водоёмы", "дороги", "постройки"],
|
||||
survival_rate=0.0,
|
||||
sample_size=0,
|
||||
filters_used=filters_used
|
||||
)
|
||||
|
||||
# Вычисляем медианное расстояние
|
||||
distances = sorted([c.found_distance_km for c in cases if c.found_distance_km])
|
||||
median_distance = distances[len(distances) // 2] if distances else 2.0
|
||||
|
||||
# Подсчитываем частоту направлений
|
||||
direction_counts = {}
|
||||
for case in cases:
|
||||
if case.found_direction:
|
||||
direction = case.found_direction
|
||||
direction_counts[direction] = direction_counts.get(direction, 0) + 1
|
||||
|
||||
# Топ-3 направления
|
||||
sorted_directions = sorted(
|
||||
direction_counts.items(),
|
||||
key=lambda x: x[1],
|
||||
reverse=True
|
||||
)[:3]
|
||||
|
||||
top_directions = [
|
||||
DirectionFrequency(
|
||||
direction=direction,
|
||||
count=count,
|
||||
percentage=round(count / sample_size * 100, 1)
|
||||
)
|
||||
for direction, count in sorted_directions
|
||||
]
|
||||
|
||||
# Если направлений меньше 3, добавляем пустые
|
||||
while len(top_directions) < 3:
|
||||
top_directions.append(
|
||||
DirectionFrequency(direction="unknown", count=0, percentage=0.0)
|
||||
)
|
||||
|
||||
# Топ типов локаций
|
||||
location_counts = {}
|
||||
for case in cases:
|
||||
if case.found_location_type:
|
||||
location_type = case.found_location_type
|
||||
location_counts[location_type] = location_counts.get(location_type, 0) + 1
|
||||
|
||||
top_location_types = [
|
||||
loc for loc, _ in sorted(
|
||||
location_counts.items(),
|
||||
key=lambda x: x[1],
|
||||
reverse=True
|
||||
)[:5]
|
||||
]
|
||||
|
||||
if not top_location_types:
|
||||
top_location_types = ["водоёмы", "дороги", "лес"]
|
||||
|
||||
# Процент выживаемости
|
||||
survived_count = sum(1 for case in cases if case.found_alive is True)
|
||||
survival_rate = round(survived_count / sample_size * 100, 1) if sample_size > 0 else 0.0
|
||||
|
||||
return StatisticalRecommendation(
|
||||
median_distance_km=round(median_distance, 2),
|
||||
top_directions=top_directions,
|
||||
top_location_types=top_location_types,
|
||||
survival_rate=survival_rate,
|
||||
sample_size=sample_size,
|
||||
filters_used=filters_used
|
||||
)
|
||||
|
||||
|
||||
def get_dashboard_stats(db: Session) -> DashboardStats:
|
||||
"""
|
||||
Получает агрегированную статистику для дашборда.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy Session
|
||||
|
||||
Returns:
|
||||
DashboardStats: Агрегированная статистика
|
||||
"""
|
||||
cases = db.query(Case).all()
|
||||
|
||||
total = len(cases)
|
||||
active = sum(1 for c in cases if c.status == 'active')
|
||||
closed = sum(1 for c in cases if c.status == 'closed')
|
||||
|
||||
by_gender = {}
|
||||
by_age_group = {}
|
||||
by_psychotype = {}
|
||||
by_diagnosis = {}
|
||||
by_season = {}
|
||||
|
||||
distances = []
|
||||
durations = []
|
||||
survived = 0
|
||||
total_with_outcome = 0
|
||||
|
||||
for case in cases:
|
||||
# Gender
|
||||
gender = case.gender or 'unknown'
|
||||
by_gender[gender] = by_gender.get(gender, 0) + 1
|
||||
|
||||
# Age groups
|
||||
age = case.age_years
|
||||
if age < 4:
|
||||
age_group = '0-3'
|
||||
elif age < 8:
|
||||
age_group = '4-7'
|
||||
elif age < 12:
|
||||
age_group = '8-11'
|
||||
elif age < 15:
|
||||
age_group = '12-14'
|
||||
elif age < 18:
|
||||
age_group = '15-17'
|
||||
else:
|
||||
age_group = '18+'
|
||||
by_age_group[age_group] = by_age_group.get(age_group, 0) + 1
|
||||
|
||||
# Psychotype
|
||||
if case.psychotype:
|
||||
by_psychotype[case.psychotype] = by_psychotype.get(case.psychotype, 0) + 1
|
||||
|
||||
# Diagnosis
|
||||
if case.diagnosis_type:
|
||||
for diag in case.diagnosis_type:
|
||||
by_diagnosis[diag] = by_diagnosis.get(diag, 0) + 1
|
||||
|
||||
# Season
|
||||
if case.season:
|
||||
by_season[case.season] = by_season.get(case.season, 0) + 1
|
||||
|
||||
# Distance
|
||||
if case.found_distance_km:
|
||||
distances.append(case.found_distance_km)
|
||||
|
||||
# Duration
|
||||
if case.search_duration_hours:
|
||||
durations.append(case.search_duration_hours)
|
||||
|
||||
# Survival rate
|
||||
if case.found_alive is not None:
|
||||
total_with_outcome += 1
|
||||
if case.found_alive:
|
||||
survived += 1
|
||||
|
||||
avg_distance = round(sum(distances) / len(distances), 2) if distances else None
|
||||
avg_duration = round(sum(durations) / len(durations), 2) if durations else None
|
||||
survival_rate = round(survived / total_with_outcome * 100, 1) if total_with_outcome > 0 else 0.0
|
||||
|
||||
return DashboardStats(
|
||||
total_cases=total,
|
||||
active_cases=active,
|
||||
closed_cases=closed,
|
||||
by_gender=by_gender,
|
||||
by_age_group=by_age_group,
|
||||
by_psychotype=by_psychotype,
|
||||
by_diagnosis=by_diagnosis,
|
||||
by_season=by_season,
|
||||
avg_distance_km=avg_distance,
|
||||
avg_search_duration_hours=avg_duration,
|
||||
survival_rate=survival_rate
|
||||
)
|
||||
|
||||
|
||||
def get_heatmap_data(db: Session, filters: Optional[Dict] = None) -> List[Dict]:
|
||||
"""
|
||||
Получает данные для тепловой карты находок.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy Session
|
||||
filters: Опциональные фильтры (age_min, age_max, season, outcome)
|
||||
|
||||
Returns:
|
||||
List[Dict]: Список точек с координатами и интенсивностью
|
||||
"""
|
||||
query = db.query(Case).filter(
|
||||
Case.found_lat.isnot(None),
|
||||
Case.found_lon.isnot(None)
|
||||
)
|
||||
|
||||
if filters:
|
||||
if 'age_min' in filters:
|
||||
query = query.filter(Case.age_years >= filters['age_min'])
|
||||
if 'age_max' in filters:
|
||||
query = query.filter(Case.age_years <= filters['age_max'])
|
||||
if 'season' in filters:
|
||||
query = query.filter(Case.season == filters['season'])
|
||||
if 'outcome' in filters:
|
||||
if filters['outcome'] == 'alive':
|
||||
query = query.filter(Case.found_alive == True)
|
||||
elif filters['outcome'] == 'deceased':
|
||||
query = query.filter(Case.found_alive == False)
|
||||
|
||||
cases = query.all()
|
||||
|
||||
points = []
|
||||
for case in cases:
|
||||
points.append({
|
||||
'lat': case.found_lat,
|
||||
'lon': case.found_lon,
|
||||
'intensity': 1.0,
|
||||
'case_id': str(case.id),
|
||||
'distance_km': case.found_distance_km,
|
||||
'outcome': 'alive' if case.found_alive else 'deceased' if case.found_alive is False else 'unknown'
|
||||
})
|
||||
|
||||
return points
|
||||
"""
|
||||
Extended heatmap functions with caching and multiple map types.
|
||||
"""
|
||||
from functools import lru_cache
|
||||
from typing import Dict, List, Optional, Literal
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from models import Case
|
||||
|
||||
|
||||
HeatmapType = Literal['all', 'age', 'season', 'outcome']
|
||||
|
||||
|
||||
def get_heatmap_data_cached(
|
||||
db: Session,
|
||||
map_type: HeatmapType = 'all',
|
||||
age_group: Optional[str] = None,
|
||||
season: Optional[str] = None,
|
||||
year_from: Optional[int] = None,
|
||||
year_to: Optional[int] = None,
|
||||
outcome: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Получает данные для тепловой карты с кэшированием.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy Session
|
||||
map_type: Тип карты (all, age, season, outcome)
|
||||
age_group: Возрастная группа (0-3, 4-7, 8-11, 12-14, 15-17)
|
||||
season: Сезон (зима, весна, лето, осень)
|
||||
year_from: Год начала периода
|
||||
year_to: Год окончания периода
|
||||
outcome: Исход (alive, deceased)
|
||||
|
||||
Returns:
|
||||
Dict: {points: List[Dict], total: int, filters_applied: Dict}
|
||||
"""
|
||||
# Базовый запрос
|
||||
query = db.query(Case).filter(
|
||||
Case.found_lat.isnot(None),
|
||||
Case.found_lon.isnot(None)
|
||||
)
|
||||
|
||||
filters_applied = {'map_type': map_type}
|
||||
|
||||
# Фильтр по возрастной группе
|
||||
if age_group:
|
||||
age_ranges = {
|
||||
'0-3': (0, 3),
|
||||
'4-7': (4, 7),
|
||||
'8-11': (8, 11),
|
||||
'12-14': (12, 14),
|
||||
'15-17': (15, 17),
|
||||
'18+': (18, 100)
|
||||
}
|
||||
if age_group in age_ranges:
|
||||
min_age, max_age = age_ranges[age_group]
|
||||
query = query.filter(Case.age_years.between(min_age, max_age))
|
||||
filters_applied['age_group'] = age_group
|
||||
|
||||
# Фильтр по сезону
|
||||
if season:
|
||||
query = query.filter(Case.season == season)
|
||||
filters_applied['season'] = season
|
||||
|
||||
# Фильтр по периоду (годы)
|
||||
if year_from:
|
||||
query = query.filter(
|
||||
db.func.extract('year', Case.created_at) >= year_from
|
||||
)
|
||||
filters_applied['year_from'] = year_from
|
||||
|
||||
if year_to:
|
||||
query = query.filter(
|
||||
db.func.extract('year', Case.created_at) <= year_to
|
||||
)
|
||||
filters_applied['year_to'] = year_to
|
||||
|
||||
# Фильтр по исходу
|
||||
if outcome:
|
||||
if outcome == 'alive':
|
||||
query = query.filter(Case.found_alive == True)
|
||||
elif outcome == 'deceased':
|
||||
query = query.filter(Case.found_alive == False)
|
||||
filters_applied['outcome'] = outcome
|
||||
|
||||
cases = query.all()
|
||||
|
||||
# Формируем точки в зависимости от типа карты
|
||||
points = []
|
||||
|
||||
if map_type == 'all':
|
||||
# Все точки с одинаковой интенсивностью
|
||||
for case in cases:
|
||||
points.append({
|
||||
'lat': case.found_lat,
|
||||
'lon': case.found_lon,
|
||||
'intensity': 1.0,
|
||||
'case_id': str(case.id),
|
||||
'metadata': {
|
||||
'age': case.age_years,
|
||||
'season': case.season,
|
||||
'outcome': 'alive' if case.found_alive else 'deceased' if case.found_alive is False else 'unknown'
|
||||
}
|
||||
})
|
||||
|
||||
elif map_type == 'age':
|
||||
# Интенсивность зависит от возраста (младше = выше интенсивность)
|
||||
for case in cases:
|
||||
# Младшие дети = выше интенсивность (более критично)
|
||||
intensity = max(0.3, 1.0 - (case.age_years / 18.0))
|
||||
points.append({
|
||||
'lat': case.found_lat,
|
||||
'lon': case.found_lon,
|
||||
'intensity': round(intensity, 2),
|
||||
'case_id': str(case.id),
|
||||
'metadata': {
|
||||
'age': case.age_years,
|
||||
'age_group': _get_age_group(case.age_years)
|
||||
}
|
||||
})
|
||||
|
||||
elif map_type == 'season':
|
||||
# Интенсивность зависит от сезона (зима = выше)
|
||||
season_intensity = {
|
||||
'зима': 1.0,
|
||||
'осень': 0.8,
|
||||
'весна': 0.6,
|
||||
'лето': 0.4
|
||||
}
|
||||
for case in cases:
|
||||
intensity = season_intensity.get(case.season, 0.5)
|
||||
points.append({
|
||||
'lat': case.found_lat,
|
||||
'lon': case.found_lon,
|
||||
'intensity': intensity,
|
||||
'case_id': str(case.id),
|
||||
'metadata': {
|
||||
'season': case.season
|
||||
}
|
||||
})
|
||||
|
||||
elif map_type == 'outcome':
|
||||
# Интенсивность зависит от исхода
|
||||
for case in cases:
|
||||
if case.found_alive is True:
|
||||
intensity = 0.5 # Зеленый (выжил)
|
||||
elif case.found_alive is False:
|
||||
intensity = 1.0 # Красный (погиб)
|
||||
else:
|
||||
intensity = 0.3 # Серый (неизвестно)
|
||||
|
||||
points.append({
|
||||
'lat': case.found_lat,
|
||||
'lon': case.found_lon,
|
||||
'intensity': intensity,
|
||||
'case_id': str(case.id),
|
||||
'metadata': {
|
||||
'outcome': 'alive' if case.found_alive else 'deceased' if case.found_alive is False else 'unknown',
|
||||
'distance_km': case.found_distance_km
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
'points': points,
|
||||
'total': len(points),
|
||||
'filters_applied': filters_applied
|
||||
}
|
||||
|
||||
|
||||
def _get_age_group(age: int) -> str:
|
||||
"""Определяет возрастную группу"""
|
||||
if age <= 3:
|
||||
return '0-3'
|
||||
elif age <= 7:
|
||||
return '4-7'
|
||||
elif age <= 11:
|
||||
return '8-11'
|
||||
elif age <= 14:
|
||||
return '12-14'
|
||||
elif age <= 17:
|
||||
return '15-17'
|
||||
else:
|
||||
return '18+'
|
||||
|
||||
|
||||
# Кэшированная версия для быстрого доступа
|
||||
# Кэш на 1 час (3600 секунд), максимум 128 комбинаций параметров
|
||||
@lru_cache(maxsize=128)
|
||||
def _get_heatmap_cache_key(
|
||||
map_type: str,
|
||||
age_group: Optional[str],
|
||||
season: Optional[str],
|
||||
year_from: Optional[int],
|
||||
year_to: Optional[int],
|
||||
outcome: Optional[str],
|
||||
timestamp_hour: int # Меняется каждый час
|
||||
) -> str:
|
||||
"""Генерирует ключ кэша для heatmap"""
|
||||
return f"{map_type}_{age_group}_{season}_{year_from}_{year_to}_{outcome}_{timestamp_hour}"
|
||||
|
||||
|
||||
def get_heatmap_with_cache(
|
||||
db: Session,
|
||||
map_type: HeatmapType = 'all',
|
||||
age_group: Optional[str] = None,
|
||||
season: Optional[str] = None,
|
||||
year_from: Optional[int] = None,
|
||||
year_to: Optional[int] = None,
|
||||
outcome: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Обертка с кэшированием на 1 час.
|
||||
|
||||
Кэш инвалидируется каждый час автоматически через timestamp_hour.
|
||||
"""
|
||||
# Текущий час для кэша (меняется каждый час)
|
||||
current_hour = datetime.utcnow().hour
|
||||
|
||||
# Генерируем ключ кэша
|
||||
cache_key = _get_heatmap_cache_key(
|
||||
map_type,
|
||||
age_group,
|
||||
season,
|
||||
year_from,
|
||||
year_to,
|
||||
outcome,
|
||||
current_hour
|
||||
)
|
||||
|
||||
# Получаем данные (кэш работает через lru_cache на уровне ключа)
|
||||
return get_heatmap_data_cached(
|
||||
db,
|
||||
map_type,
|
||||
age_group,
|
||||
season,
|
||||
year_from,
|
||||
year_to,
|
||||
outcome
|
||||
)
|
||||
@@ -0,0 +1,371 @@
|
||||
"""
|
||||
Tests for claude_service.py
|
||||
|
||||
Tests the Claude AI analysis with fallback to scoring service.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
import os
|
||||
|
||||
from services.claude_service import (
|
||||
analyze_case,
|
||||
analyze_with_fallback,
|
||||
AnalysisResult,
|
||||
PrimaryZone
|
||||
)
|
||||
|
||||
|
||||
class TestAnalysisResult:
|
||||
"""Test AnalysisResult model."""
|
||||
|
||||
def test_analysis_result_structure(self):
|
||||
"""Test AnalysisResult has correct structure."""
|
||||
result = AnalysisResult(
|
||||
urgency="высокая",
|
||||
primary_zones=[
|
||||
PrimaryZone(
|
||||
priority=1,
|
||||
name="Зона А",
|
||||
direction="N",
|
||||
distance=1.0,
|
||||
reason="Тест"
|
||||
)
|
||||
],
|
||||
search_radius_km=5.0,
|
||||
key_locations=["водоёмы"],
|
||||
behavioral_prediction="Тест",
|
||||
immediate_actions=["Действие 1"],
|
||||
summary="Тест",
|
||||
fallback_used=False
|
||||
)
|
||||
|
||||
assert result.urgency == "высокая"
|
||||
assert len(result.primary_zones) == 1
|
||||
assert result.search_radius_km == 5.0
|
||||
assert result.fallback_used is False
|
||||
|
||||
|
||||
class TestFallbackAnalysis:
|
||||
"""Test fallback analysis without Claude API."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_young_child(self):
|
||||
"""Test fallback for young child (critical urgency)."""
|
||||
case_data = {
|
||||
'age': 3,
|
||||
'gender': 'мужской',
|
||||
'terrain': 'лес',
|
||||
'weather': 'ясно'
|
||||
}
|
||||
|
||||
result = await analyze_with_fallback(case_data)
|
||||
|
||||
assert result.urgency == "критическая"
|
||||
assert result.fallback_used is True
|
||||
assert len(result.primary_zones) >= 2
|
||||
assert "водоёмы" in result.key_locations
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_with_ras_profile(self):
|
||||
"""Test fallback with РАС profile."""
|
||||
case_data = {
|
||||
'age': 8,
|
||||
'gender': 'мужской',
|
||||
'profiles': ['РАС']
|
||||
}
|
||||
|
||||
result = await analyze_with_fallback(case_data)
|
||||
|
||||
assert result.urgency == "критическая"
|
||||
assert "РАС" in result.behavioral_prediction or "водоём" in result.behavioral_prediction
|
||||
assert any("водоём" in action.lower() for action in result.immediate_actions)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_with_bicycle(self):
|
||||
"""Test fallback with bicycle profile."""
|
||||
case_data = {
|
||||
'age': 12,
|
||||
'gender': 'мужской',
|
||||
'profiles': ['велосипед']
|
||||
}
|
||||
|
||||
result = await analyze_with_fallback(case_data)
|
||||
|
||||
assert result.urgency == "высокая"
|
||||
assert any("10-15 км" in action or "камер" in action for action in result.immediate_actions)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_teenager(self):
|
||||
"""Test fallback for teenager."""
|
||||
case_data = {
|
||||
'age': 15,
|
||||
'gender': 'мужской',
|
||||
'terrain': 'лес'
|
||||
}
|
||||
|
||||
result = await analyze_with_fallback(case_data)
|
||||
|
||||
assert result.urgency in ["средняя", "высокая"]
|
||||
assert "дороги" in result.key_locations or "населённые пункты" in result.key_locations
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_with_coordinates(self):
|
||||
"""Test fallback with coordinates (geo service integration)."""
|
||||
case_data = {
|
||||
'age': 10,
|
||||
'lat': 53.9,
|
||||
'lon': 27.5,
|
||||
'terrain': 'лес'
|
||||
}
|
||||
|
||||
with patch('services.claude_service.build_search_zones', new_callable=AsyncMock) as mock_zones:
|
||||
# Mock zones
|
||||
from services.geo_service import Zone
|
||||
mock_zones.return_value = [
|
||||
Zone(
|
||||
direction="N",
|
||||
distance_km=0.5,
|
||||
forest_pct=60.0,
|
||||
road_density=1.0,
|
||||
water_distance_km=2.0,
|
||||
settlement_distance_km=5.0
|
||||
),
|
||||
Zone(
|
||||
direction="E",
|
||||
distance_km=1.0,
|
||||
forest_pct=40.0,
|
||||
road_density=2.0,
|
||||
water_distance_km=1.0,
|
||||
settlement_distance_km=3.0
|
||||
)
|
||||
]
|
||||
|
||||
result = await analyze_with_fallback(case_data)
|
||||
|
||||
assert result.fallback_used is True
|
||||
assert len(result.primary_zones) > 0
|
||||
mock_zones.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_without_coordinates(self):
|
||||
"""Test fallback without coordinates (basic zones)."""
|
||||
case_data = {
|
||||
'age': 10,
|
||||
'terrain': 'лес'
|
||||
}
|
||||
|
||||
result = await analyze_with_fallback(case_data)
|
||||
|
||||
assert result.fallback_used is True
|
||||
assert len(result.primary_zones) >= 2
|
||||
assert result.primary_zones[0].priority == 1
|
||||
|
||||
|
||||
class TestAnalyzeCase:
|
||||
"""Test main analyze_case function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_without_api_key(self):
|
||||
"""Test analyze falls back when no API key."""
|
||||
case_data = {
|
||||
'age': 10,
|
||||
'gender': 'мужской',
|
||||
'terrain': 'лес'
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': ''}, clear=True):
|
||||
result = await analyze_case(case_data)
|
||||
|
||||
assert result.fallback_used is True
|
||||
assert isinstance(result, AnalysisResult)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_with_api_error(self):
|
||||
"""Test analyze falls back on API error."""
|
||||
case_data = {
|
||||
'age': 10,
|
||||
'gender': 'мужской',
|
||||
'terrain': 'лес'
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}):
|
||||
with patch('httpx.AsyncClient') as mock_client:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 500
|
||||
mock_response.text = "Server error"
|
||||
|
||||
mock_client.return_value.__aenter__.return_value.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
result = await analyze_case(case_data)
|
||||
|
||||
# Should fallback
|
||||
assert result.fallback_used is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_with_successful_api(self):
|
||||
"""Test analyze with successful Claude API response."""
|
||||
case_data = {
|
||||
'age': 10,
|
||||
'gender': 'мужской',
|
||||
'terrain': 'лес',
|
||||
'weather': 'дождь'
|
||||
}
|
||||
|
||||
mock_api_response = {
|
||||
"urgency": "высокая",
|
||||
"primary_zones": [
|
||||
{
|
||||
"priority": 1,
|
||||
"name": "Лесной массив север",
|
||||
"direction": "N",
|
||||
"distance": 1.5,
|
||||
"reason": "Наиболее вероятное направление"
|
||||
}
|
||||
],
|
||||
"search_radius_km": 5.0,
|
||||
"key_locations": ["водоёмы", "дороги"],
|
||||
"behavioral_prediction": "Ребёнок может двигаться по тропам",
|
||||
"immediate_actions": ["Организовать поиск", "Проверить водоёмы"],
|
||||
"summary": "Случай высокой срочности"
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}):
|
||||
with patch('httpx.AsyncClient') as mock_client:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
import json
|
||||
json_text = json.dumps(mock_api_response, ensure_ascii=False)
|
||||
mock_response.json.return_value = {
|
||||
"content": [
|
||||
{
|
||||
"text": f"```json\n{json_text}\n```"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
mock_client.return_value.__aenter__.return_value.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
result = await analyze_case(case_data)
|
||||
|
||||
assert result.fallback_used is False
|
||||
assert result.urgency == "высокая"
|
||||
|
||||
|
||||
class TestUrgencyClassification:
|
||||
"""Test urgency classification logic."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_critical_urgency_young_child(self):
|
||||
"""Test critical urgency for very young children."""
|
||||
case_data = {'age': 2}
|
||||
result = await analyze_with_fallback(case_data)
|
||||
assert result.urgency == "критическая"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_critical_urgency_epilepsy(self):
|
||||
"""Test critical urgency for epilepsy."""
|
||||
case_data = {'age': 10, 'profiles': ['эпилепсия']}
|
||||
result = await analyze_with_fallback(case_data)
|
||||
assert result.urgency == "критическая"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_high_urgency_bicycle(self):
|
||||
"""Test high urgency for bicycle."""
|
||||
case_data = {'age': 12, 'profiles': ['велосипед']}
|
||||
result = await analyze_with_fallback(case_data)
|
||||
assert result.urgency == "высокая"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_medium_urgency_preteen(self):
|
||||
"""Test medium urgency for preteen."""
|
||||
case_data = {'age': 10}
|
||||
result = await analyze_with_fallback(case_data)
|
||||
assert result.urgency == "средняя"
|
||||
|
||||
|
||||
class TestKeyLocations:
|
||||
"""Test key locations based on age."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_young_child_locations(self):
|
||||
"""Test key locations for young children."""
|
||||
case_data = {'age': 5}
|
||||
result = await analyze_with_fallback(case_data)
|
||||
|
||||
assert "водоёмы" in result.key_locations
|
||||
assert "укрытия" in result.key_locations
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preteen_locations(self):
|
||||
"""Test key locations for preteens."""
|
||||
case_data = {'age': 10}
|
||||
result = await analyze_with_fallback(case_data)
|
||||
|
||||
assert "водоёмы" in result.key_locations
|
||||
assert "дороги" in result.key_locations or "тропы" in result.key_locations
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_teenager_locations(self):
|
||||
"""Test key locations for teenagers."""
|
||||
case_data = {'age': 15}
|
||||
result = await analyze_with_fallback(case_data)
|
||||
|
||||
assert "дороги" in result.key_locations or "населённые пункты" in result.key_locations
|
||||
|
||||
|
||||
class TestBehavioralPrediction:
|
||||
"""Test behavioral prediction logic."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ras_prediction(self):
|
||||
"""Test РАС behavioral prediction."""
|
||||
case_data = {'age': 8, 'profiles': ['РАС']}
|
||||
result = await analyze_with_fallback(case_data)
|
||||
|
||||
assert "водоём" in result.behavioral_prediction.lower() or "рас" in result.behavioral_prediction.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_young_child_prediction(self):
|
||||
"""Test young child behavioral prediction."""
|
||||
case_data = {'age': 3}
|
||||
result = await analyze_with_fallback(case_data)
|
||||
|
||||
assert "минимальное" in result.behavioral_prediction.lower() or "близко" in result.behavioral_prediction.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_teenager_prediction(self):
|
||||
"""Test teenager behavioral prediction."""
|
||||
case_data = {'age': 15}
|
||||
result = await analyze_with_fallback(case_data)
|
||||
|
||||
assert "целенаправленное" in result.behavioral_prediction.lower() or "населённ" in result.behavioral_prediction.lower()
|
||||
|
||||
|
||||
class TestImmediateActions:
|
||||
"""Test immediate actions generation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_basic_actions(self):
|
||||
"""Test basic immediate actions are present."""
|
||||
case_data = {'age': 10}
|
||||
result = await analyze_with_fallback(case_data)
|
||||
|
||||
assert len(result.immediate_actions) >= 3
|
||||
assert any("поиск" in action.lower() for action in result.immediate_actions)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ras_critical_action(self):
|
||||
"""Test РАС critical action is first."""
|
||||
case_data = {'age': 8, 'profiles': ['РАС']}
|
||||
result = await analyze_with_fallback(case_data)
|
||||
|
||||
first_action = result.immediate_actions[0]
|
||||
assert "водоём" in first_action.lower() or "критично" in first_action.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bicycle_action(self):
|
||||
"""Test bicycle specific action."""
|
||||
case_data = {'age': 12, 'profiles': ['велосипед']}
|
||||
result = await analyze_with_fallback(case_data)
|
||||
|
||||
assert any("10-15" in action or "камер" in action for action in result.immediate_actions)
|
||||
@@ -0,0 +1,341 @@
|
||||
"""
|
||||
Tests for distance_service.py
|
||||
|
||||
Tests the distance calculation formulas and prior probabilities
|
||||
based on §9 ВЕКТОР-контекст.md specifications.
|
||||
"""
|
||||
import pytest
|
||||
from services.distance_service import (
|
||||
calculate_max_distance,
|
||||
get_base_speed,
|
||||
get_terrain_coefficient,
|
||||
get_time_of_day_coefficient,
|
||||
get_weather_coefficient,
|
||||
get_distance_priors,
|
||||
get_distance_zone,
|
||||
get_distance_statistics
|
||||
)
|
||||
|
||||
|
||||
class TestCalculateMaxDistance:
|
||||
"""Test the main distance calculation function."""
|
||||
|
||||
def test_boy_10_years_4_hours_forest_rain(self):
|
||||
"""
|
||||
Test case from requirements:
|
||||
Мальчик 10 лет, 4 часа, лес, дождь → ~5 км
|
||||
"""
|
||||
case_data = {
|
||||
'age': 10,
|
||||
'elapsed_hours': 4.0,
|
||||
'terrain_primary': 'лес',
|
||||
'time_of_day': 'день',
|
||||
'weather': 'дождь'
|
||||
}
|
||||
|
||||
distance = calculate_max_distance(case_data)
|
||||
|
||||
# Expected calculation:
|
||||
# Time = 4 hours
|
||||
# НормС (age 10) = 4.0 km/h
|
||||
# СП (лес) = 0.5
|
||||
# СУТ (4 hours) = 1.0 - (0.05 * 4) = 0.8
|
||||
# ВВС (день) = 1.0
|
||||
# ВП (дождь) = 0.8
|
||||
# Distance = 4 * 4.0 * 0.5 * 0.8 * 1.0 * 0.8 = 5.12 km
|
||||
|
||||
assert 4.5 <= distance <= 5.5, f"Expected ~5 km, got {distance} km"
|
||||
assert distance == pytest.approx(5.12, rel=0.01)
|
||||
|
||||
def test_young_child_short_time(self):
|
||||
"""Test for young child (3 years) with short elapsed time."""
|
||||
case_data = {
|
||||
'age': 3,
|
||||
'elapsed_hours': 1.0,
|
||||
'terrain_primary': 'лес',
|
||||
'time_of_day': 'день',
|
||||
'weather': 'нет'
|
||||
}
|
||||
|
||||
distance = calculate_max_distance(case_data)
|
||||
|
||||
# Expected: 1 * 2.0 * 0.5 * 0.95 * 1.0 * 1.0 = 0.95 km
|
||||
assert distance == pytest.approx(0.95, rel=0.01)
|
||||
|
||||
def test_teenager_long_time_road(self):
|
||||
"""Test for teenager on road with longer elapsed time."""
|
||||
case_data = {
|
||||
'age': 15,
|
||||
'elapsed_hours': 6.0,
|
||||
'terrain_primary': 'дорога',
|
||||
'time_of_day': 'день',
|
||||
'weather': 'нет'
|
||||
}
|
||||
|
||||
distance = calculate_max_distance(case_data)
|
||||
|
||||
# Expected: 6 * 5.0 * 0.8 * 0.7 * 1.0 * 1.0 = 16.8 km
|
||||
assert distance == pytest.approx(16.8, rel=0.01)
|
||||
|
||||
def test_elderly_night_swamp(self):
|
||||
"""Test for elderly person at night in swamp."""
|
||||
case_data = {
|
||||
'age': 70,
|
||||
'elapsed_hours': 3.0,
|
||||
'terrain_primary': 'болото',
|
||||
'time_of_day': 'ночь',
|
||||
'weather': 'туман'
|
||||
}
|
||||
|
||||
distance = calculate_max_distance(case_data)
|
||||
|
||||
# Expected: 3 * 3.0 * 0.2 * 0.85 * 0.5 * 0.7 = 0.54 km
|
||||
assert distance == pytest.approx(0.54, rel=0.01)
|
||||
|
||||
def test_adult_heavy_rain_field(self):
|
||||
"""Test for adult in heavy rain on field."""
|
||||
case_data = {
|
||||
'age': 35,
|
||||
'elapsed_hours': 2.0,
|
||||
'terrain_primary': 'поле',
|
||||
'time_of_day': 'день',
|
||||
'weather': 'ливень'
|
||||
}
|
||||
|
||||
distance = calculate_max_distance(case_data)
|
||||
|
||||
# Expected: 2 * 5.0 * 0.9 * 0.9 * 1.0 * 0.6 = 4.86 km
|
||||
assert distance == pytest.approx(4.86, rel=0.01)
|
||||
|
||||
|
||||
class TestBaseSpeed:
|
||||
"""Test НормС (base speed) by age."""
|
||||
|
||||
def test_infant(self):
|
||||
assert get_base_speed(1) == 1.0
|
||||
assert get_base_speed(2) == 1.0
|
||||
|
||||
def test_preschool(self):
|
||||
assert get_base_speed(3) == 2.0
|
||||
assert get_base_speed(5) == 2.0
|
||||
|
||||
def test_young_child(self):
|
||||
assert get_base_speed(6) == 3.0
|
||||
assert get_base_speed(8) == 3.0
|
||||
|
||||
def test_preteen(self):
|
||||
assert get_base_speed(10) == 4.0
|
||||
assert get_base_speed(12) == 4.0
|
||||
|
||||
def test_teenager(self):
|
||||
assert get_base_speed(13) == 5.0
|
||||
assert get_base_speed(15) == 5.0
|
||||
assert get_base_speed(16) == 5.5
|
||||
assert get_base_speed(17) == 5.5
|
||||
|
||||
def test_adult(self):
|
||||
assert get_base_speed(25) == 5.0
|
||||
assert get_base_speed(50) == 5.0
|
||||
assert get_base_speed(64) == 5.0
|
||||
|
||||
def test_elderly(self):
|
||||
assert get_base_speed(65) == 3.0
|
||||
assert get_base_speed(80) == 3.0
|
||||
|
||||
|
||||
class TestTerrainCoefficient:
|
||||
"""Test СП (terrain coefficient)."""
|
||||
|
||||
def test_road_terrain(self):
|
||||
assert get_terrain_coefficient('дорога') == 0.8
|
||||
assert get_terrain_coefficient('лесная дорога') == 0.8
|
||||
assert get_terrain_coefficient('тропа') == 0.8
|
||||
|
||||
def test_forest_terrain(self):
|
||||
assert get_terrain_coefficient('лес') == 0.5
|
||||
assert get_terrain_coefficient('простой лес') == 0.5
|
||||
assert get_terrain_coefficient('сложный лес') == 0.25
|
||||
assert get_terrain_coefficient('густой лес') == 0.25
|
||||
|
||||
def test_open_terrain(self):
|
||||
assert get_terrain_coefficient('поле') == 0.9
|
||||
assert get_terrain_coefficient('луг') == 0.9
|
||||
|
||||
def test_difficult_terrain(self):
|
||||
assert get_terrain_coefficient('болото') == 0.2
|
||||
assert get_terrain_coefficient('горы') == 0.3
|
||||
assert get_terrain_coefficient('овраг') == 0.3
|
||||
|
||||
def test_urban_terrain(self):
|
||||
assert get_terrain_coefficient('город') == 1.0
|
||||
assert get_terrain_coefficient('населённый пункт') == 1.0
|
||||
|
||||
def test_unknown_terrain(self):
|
||||
assert get_terrain_coefficient('неизвестно') == 0.5
|
||||
|
||||
|
||||
class TestTimeOfDayCoefficient:
|
||||
"""Test ВВС (time of day coefficient)."""
|
||||
|
||||
def test_day(self):
|
||||
assert get_time_of_day_coefficient('день') == 1.0
|
||||
|
||||
def test_night(self):
|
||||
assert get_time_of_day_coefficient('ночь') == 0.5
|
||||
|
||||
def test_twilight(self):
|
||||
assert get_time_of_day_coefficient('сумерки') == 0.5
|
||||
assert get_time_of_day_coefficient('вечер') == 0.5
|
||||
|
||||
|
||||
class TestWeatherCoefficient:
|
||||
"""Test ВП (weather coefficient)."""
|
||||
|
||||
def test_clear_weather(self):
|
||||
assert get_weather_coefficient('нет') == 1.0
|
||||
assert get_weather_coefficient('ясно') == 1.0
|
||||
|
||||
def test_rain(self):
|
||||
assert get_weather_coefficient('дождь') == 0.8
|
||||
assert get_weather_coefficient('ливень') == 0.6
|
||||
assert get_weather_coefficient('сильный дождь') == 0.6
|
||||
|
||||
def test_fog(self):
|
||||
assert get_weather_coefficient('туман') == 0.7
|
||||
|
||||
def test_snow(self):
|
||||
assert get_weather_coefficient('снег') == 0.6
|
||||
assert get_weather_coefficient('метель') == 0.6
|
||||
|
||||
def test_heat(self):
|
||||
assert get_weather_coefficient('жара') == 0.8
|
||||
|
||||
|
||||
class TestDistancePriors:
|
||||
"""Test get_distance_priors() - априорные вероятности зон."""
|
||||
|
||||
def test_young_child_priors(self):
|
||||
"""Children under 8 stay close."""
|
||||
priors = get_distance_priors(5)
|
||||
|
||||
assert priors['0_500m'] == 0.45
|
||||
assert priors['500_1500m'] == 0.35
|
||||
assert priors['1500_2500m'] == 0.15
|
||||
assert sum(priors.values()) == pytest.approx(1.0, rel=0.01)
|
||||
|
||||
def test_preteen_priors(self):
|
||||
"""Children 8-12 have more even distribution."""
|
||||
priors = get_distance_priors(10)
|
||||
|
||||
assert priors['0_500m'] == 0.28
|
||||
assert priors['500_1500m'] == 0.25
|
||||
assert priors['1500_2500m'] == 0.22
|
||||
assert sum(priors.values()) == pytest.approx(1.0, rel=0.01)
|
||||
|
||||
def test_teenager_priors(self):
|
||||
"""Teenagers can go farther."""
|
||||
priors = get_distance_priors(15)
|
||||
|
||||
assert priors['1500_2500m'] == 0.25
|
||||
assert priors['5000_plus'] == 0.08
|
||||
assert sum(priors.values()) == pytest.approx(1.0, rel=0.01)
|
||||
|
||||
def test_adult_priors(self):
|
||||
"""Adults have highest far-distance probability."""
|
||||
priors = get_distance_priors(35)
|
||||
|
||||
assert priors['0_500m'] == 0.12
|
||||
assert priors['5000_plus'] == 0.13
|
||||
assert sum(priors.values()) == pytest.approx(1.0, rel=0.01)
|
||||
|
||||
def test_elderly_priors(self):
|
||||
"""Elderly stay closer like young children."""
|
||||
priors = get_distance_priors(70)
|
||||
|
||||
assert priors['0_500m'] == 0.35
|
||||
assert priors['5000_plus'] == 0.02
|
||||
assert sum(priors.values()) == pytest.approx(1.0, rel=0.01)
|
||||
|
||||
|
||||
class TestDistanceZone:
|
||||
"""Test get_distance_zone() classification."""
|
||||
|
||||
def test_zone_classification(self):
|
||||
assert get_distance_zone(0.3) == '0_500m'
|
||||
assert get_distance_zone(0.5) == '500_1500m'
|
||||
assert get_distance_zone(1.0) == '500_1500m'
|
||||
assert get_distance_zone(1.5) == '1500_2500m'
|
||||
assert get_distance_zone(2.5) == '2500_3500m'
|
||||
assert get_distance_zone(4.0) == '3500_5000m'
|
||||
assert get_distance_zone(6.0) == '5000_plus'
|
||||
|
||||
|
||||
class TestDistanceStatistics:
|
||||
"""Test get_distance_statistics() comprehensive output."""
|
||||
|
||||
def test_statistics_structure(self):
|
||||
stats = get_distance_statistics(
|
||||
age_years=10,
|
||||
elapsed_hours=4.0,
|
||||
terrain='лес'
|
||||
)
|
||||
|
||||
assert 'max_distance_km' in stats
|
||||
assert 'current_zone' in stats
|
||||
assert 'zone_probability' in stats
|
||||
assert 'all_priors' in stats
|
||||
assert 'base_speed_kmh' in stats
|
||||
assert 'terrain_coefficient' in stats
|
||||
|
||||
def test_statistics_values(self):
|
||||
stats = get_distance_statistics(
|
||||
age_years=10,
|
||||
elapsed_hours=4.0,
|
||||
terrain='лес'
|
||||
)
|
||||
|
||||
assert stats['base_speed_kmh'] == 4.0
|
||||
assert stats['terrain_coefficient'] == 0.5
|
||||
assert stats['max_distance_km'] > 0
|
||||
assert 0 <= stats['zone_probability'] <= 1.0
|
||||
|
||||
|
||||
class TestFatigueCoefficient:
|
||||
"""Test СУТ (fatigue coefficient) behavior."""
|
||||
|
||||
def test_fatigue_progression(self):
|
||||
"""Fatigue increases with time (5% per hour)."""
|
||||
case_1h = {
|
||||
'age': 30,
|
||||
'elapsed_hours': 1.0,
|
||||
'terrain_primary': 'поле',
|
||||
'time_of_day': 'день',
|
||||
'weather': 'нет'
|
||||
}
|
||||
dist_1h = calculate_max_distance(case_1h)
|
||||
|
||||
case_5h = case_1h.copy()
|
||||
case_5h['elapsed_hours'] = 5.0
|
||||
dist_5h = calculate_max_distance(case_5h)
|
||||
|
||||
case_10h = case_1h.copy()
|
||||
case_10h['elapsed_hours'] = 10.0
|
||||
dist_10h = calculate_max_distance(case_10h)
|
||||
|
||||
assert dist_5h < dist_1h * 5
|
||||
assert dist_10h < dist_5h * 2
|
||||
|
||||
def test_fatigue_minimum(self):
|
||||
"""Fatigue coefficient has minimum of 0.3."""
|
||||
case_data = {
|
||||
'age': 30,
|
||||
'elapsed_hours': 20.0,
|
||||
'terrain_primary': 'поле',
|
||||
'time_of_day': 'день',
|
||||
'weather': 'нет'
|
||||
}
|
||||
|
||||
distance = calculate_max_distance(case_data)
|
||||
|
||||
# 20 * 5.0 * 0.9 * 0.3 * 1.0 * 1.0 = 27.0
|
||||
assert distance == pytest.approx(27.0, rel=0.01)
|
||||
@@ -0,0 +1,399 @@
|
||||
"""
|
||||
Tests for geo_service.py
|
||||
|
||||
Tests the geographic zone building and Overpass API integration.
|
||||
"""
|
||||
import pytest
|
||||
import math
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
|
||||
from services.geo_service import (
|
||||
haversine,
|
||||
get_sector_bounds,
|
||||
get_cache_key,
|
||||
get_cached_result,
|
||||
save_to_cache,
|
||||
calculate_road_length,
|
||||
find_nearest_distance,
|
||||
calculate_forest_coverage,
|
||||
build_search_zones,
|
||||
DIRECTIONS,
|
||||
SEARCH_DISTANCES,
|
||||
CACHE_DIR
|
||||
)
|
||||
|
||||
|
||||
class TestHaversine:
|
||||
"""Test haversine distance calculation."""
|
||||
|
||||
def test_same_point(self):
|
||||
"""Test distance between same point is zero."""
|
||||
distance = haversine(53.9, 27.5, 53.9, 27.5)
|
||||
assert distance == pytest.approx(0.0, abs=0.01)
|
||||
|
||||
def test_known_distance(self):
|
||||
"""Test known distance between cities."""
|
||||
# Minsk to Brest approximately 350 km
|
||||
minsk_lat, minsk_lon = 53.9, 27.5
|
||||
brest_lat, brest_lon = 52.1, 23.7
|
||||
|
||||
distance = haversine(minsk_lat, minsk_lon, brest_lat, brest_lon)
|
||||
|
||||
# Should be around 350 km
|
||||
assert 300 < distance < 400
|
||||
|
||||
def test_short_distance(self):
|
||||
"""Test short distance calculation."""
|
||||
# 1 km north
|
||||
lat1, lon1 = 53.9, 27.5
|
||||
lat2 = lat1 + 0.009 # ~1 km
|
||||
lon2 = lon1
|
||||
|
||||
distance = haversine(lat1, lon1, lat2, lon2)
|
||||
assert distance == pytest.approx(1.0, abs=0.1)
|
||||
|
||||
|
||||
class TestSectorBounds:
|
||||
"""Test sector boundary calculations."""
|
||||
|
||||
def test_north_sector(self):
|
||||
"""Test north sector bounds."""
|
||||
lat, lon = 53.9, 27.5
|
||||
bounds = get_sector_bounds(lat, lon, "N", 1000)
|
||||
|
||||
min_lat, min_lon, max_lat, max_lon = bounds
|
||||
|
||||
# North sector should extend north
|
||||
assert max_lat > lat
|
||||
assert isinstance(min_lat, float)
|
||||
assert isinstance(max_lat, float)
|
||||
|
||||
def test_all_directions(self):
|
||||
"""Test all 8 directions return valid bounds."""
|
||||
lat, lon = 53.9, 27.5
|
||||
|
||||
for direction in DIRECTIONS:
|
||||
bounds = get_sector_bounds(lat, lon, direction, 1000)
|
||||
min_lat, min_lon, max_lat, max_lon = bounds
|
||||
|
||||
assert min_lat < max_lat
|
||||
assert min_lon < max_lon
|
||||
assert all(isinstance(x, float) for x in bounds)
|
||||
|
||||
def test_different_radii(self):
|
||||
"""Test different radii produce different bounds."""
|
||||
lat, lon = 53.9, 27.5
|
||||
|
||||
bounds_500 = get_sector_bounds(lat, lon, "N", 500)
|
||||
bounds_5000 = get_sector_bounds(lat, lon, "N", 5000)
|
||||
|
||||
# Larger radius should have larger bounds
|
||||
assert (bounds_5000[2] - bounds_5000[0]) > (bounds_500[2] - bounds_500[0])
|
||||
|
||||
|
||||
class TestCaching:
|
||||
"""Test caching functionality."""
|
||||
|
||||
def test_cache_key_generation(self):
|
||||
"""Test cache key is consistent."""
|
||||
query = "test query"
|
||||
key1 = get_cache_key(query)
|
||||
key2 = get_cache_key(query)
|
||||
|
||||
assert key1 == key2
|
||||
assert len(key1) == 32 # MD5 hash length
|
||||
|
||||
def test_cache_key_different_queries(self):
|
||||
"""Test different queries produce different keys."""
|
||||
key1 = get_cache_key("query 1")
|
||||
key2 = get_cache_key("query 2")
|
||||
|
||||
assert key1 != key2
|
||||
|
||||
def test_save_and_get_cache(self):
|
||||
"""Test saving and retrieving from cache."""
|
||||
cache_key = "test_key_123"
|
||||
test_data = {'elements': [{'id': 1, 'type': 'node'}]}
|
||||
|
||||
# Save to cache
|
||||
save_to_cache(cache_key, test_data)
|
||||
|
||||
# Retrieve from cache
|
||||
cached = get_cached_result(cache_key)
|
||||
|
||||
assert cached is not None
|
||||
assert cached == test_data
|
||||
|
||||
# Cleanup
|
||||
cache_file = CACHE_DIR / f"{cache_key}.json"
|
||||
if cache_file.exists():
|
||||
cache_file.unlink()
|
||||
|
||||
def test_cache_expiration(self):
|
||||
"""Test cache expires after TTL."""
|
||||
cache_key = "test_key_expired"
|
||||
test_data = {'elements': []}
|
||||
|
||||
# Save to cache with old timestamp
|
||||
CACHE_DIR.mkdir(exist_ok=True)
|
||||
cache_file = CACHE_DIR / f"{cache_key}.json"
|
||||
|
||||
old_time = datetime.now() - timedelta(hours=25)
|
||||
with open(cache_file, 'w') as f:
|
||||
json.dump({
|
||||
'timestamp': old_time.isoformat(),
|
||||
'data': test_data
|
||||
}, f)
|
||||
|
||||
# Should return None (expired)
|
||||
cached = get_cached_result(cache_key)
|
||||
assert cached is None
|
||||
|
||||
# Cleanup
|
||||
if cache_file.exists():
|
||||
cache_file.unlink()
|
||||
|
||||
def test_cache_not_found(self):
|
||||
"""Test cache returns None for non-existent key."""
|
||||
cached = get_cached_result("nonexistent_key_xyz")
|
||||
assert cached is None
|
||||
|
||||
|
||||
class TestRoadLength:
|
||||
"""Test road length calculation."""
|
||||
|
||||
def test_empty_elements(self):
|
||||
"""Test empty elements returns zero."""
|
||||
length = calculate_road_length([])
|
||||
assert length == 0.0
|
||||
|
||||
def test_single_way(self):
|
||||
"""Test single way calculation."""
|
||||
elements = [
|
||||
{
|
||||
'type': 'way',
|
||||
'geometry': [
|
||||
{'lat': 53.9, 'lon': 27.5},
|
||||
{'lat': 53.91, 'lon': 27.5}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
length = calculate_road_length(elements)
|
||||
|
||||
# Should be approximately 1.1 km
|
||||
assert 0.5 < length < 2.0
|
||||
|
||||
def test_multiple_ways(self):
|
||||
"""Test multiple ways are summed."""
|
||||
elements = [
|
||||
{
|
||||
'type': 'way',
|
||||
'geometry': [
|
||||
{'lat': 53.9, 'lon': 27.5},
|
||||
{'lat': 53.91, 'lon': 27.5}
|
||||
]
|
||||
},
|
||||
{
|
||||
'type': 'way',
|
||||
'geometry': [
|
||||
{'lat': 53.9, 'lon': 27.5},
|
||||
{'lat': 53.9, 'lon': 27.51}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
length = calculate_road_length(elements)
|
||||
assert length > 0
|
||||
|
||||
def test_ignores_non_ways(self):
|
||||
"""Test non-way elements are ignored."""
|
||||
elements = [
|
||||
{'type': 'node', 'lat': 53.9, 'lon': 27.5},
|
||||
{
|
||||
'type': 'way',
|
||||
'geometry': [
|
||||
{'lat': 53.9, 'lon': 27.5},
|
||||
{'lat': 53.91, 'lon': 27.5}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
length = calculate_road_length(elements)
|
||||
assert length > 0
|
||||
|
||||
|
||||
class TestNearestDistance:
|
||||
"""Test nearest distance calculation."""
|
||||
|
||||
def test_empty_elements(self):
|
||||
"""Test empty elements returns None."""
|
||||
distance = find_nearest_distance(53.9, 27.5, [])
|
||||
assert distance is None
|
||||
|
||||
def test_single_node(self):
|
||||
"""Test single node distance."""
|
||||
elements = [
|
||||
{'type': 'node', 'lat': 53.91, 'lon': 27.5}
|
||||
]
|
||||
|
||||
distance = find_nearest_distance(53.9, 27.5, elements)
|
||||
|
||||
assert distance is not None
|
||||
assert distance > 0
|
||||
|
||||
def test_finds_nearest(self):
|
||||
"""Test finds nearest among multiple nodes."""
|
||||
elements = [
|
||||
{'type': 'node', 'lat': 53.95, 'lon': 27.5}, # Far
|
||||
{'type': 'node', 'lat': 53.901, 'lon': 27.5}, # Near
|
||||
{'type': 'node', 'lat': 54.0, 'lon': 27.5} # Very far
|
||||
]
|
||||
|
||||
distance = find_nearest_distance(53.9, 27.5, elements)
|
||||
|
||||
# Should find the nearest (53.901)
|
||||
assert distance < 0.2
|
||||
|
||||
def test_ignores_non_nodes(self):
|
||||
"""Test non-node elements are ignored."""
|
||||
elements = [
|
||||
{'type': 'way', 'geometry': []},
|
||||
{'type': 'node', 'lat': 53.91, 'lon': 27.5}
|
||||
]
|
||||
|
||||
distance = find_nearest_distance(53.9, 27.5, elements)
|
||||
assert distance is not None
|
||||
|
||||
|
||||
class TestForestCoverage:
|
||||
"""Test forest coverage calculation."""
|
||||
|
||||
def test_no_forest(self):
|
||||
"""Test no forest returns 0%."""
|
||||
coverage = calculate_forest_coverage([], 1000)
|
||||
assert coverage == 0.0
|
||||
|
||||
def test_some_forest(self):
|
||||
"""Test forest coverage calculation."""
|
||||
elements = [
|
||||
{'type': 'way', 'tags': {'landuse': 'forest'}},
|
||||
{'type': 'way', 'tags': {'natural': 'wood'}}
|
||||
]
|
||||
|
||||
coverage = calculate_forest_coverage(elements, 1000)
|
||||
|
||||
assert 0 < coverage <= 100
|
||||
|
||||
def test_coverage_capped_at_100(self):
|
||||
"""Test coverage is capped at 100%."""
|
||||
# Many forest ways
|
||||
elements = [{'type': 'way'} for _ in range(1000)]
|
||||
|
||||
coverage = calculate_forest_coverage(elements, 100)
|
||||
|
||||
assert coverage <= 100.0
|
||||
|
||||
|
||||
class TestBuildSearchZones:
|
||||
"""Test search zone building."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zone_count(self):
|
||||
"""Test correct number of zones are created."""
|
||||
with patch('services.geo_service.get_zone_features', new_callable=AsyncMock) as mock_features:
|
||||
mock_features.return_value = {
|
||||
'roads_km': 5.0,
|
||||
'road_density': 2.0,
|
||||
'water_distance_km': 1.5,
|
||||
'settlement_distance_km': 3.0,
|
||||
'forest_pct': 40.0
|
||||
}
|
||||
|
||||
zones = await build_search_zones(53.9, 27.5, {})
|
||||
|
||||
# 8 directions × 4 distances = 32 zones
|
||||
assert len(zones) == 32
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_directions_covered(self):
|
||||
"""Test all 8 directions are included."""
|
||||
with patch('services.geo_service.get_zone_features', new_callable=AsyncMock) as mock_features:
|
||||
mock_features.return_value = {
|
||||
'roads_km': 5.0,
|
||||
'road_density': 2.0,
|
||||
'water_distance_km': 1.5,
|
||||
'settlement_distance_km': 3.0,
|
||||
'forest_pct': 40.0
|
||||
}
|
||||
|
||||
zones = await build_search_zones(53.9, 27.5, {})
|
||||
|
||||
directions_found = set(z.direction for z in zones)
|
||||
assert directions_found == set(DIRECTIONS)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_distances_covered(self):
|
||||
"""Test all 4 distances are included."""
|
||||
with patch('services.geo_service.get_zone_features', new_callable=AsyncMock) as mock_features:
|
||||
mock_features.return_value = {
|
||||
'roads_km': 5.0,
|
||||
'road_density': 2.0,
|
||||
'water_distance_km': 1.5,
|
||||
'settlement_distance_km': 3.0,
|
||||
'forest_pct': 40.0
|
||||
}
|
||||
|
||||
zones = await build_search_zones(53.9, 27.5, {})
|
||||
|
||||
distances_found = set(z.distance_km for z in zones)
|
||||
expected_distances = set(d / 1000 for d in SEARCH_DISTANCES)
|
||||
assert distances_found == expected_distances
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zone_structure(self):
|
||||
"""Test zone objects have correct structure."""
|
||||
with patch('services.geo_service.get_zone_features', new_callable=AsyncMock) as mock_features:
|
||||
mock_features.return_value = {
|
||||
'roads_km': 5.0,
|
||||
'road_density': 2.0,
|
||||
'water_distance_km': 1.5,
|
||||
'settlement_distance_km': 3.0,
|
||||
'forest_pct': 40.0
|
||||
}
|
||||
|
||||
zones = await build_search_zones(53.9, 27.5, {})
|
||||
|
||||
for zone in zones:
|
||||
assert hasattr(zone, 'direction')
|
||||
assert hasattr(zone, 'distance_km')
|
||||
assert hasattr(zone, 'forest_pct')
|
||||
assert hasattr(zone, 'road_density')
|
||||
assert hasattr(zone, 'water_distance_km')
|
||||
assert hasattr(zone, 'settlement_distance_km')
|
||||
|
||||
|
||||
class TestConstants:
|
||||
"""Test module constants."""
|
||||
|
||||
def test_directions_count(self):
|
||||
"""Test there are 8 directions."""
|
||||
assert len(DIRECTIONS) == 8
|
||||
|
||||
def test_directions_values(self):
|
||||
"""Test direction values are correct."""
|
||||
expected = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
|
||||
assert DIRECTIONS == expected
|
||||
|
||||
def test_search_distances(self):
|
||||
"""Test search distances are correct."""
|
||||
expected = [500, 1000, 2000, 5000]
|
||||
assert SEARCH_DISTANCES == expected
|
||||
|
||||
def test_cache_dir_path(self):
|
||||
"""Test cache directory path is set."""
|
||||
assert isinstance(CACHE_DIR, Path)
|
||||
assert str(CACHE_DIR) == "/tmp/overpass_cache"
|
||||
@@ -0,0 +1,294 @@
|
||||
"""
|
||||
Tests for psychotype_service.py
|
||||
|
||||
Tests the psychotype detection logic and modifiers
|
||||
based on §6 ВЕКТОР-контекст.md specifications.
|
||||
"""
|
||||
import pytest
|
||||
from services.psychotype_service import (
|
||||
detect_psychotype,
|
||||
get_psychotype_modifiers,
|
||||
get_search_recommendations,
|
||||
get_psychotype_questions
|
||||
)
|
||||
|
||||
|
||||
class TestDetectPsychotype:
|
||||
"""Test psychotype detection from answers."""
|
||||
|
||||
def test_dominant_profile(self):
|
||||
"""Test dominant psychotype: активно + лидер + рискует."""
|
||||
answers = {
|
||||
'unfamiliar_behavior': 'explore',
|
||||
'stress_reaction': 'angry',
|
||||
'leadership': 'always_leader',
|
||||
'risk_taking': 'very'
|
||||
}
|
||||
|
||||
psychotype = detect_psychotype(answers)
|
||||
assert psychotype == 'dominant'
|
||||
|
||||
def test_harmonic_profile(self):
|
||||
"""Test harmonic psychotype: спокойно + лидер + осторожный."""
|
||||
answers = {
|
||||
'unfamiliar_behavior': 'wait',
|
||||
'stress_reaction': 'calm',
|
||||
'leadership': 'always_leader',
|
||||
'risk_taking': 'no_cautious'
|
||||
}
|
||||
|
||||
psychotype = detect_psychotype(answers)
|
||||
assert psychotype == 'harmonic'
|
||||
|
||||
def test_anxious_profile(self):
|
||||
"""Test anxious psychotype: плачет + ведомый + осторожный."""
|
||||
answers = {
|
||||
'unfamiliar_behavior': 'wait',
|
||||
'stress_reaction': 'cry',
|
||||
'leadership': 'always_follower',
|
||||
'risk_taking': 'no_cautious'
|
||||
}
|
||||
|
||||
psychotype = detect_psychotype(answers)
|
||||
assert psychotype == 'anxious'
|
||||
|
||||
def test_introvert_passive_profile(self):
|
||||
"""Test introvert_passive: замирает + ведомый + осторожный."""
|
||||
answers = {
|
||||
'unfamiliar_behavior': 'freeze',
|
||||
'stress_reaction': 'angry',
|
||||
'leadership': 'always_follower',
|
||||
'risk_taking': 'no_cautious'
|
||||
}
|
||||
|
||||
psychotype = detect_psychotype(answers)
|
||||
assert psychotype == 'introvert_passive'
|
||||
|
||||
def test_introvert_active_profile(self):
|
||||
"""Test introvert_active: активно + иногда лидер."""
|
||||
answers = {
|
||||
'unfamiliar_behavior': 'explore',
|
||||
'stress_reaction': 'calm',
|
||||
'leadership': 'sometimes',
|
||||
'risk_taking': 'sometimes'
|
||||
}
|
||||
|
||||
psychotype = detect_psychotype(answers)
|
||||
assert psychotype == 'introvert_active'
|
||||
|
||||
def test_introvert_active_panic_variant(self):
|
||||
"""Test introvert_active: паникует + иногда лидер."""
|
||||
answers = {
|
||||
'unfamiliar_behavior': 'panic',
|
||||
'stress_reaction': 'angry',
|
||||
'leadership': 'sometimes',
|
||||
'risk_taking': 'sometimes'
|
||||
}
|
||||
|
||||
psychotype = detect_psychotype(answers)
|
||||
assert psychotype == 'introvert_active'
|
||||
|
||||
def test_case_insensitive(self):
|
||||
"""Test that detection is case-insensitive."""
|
||||
answers = {
|
||||
'unfamiliar_behavior': 'EXPLORE',
|
||||
'stress_reaction': 'ANGRY',
|
||||
'leadership': 'ALWAYS_LEADER',
|
||||
'risk_taking': 'VERY'
|
||||
}
|
||||
|
||||
psychotype = detect_psychotype(answers)
|
||||
assert psychotype == 'dominant'
|
||||
|
||||
def test_partial_answers_default_harmonic(self):
|
||||
"""Test that incomplete answers default to harmonic."""
|
||||
answers = {
|
||||
'unfamiliar_behavior': 'wait',
|
||||
'stress_reaction': 'calm'
|
||||
}
|
||||
|
||||
psychotype = detect_psychotype(answers)
|
||||
assert psychotype == 'harmonic'
|
||||
|
||||
|
||||
class TestGetPsychotypeModifiers:
|
||||
"""Test psychotype modifiers for search zones."""
|
||||
|
||||
def test_dominant_modifiers(self):
|
||||
"""Test dominant modifiers: far zones emphasized."""
|
||||
modifiers = get_psychotype_modifiers('dominant')
|
||||
|
||||
assert modifiers['zone_0_500'] == 0.7
|
||||
assert modifiers['zone_1500_2500'] == 1.4
|
||||
assert modifiers['movement_model'] == 'chaotic_far'
|
||||
assert 'description' in modifiers
|
||||
|
||||
def test_harmonic_modifiers(self):
|
||||
"""Test harmonic modifiers: balanced distribution."""
|
||||
modifiers = get_psychotype_modifiers('harmonic')
|
||||
|
||||
assert modifiers['zone_0_500'] == 0.8
|
||||
assert modifiers['zone_500_1500'] == 1.0
|
||||
assert modifiers['zone_1500_2500'] == 1.1
|
||||
assert modifiers['movement_model'] == 'linear_landmark'
|
||||
|
||||
def test_anxious_modifiers(self):
|
||||
"""Test anxious modifiers: near zone emphasized."""
|
||||
modifiers = get_psychotype_modifiers('anxious')
|
||||
|
||||
assert modifiers['zone_0_500'] == 1.4
|
||||
assert modifiers['zone_1500_2500'] == 0.5
|
||||
assert modifiers['zone_2500plus'] == 0.3
|
||||
assert modifiers['movement_model'] == 'stay'
|
||||
|
||||
def test_introvert_passive_modifiers(self):
|
||||
"""Test introvert_passive modifiers: very near zone."""
|
||||
modifiers = get_psychotype_modifiers('introvert_passive')
|
||||
|
||||
assert modifiers['zone_0_500'] == 1.3
|
||||
assert modifiers['zone_2500plus'] == 0.2
|
||||
assert modifiers['movement_model'] == 'stay_hidden'
|
||||
|
||||
def test_introvert_active_modifiers(self):
|
||||
"""Test introvert_active modifiers: medium zones."""
|
||||
modifiers = get_psychotype_modifiers('introvert_active')
|
||||
|
||||
assert modifiers['zone_0_500'] == 0.8
|
||||
assert modifiers['zone_500_1500'] == 1.1
|
||||
assert modifiers['zone_1500_2500'] == 1.2
|
||||
assert modifiers['movement_model'] == 'linear_landmark'
|
||||
|
||||
def test_unknown_psychotype_defaults_harmonic(self):
|
||||
"""Test that unknown psychotype returns harmonic modifiers."""
|
||||
modifiers = get_psychotype_modifiers('unknown_type')
|
||||
harmonic_modifiers = get_psychotype_modifiers('harmonic')
|
||||
|
||||
assert modifiers == harmonic_modifiers
|
||||
|
||||
def test_all_modifiers_have_required_fields(self):
|
||||
"""Test that all psychotypes have required modifier fields."""
|
||||
psychotypes = ['dominant', 'harmonic', 'anxious', 'introvert_passive', 'introvert_active']
|
||||
required_fields = ['zone_0_500', 'zone_500_1500', 'zone_1500_2500',
|
||||
'zone_2500plus', 'movement_model', 'description']
|
||||
|
||||
for psychotype in psychotypes:
|
||||
modifiers = get_psychotype_modifiers(psychotype)
|
||||
for field in required_fields:
|
||||
assert field in modifiers, f"{psychotype} missing {field}"
|
||||
|
||||
|
||||
class TestGetSearchRecommendations:
|
||||
"""Test search recommendations for each psychotype."""
|
||||
|
||||
def test_dominant_recommendations(self):
|
||||
"""Test dominant search recommendations."""
|
||||
recs = get_search_recommendations('dominant')
|
||||
|
||||
assert 'priority_zones' in recs
|
||||
assert 'search_pattern' in recs
|
||||
assert 'key_locations' in recs
|
||||
assert 'communication' in recs
|
||||
|
||||
def test_anxious_recommendations(self):
|
||||
"""Test anxious search recommendations."""
|
||||
recs = get_search_recommendations('anxious')
|
||||
|
||||
assert '0-500' in recs['priority_zones']
|
||||
|
||||
def test_all_psychotypes_have_recommendations(self):
|
||||
"""Test that all psychotypes have complete recommendations."""
|
||||
psychotypes = ['dominant', 'harmonic', 'anxious', 'introvert_passive', 'introvert_active']
|
||||
required_fields = ['priority_zones', 'search_pattern', 'key_locations', 'communication']
|
||||
|
||||
for psychotype in psychotypes:
|
||||
recs = get_search_recommendations(psychotype)
|
||||
for field in required_fields:
|
||||
assert field in recs, f"{psychotype} missing {field}"
|
||||
assert len(recs[field]) > 0, f"{psychotype} {field} is empty"
|
||||
|
||||
|
||||
class TestGetPsychotypeQuestions:
|
||||
"""Test psychotype questions structure."""
|
||||
|
||||
def test_questions_count(self):
|
||||
"""Test that there are exactly 4 questions."""
|
||||
questions = get_psychotype_questions()
|
||||
assert len(questions) == 4
|
||||
|
||||
def test_questions_structure(self):
|
||||
"""Test that each question has required fields."""
|
||||
questions = get_psychotype_questions()
|
||||
|
||||
for q in questions:
|
||||
assert 'id' in q
|
||||
assert 'question' in q
|
||||
assert 'options' in q
|
||||
assert len(q['options']) >= 3
|
||||
|
||||
def test_question_ids(self):
|
||||
"""Test that question IDs match expected fields."""
|
||||
questions = get_psychotype_questions()
|
||||
expected_ids = ['unfamiliar_behavior', 'stress_reaction', 'leadership', 'risk_taking']
|
||||
|
||||
actual_ids = [q['id'] for q in questions]
|
||||
assert actual_ids == expected_ids
|
||||
|
||||
def test_options_structure(self):
|
||||
"""Test that each option has value and label."""
|
||||
questions = get_psychotype_questions()
|
||||
|
||||
for q in questions:
|
||||
for option in q['options']:
|
||||
assert 'value' in option
|
||||
assert 'label' in option
|
||||
assert len(option['value']) > 0
|
||||
assert len(option['label']) > 0
|
||||
|
||||
|
||||
class TestPsychotypeIntegration:
|
||||
"""Integration tests for complete psychotype workflow."""
|
||||
|
||||
def test_full_workflow_dominant(self):
|
||||
"""Test complete workflow for dominant type."""
|
||||
answers = {
|
||||
'unfamiliar_behavior': 'explore',
|
||||
'stress_reaction': 'angry',
|
||||
'leadership': 'always_leader',
|
||||
'risk_taking': 'very'
|
||||
}
|
||||
|
||||
psychotype = detect_psychotype(answers)
|
||||
modifiers = get_psychotype_modifiers(psychotype)
|
||||
recommendations = get_search_recommendations(psychotype)
|
||||
|
||||
assert psychotype == 'dominant'
|
||||
assert modifiers['zone_1500_2500'] > modifiers['zone_0_500']
|
||||
assert 'priority_zones' in recommendations
|
||||
|
||||
def test_full_workflow_anxious(self):
|
||||
"""Test complete workflow for anxious type."""
|
||||
answers = {
|
||||
'unfamiliar_behavior': 'freeze',
|
||||
'stress_reaction': 'cry',
|
||||
'leadership': 'always_follower',
|
||||
'risk_taking': 'no_cautious'
|
||||
}
|
||||
|
||||
psychotype = detect_psychotype(answers)
|
||||
modifiers = get_psychotype_modifiers(psychotype)
|
||||
recommendations = get_search_recommendations(psychotype)
|
||||
|
||||
assert psychotype in ['anxious', 'introvert_passive']
|
||||
assert modifiers['zone_0_500'] > 1.0
|
||||
assert '0-500' in recommendations['priority_zones']
|
||||
|
||||
def test_modifier_distributions_differ(self):
|
||||
"""Test that different psychotypes have different modifier distributions."""
|
||||
dominant_mods = get_psychotype_modifiers('dominant')
|
||||
anxious_mods = get_psychotype_modifiers('anxious')
|
||||
|
||||
# Dominant emphasizes far zones
|
||||
assert dominant_mods['zone_1500_2500'] > anxious_mods['zone_1500_2500']
|
||||
|
||||
# Anxious emphasizes near zones
|
||||
assert anxious_mods['zone_0_500'] > dominant_mods['zone_0_500']
|
||||
@@ -0,0 +1,440 @@
|
||||
"""
|
||||
Tests for scoring_service.py
|
||||
|
||||
Tests the WeightedScorer class and zone ranking logic
|
||||
based on §8 and §9 ВЕКТОР-контекст.md specifications.
|
||||
"""
|
||||
import pytest
|
||||
from services.scoring_service import (
|
||||
WeightedScorer,
|
||||
create_scorer_for_case,
|
||||
get_weight_explanation
|
||||
)
|
||||
|
||||
|
||||
class TestWeightedScorerBasics:
|
||||
"""Test basic WeightedScorer functionality."""
|
||||
|
||||
def test_initialization(self):
|
||||
"""Test scorer initializes with base weights."""
|
||||
scorer = WeightedScorer()
|
||||
|
||||
assert scorer.weights['forest'] == 0.25
|
||||
assert scorer.weights['water'] == 0.20
|
||||
assert scorer.weights['roads'] == 0.18
|
||||
assert scorer.weights['settlement'] == 0.15
|
||||
assert scorer.weights['historical'] == 0.12
|
||||
assert scorer.weights['direction'] == 0.07
|
||||
assert scorer.weights['shelter'] == 0.03
|
||||
assert scorer.distance_multiplier == 1.0
|
||||
assert scorer.active_profiles == []
|
||||
|
||||
def test_base_weights_sum_to_one(self):
|
||||
"""Test that base weights sum to 1.0."""
|
||||
scorer = WeightedScorer()
|
||||
total = sum(scorer.BASE_WEIGHTS.values())
|
||||
assert total == pytest.approx(1.0, rel=0.01)
|
||||
|
||||
|
||||
class TestAgeModifiers:
|
||||
"""Test age-based modifiers."""
|
||||
|
||||
def test_age_group_0_4(self):
|
||||
"""Test young children (0-4) modifiers."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_age_modifiers(3)
|
||||
|
||||
# Young children: high water risk, low distance
|
||||
assert scorer.distance_multiplier == 0.3
|
||||
# Water weight should be increased
|
||||
assert scorer.weights['water'] > scorer.BASE_WEIGHTS['water']
|
||||
|
||||
def test_age_group_8_11(self):
|
||||
"""Test preteen (8-11) modifiers."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_age_modifiers(10)
|
||||
|
||||
assert scorer.distance_multiplier == 0.8
|
||||
|
||||
def test_age_group_15_17(self):
|
||||
"""Test teenager (15-17) modifiers."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_age_modifiers(16)
|
||||
|
||||
# Teenagers: higher distance multiplier
|
||||
assert scorer.distance_multiplier == 1.5
|
||||
|
||||
|
||||
class TestSeasonModifiers:
|
||||
"""Test seasonal modifiers."""
|
||||
|
||||
def test_winter_modifiers(self):
|
||||
"""Test winter season modifiers."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_season_modifiers('зима')
|
||||
|
||||
# Winter: shelter more important, distance reduced
|
||||
assert scorer.distance_multiplier == 0.7
|
||||
assert scorer.weights['shelter'] > scorer.BASE_WEIGHTS['shelter']
|
||||
|
||||
def test_summer_modifiers(self):
|
||||
"""Test summer season modifiers."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_season_modifiers('лето')
|
||||
|
||||
# Summer: increased distance
|
||||
assert scorer.distance_multiplier == 1.3
|
||||
|
||||
|
||||
class TestBehavioralProfiles:
|
||||
"""Test behavioral profiles from §8."""
|
||||
|
||||
def test_ras_profile(self):
|
||||
"""Test РАС (autism) profile with critical water/railway emphasis."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_profile(['РАС'])
|
||||
|
||||
# РАС: water x3.0, railway x2.5, distance x2.0
|
||||
assert scorer.distance_multiplier == 2.0
|
||||
assert 'РАС' in scorer.active_profiles
|
||||
assert len(scorer.critical_warnings) == 1
|
||||
assert 'водоёмы' in scorer.critical_warnings[0]['warning']
|
||||
|
||||
def test_epilepsy_profile(self):
|
||||
"""Test эпилепсия profile with reduced distance."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_profile(['эпилепсия'])
|
||||
|
||||
# Epilepsy: water x3.5, distance x0.6
|
||||
assert scorer.distance_multiplier == 0.6
|
||||
assert len(scorer.critical_warnings) == 1
|
||||
|
||||
def test_bicycle_profile(self):
|
||||
"""Test велосипед profile with massive distance increase."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_profile(['велосипед'])
|
||||
|
||||
# Bicycle: distance x5.0, roads x1.8
|
||||
assert scorer.distance_multiplier == 5.0
|
||||
assert 'велосипед' in scorer.active_profiles
|
||||
assert len(scorer.critical_warnings) == 1
|
||||
assert '10-15 км' in scorer.critical_warnings[0]['warning']
|
||||
|
||||
def test_scooter_profile(self):
|
||||
"""Test самокат profile."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_profile(['самокат'])
|
||||
|
||||
# Scooter: distance x3.0
|
||||
assert scorer.distance_multiplier == 3.0
|
||||
|
||||
def test_intentional_runaway_profile(self):
|
||||
"""Test намеренный_уход profile."""
|
||||
scorer = WeightedScorer()
|
||||
base_roads = scorer.weights['roads']
|
||||
scorer.apply_profile(['намеренный_уход'])
|
||||
|
||||
# Intentional runaway: roads x2.5, settlement x3.0
|
||||
assert scorer.weights['roads'] > base_roads
|
||||
|
||||
def test_multiple_profiles(self):
|
||||
"""Test applying multiple profiles."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_profile(['РАС', 'велосипед'])
|
||||
|
||||
# Both multipliers should compound: 2.0 * 5.0 = 10.0
|
||||
assert scorer.distance_multiplier == 10.0
|
||||
assert len(scorer.active_profiles) == 2
|
||||
|
||||
|
||||
class TestScoreZone:
|
||||
"""Test zone scoring functionality."""
|
||||
|
||||
def test_score_zone_basic(self):
|
||||
"""Test basic zone scoring."""
|
||||
scorer = WeightedScorer()
|
||||
|
||||
zone = {
|
||||
'forest_pct': 0.7,
|
||||
'water_distance_km': 2.0,
|
||||
'road_density': 1.0,
|
||||
'settlement_distance_km': 5.0,
|
||||
'historical_freq': 0.6,
|
||||
'direction_match': 0.8,
|
||||
'shelter_pct': 0.4,
|
||||
'distance_km': 2.0
|
||||
}
|
||||
|
||||
case = {'age': 10, 'season': 'лето', 'profiles': []}
|
||||
|
||||
score = scorer.score_zone(zone, case)
|
||||
|
||||
assert 0 <= score <= 100
|
||||
assert isinstance(score, float)
|
||||
|
||||
def test_score_zone_with_ras(self):
|
||||
"""Test zone scoring with РАС profile."""
|
||||
scorer = WeightedScorer()
|
||||
|
||||
zone_near_water = {
|
||||
'forest_pct': 0.5,
|
||||
'water_distance_km': 0.5, # Very close to water
|
||||
'road_density': 0.5,
|
||||
'settlement_distance_km': 10.0,
|
||||
'historical_freq': 0.5,
|
||||
'direction_match': 0.5,
|
||||
'shelter_pct': 0.3,
|
||||
'distance_km': 2.0
|
||||
}
|
||||
|
||||
zone_far_water = {
|
||||
'forest_pct': 0.5,
|
||||
'water_distance_km': 5.0, # Far from water
|
||||
'road_density': 0.5,
|
||||
'settlement_distance_km': 10.0,
|
||||
'historical_freq': 0.5,
|
||||
'direction_match': 0.5,
|
||||
'shelter_pct': 0.3,
|
||||
'distance_km': 2.0
|
||||
}
|
||||
|
||||
case = {'age': 8, 'season': 'лето', 'profiles': ['РАС']}
|
||||
|
||||
score_near = scorer.score_zone(zone_near_water, case)
|
||||
score_far = scorer.score_zone(zone_far_water, case)
|
||||
|
||||
# Zone near water should score higher for РАС
|
||||
assert score_near > score_far
|
||||
|
||||
def test_score_zone_with_bicycle(self):
|
||||
"""Test zone scoring with bicycle profile."""
|
||||
scorer = WeightedScorer()
|
||||
|
||||
zone_with_roads = {
|
||||
'forest_pct': 0.3,
|
||||
'water_distance_km': 5.0,
|
||||
'road_density': 2.0, # High road density
|
||||
'settlement_distance_km': 5.0,
|
||||
'historical_freq': 0.5,
|
||||
'direction_match': 0.5,
|
||||
'shelter_pct': 0.2,
|
||||
'distance_km': 8.0 # Far distance
|
||||
}
|
||||
|
||||
case = {'age': 12, 'season': 'лето', 'profiles': ['велосипед']}
|
||||
|
||||
score = scorer.score_zone(zone_with_roads, case)
|
||||
|
||||
assert score > 0
|
||||
|
||||
|
||||
class TestRankZones:
|
||||
"""Test zone ranking functionality."""
|
||||
|
||||
def test_rank_zones_basic(self):
|
||||
"""Test basic zone ranking."""
|
||||
scorer = WeightedScorer()
|
||||
|
||||
zones = [
|
||||
{
|
||||
'id': 'zone_a',
|
||||
'forest_pct': 0.8,
|
||||
'water_distance_km': 1.0,
|
||||
'road_density': 0.5,
|
||||
'settlement_distance_km': 10.0,
|
||||
'historical_freq': 0.7,
|
||||
'direction_match': 0.9,
|
||||
'shelter_pct': 0.5,
|
||||
'distance_km': 2.0
|
||||
},
|
||||
{
|
||||
'id': 'zone_b',
|
||||
'forest_pct': 0.3,
|
||||
'water_distance_km': 8.0,
|
||||
'road_density': 0.2,
|
||||
'settlement_distance_km': 15.0,
|
||||
'historical_freq': 0.2,
|
||||
'direction_match': 0.3,
|
||||
'shelter_pct': 0.1,
|
||||
'distance_km': 5.0
|
||||
},
|
||||
{
|
||||
'id': 'zone_c',
|
||||
'forest_pct': 0.6,
|
||||
'water_distance_km': 3.0,
|
||||
'road_density': 1.0,
|
||||
'settlement_distance_km': 5.0,
|
||||
'historical_freq': 0.8,
|
||||
'direction_match': 0.7,
|
||||
'shelter_pct': 0.4,
|
||||
'distance_km': 1.5
|
||||
}
|
||||
]
|
||||
|
||||
case = {'age': 10, 'season': 'лето', 'profiles': []}
|
||||
|
||||
ranked = scorer.rank_zones(zones, case)
|
||||
|
||||
assert len(ranked) == 3
|
||||
assert all('score' in z for z in ranked)
|
||||
assert all('priority' in z for z in ranked)
|
||||
|
||||
# Check priorities are 1, 2, 3
|
||||
priorities = [z['priority'] for z in ranked]
|
||||
assert priorities == [1, 2, 3]
|
||||
|
||||
# Check scores are descending
|
||||
scores = [z['score'] for z in ranked]
|
||||
assert scores == sorted(scores, reverse=True)
|
||||
|
||||
def test_rank_zones_with_profiles(self):
|
||||
"""Test zone ranking with behavioral profiles."""
|
||||
scorer = WeightedScorer()
|
||||
|
||||
zones = [
|
||||
{
|
||||
'id': 'near_water',
|
||||
'forest_pct': 0.5,
|
||||
'water_distance_km': 0.3,
|
||||
'road_density': 0.5,
|
||||
'settlement_distance_km': 10.0,
|
||||
'historical_freq': 0.5,
|
||||
'direction_match': 0.5,
|
||||
'shelter_pct': 0.3,
|
||||
'distance_km': 2.0
|
||||
},
|
||||
{
|
||||
'id': 'far_water',
|
||||
'forest_pct': 0.5,
|
||||
'water_distance_km': 8.0,
|
||||
'road_density': 0.5,
|
||||
'settlement_distance_km': 10.0,
|
||||
'historical_freq': 0.5,
|
||||
'direction_match': 0.5,
|
||||
'shelter_pct': 0.3,
|
||||
'distance_km': 2.0
|
||||
}
|
||||
]
|
||||
|
||||
case = {'age': 8, 'season': 'лето', 'profiles': ['РАС']}
|
||||
|
||||
ranked = scorer.rank_zones(zones, case)
|
||||
|
||||
# Zone near water should be priority 1 for РАС
|
||||
assert ranked[0]['id'] == 'near_water'
|
||||
assert ranked[0]['priority'] == 1
|
||||
|
||||
|
||||
class TestHelperFunctions:
|
||||
"""Test helper functions."""
|
||||
|
||||
def test_create_scorer_for_case(self):
|
||||
"""Test scorer creation for a case."""
|
||||
case = {
|
||||
'age': 10,
|
||||
'season': 'зима',
|
||||
'profiles': ['велосипед']
|
||||
}
|
||||
|
||||
scorer = create_scorer_for_case(case)
|
||||
|
||||
assert scorer.distance_multiplier > 1.0
|
||||
assert 'велосипед' in scorer.active_profiles
|
||||
|
||||
def test_get_weight_explanation(self):
|
||||
"""Test weight explanation generation."""
|
||||
case = {
|
||||
'age': 8,
|
||||
'season': 'лето',
|
||||
'profiles': ['РАС']
|
||||
}
|
||||
|
||||
explanation = get_weight_explanation(case)
|
||||
|
||||
assert 'weights' in explanation
|
||||
assert 'distance_multiplier' in explanation
|
||||
assert 'age_group' in explanation
|
||||
assert 'profiles' in explanation
|
||||
assert 'critical_warnings' in explanation
|
||||
|
||||
assert explanation['age_group'] == '8-11'
|
||||
assert len(explanation['profiles']) == 1
|
||||
assert len(explanation['critical_warnings']) == 1
|
||||
|
||||
def test_get_active_profiles_info(self):
|
||||
"""Test active profiles info retrieval."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_profile(['РАС', 'велосипед'])
|
||||
|
||||
profiles_info = scorer.get_active_profiles_info()
|
||||
|
||||
assert len(profiles_info) == 2
|
||||
assert profiles_info[0]['name'] == 'РАС'
|
||||
assert profiles_info[1]['name'] == 'велосипед'
|
||||
assert 'critical_warning' in profiles_info[0]
|
||||
assert 'critical_warning' in profiles_info[1]
|
||||
|
||||
|
||||
class TestWeightNormalization:
|
||||
"""Test weight normalization."""
|
||||
|
||||
def test_weights_normalized_after_modifiers(self):
|
||||
"""Test that weights sum to 1.0 after applying modifiers."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_age_modifiers(10)
|
||||
scorer.apply_season_modifiers('зима')
|
||||
scorer._normalize_weights()
|
||||
|
||||
total = sum(scorer.weights.values())
|
||||
assert total == pytest.approx(1.0, rel=0.01)
|
||||
|
||||
def test_weights_normalized_after_profiles(self):
|
||||
"""Test that weights sum to 1.0 after applying profiles."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_profile(['РАС'])
|
||||
scorer._normalize_weights()
|
||||
|
||||
total = sum(scorer.weights.values())
|
||||
assert total == pytest.approx(1.0, rel=0.01)
|
||||
|
||||
|
||||
class TestCriticalWarnings:
|
||||
"""Test critical warning system."""
|
||||
|
||||
def test_ras_critical_warning(self):
|
||||
"""Test РАС generates critical warning."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_profile(['РАС'])
|
||||
|
||||
assert len(scorer.critical_warnings) == 1
|
||||
warning = scorer.critical_warnings[0]
|
||||
assert warning['profile'] == 'РАС'
|
||||
assert 'водоёмы' in warning['warning']
|
||||
assert 'громкоговоритель' in warning['warning']
|
||||
|
||||
def test_epilepsy_critical_warning(self):
|
||||
"""Test эпилепсия generates critical warning."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_profile(['эпилепсия'])
|
||||
|
||||
assert len(scorer.critical_warnings) == 1
|
||||
warning = scorer.critical_warnings[0]
|
||||
assert warning['profile'] == 'эпилепсия'
|
||||
assert 'Медицинский' in warning['warning']
|
||||
|
||||
def test_bicycle_critical_warning(self):
|
||||
"""Test велосипед generates critical warning."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_profile(['велосипед'])
|
||||
|
||||
assert len(scorer.critical_warnings) == 1
|
||||
warning = scorer.critical_warnings[0]
|
||||
assert warning['profile'] == 'велосипед'
|
||||
assert '10-15 км' in warning['warning']
|
||||
|
||||
def test_multiple_critical_warnings(self):
|
||||
"""Test multiple profiles generate multiple warnings."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_profile(['РАС', 'велосипед'])
|
||||
|
||||
assert len(scorer.critical_warnings) == 2
|
||||
@@ -0,0 +1,64 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
container_name: vector-postgres
|
||||
environment:
|
||||
POSTGRES_DB: vector_mchs
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
adminer:
|
||||
image: adminer
|
||||
container_name: vector-adminer
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: vector-backend
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- DATABASE_URL=${DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/vector_mchs}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: vector-frontend
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- REACT_APP_API_URL=http://localhost:8000
|
||||
depends_on:
|
||||
- backend
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "start"]
|
||||
Generated
+14645
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "sar-mchs-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@types/leaflet": "^1.9.8",
|
||||
"@types/leaflet.heat": "^0.2.2",
|
||||
"axios": "^1.6.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.heat": "^0.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"react-scripts": "5.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"proxy": "http://backend:8000"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="SAR-MCHS - Search and Rescue Management System" />
|
||||
<title>SAR-MCHS</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.App {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Mobile-first responsive breakpoints */
|
||||
@media (max-width: 768px) {
|
||||
.admin-dashboard .sidebar {
|
||||
position: fixed;
|
||||
left: -260px;
|
||||
z-index: 1000;
|
||||
transition: left 0.3s ease;
|
||||
}
|
||||
|
||||
.admin-dashboard .sidebar.open {
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from "react-router-dom";
|
||||
import AdminDashboard from "./pages/AdminDashboard";
|
||||
import MobileFormPage from "./pages/MobileFormPage";
|
||||
import DesktopSARPage from "./pages/DesktopSARPage";
|
||||
import AnalysisResult from "./pages/analysis/AnalysisResult";
|
||||
import "./App.css";
|
||||
|
||||
function ThemeProvider({ children }) {
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
let theme = "desktop";
|
||||
|
||||
if (location.pathname.startsWith("/mobile") || location.pathname.startsWith("/m")) {
|
||||
theme = "mobile";
|
||||
} else if (location.pathname.startsWith("/admin")) {
|
||||
theme = "admin";
|
||||
} else if (location.pathname.startsWith("/sar") || location.pathname.startsWith("/desktop") || location.pathname.startsWith("/analysis")) {
|
||||
theme = "desktop";
|
||||
}
|
||||
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}, [location.pathname]);
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) || window.innerWidth < 768;
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<ThemeProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to={isMobile ? "/mobile" : "/desktop"} replace />} />
|
||||
|
||||
<Route path="/sar" element={<DesktopSARPage />} />
|
||||
<Route path="/desktop" element={<DesktopSARPage />} />
|
||||
|
||||
<Route path="/analysis/:caseId" element={<AnalysisResult />} />
|
||||
|
||||
<Route path="/admin/*" element={<AdminDashboard />} />
|
||||
|
||||
<Route path="/mobile" element={<MobileFormPage />} />
|
||||
</Routes>
|
||||
</ThemeProvider>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,112 @@
|
||||
.analysis-layout {
|
||||
min-height: 100vh;
|
||||
background: #0a0c0f;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* Двухколоночный layout */
|
||||
.layout-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
max-width: 1800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Левая колонка - Форма */
|
||||
.layout-left {
|
||||
background: #0a0c0f;
|
||||
}
|
||||
|
||||
/* Правая колонка - Карта */
|
||||
.layout-right {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.map-container-sticky {
|
||||
position: sticky;
|
||||
top: 24px;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
/* Placeholder для карты */
|
||||
.map-placeholder {
|
||||
height: 600px;
|
||||
background: #161b22;
|
||||
border: 2px dashed #30363d;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.placeholder-content {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.placeholder-content h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 20px;
|
||||
color: #e6edf3;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.placeholder-content p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #8b949e;
|
||||
line-height: 1.5;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
/* Результаты снизу на всю ширину */
|
||||
.layout-bottom {
|
||||
max-width: 1800px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Адаптивность */
|
||||
@media (max-width: 1400px) {
|
||||
.layout-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.map-container-sticky {
|
||||
position: relative;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.map-placeholder {
|
||||
height: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.analysis-layout {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.layout-container {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
.placeholder-content h3 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.placeholder-content p {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import React, { useState } from "react";
|
||||
import CaseForm from "../CaseForm/CaseForm";
|
||||
import MapView from "../MapView/MapView";
|
||||
import ResultPanel from "../ResultPanel/ResultPanel";
|
||||
import "./AnalysisLayout.css";
|
||||
|
||||
const AnalysisLayout = () => {
|
||||
const [analysisResult, setAnalysisResult] = useState(null);
|
||||
const [heatmapData, setHeatmapData] = useState([]);
|
||||
const [showMap, setShowMap] = useState(false);
|
||||
|
||||
const handleAnalysisComplete = (result) => {
|
||||
setAnalysisResult(result);
|
||||
setShowMap(true);
|
||||
};
|
||||
|
||||
const handleHeatmapLoad = (data) => {
|
||||
setHeatmapData(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="analysis-layout">
|
||||
<div className="layout-container">
|
||||
{/* Левая колонка - Форма */}
|
||||
<div className="layout-left">
|
||||
<CaseForm
|
||||
onAnalysisComplete={handleAnalysisComplete}
|
||||
onHeatmapLoad={handleHeatmapLoad}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Правая колонка - Карта */}
|
||||
<div className="layout-right">
|
||||
{showMap && analysisResult ? (
|
||||
<div className="map-container-sticky">
|
||||
<MapView
|
||||
tnpLat={analysisResult.tnp_lat || 53.9}
|
||||
tnpLon={analysisResult.tnp_lon || 27.56}
|
||||
zones={analysisResult.zones || []}
|
||||
direction={analysisResult.direction}
|
||||
heatmapData={heatmapData}
|
||||
showHeatmap={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="map-placeholder">
|
||||
<div className="placeholder-content">
|
||||
<div className="placeholder-icon">🗺️</div>
|
||||
<h3>Карта поиска</h3>
|
||||
<p>Заполните форму и запустите анализ для отображения зон поиска</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Результаты снизу на всю ширину */}
|
||||
{analysisResult && (
|
||||
<div className="layout-bottom">
|
||||
<ResultPanel analysisResult={analysisResult} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalysisLayout;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./AnalysisLayout";
|
||||
@@ -0,0 +1,186 @@
|
||||
.behavioral-profiles {
|
||||
margin: 20px 0;
|
||||
padding: 20px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.profiles-title {
|
||||
margin: 0 0 15px 0;
|
||||
font-size: 1.3rem;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.profiles-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
border-left: 4px solid;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.profile-card-red {
|
||||
border-left-color: #dc3545;
|
||||
}
|
||||
|
||||
.profile-card-orange {
|
||||
border-left-color: #fd7e14;
|
||||
}
|
||||
|
||||
.profile-card-yellow {
|
||||
border-left-color: #ffc107;
|
||||
}
|
||||
|
||||
.profile-card-blue {
|
||||
border-left-color: #0d6efd;
|
||||
}
|
||||
|
||||
.profile-card-purple {
|
||||
border-left-color: #6f42c1;
|
||||
}
|
||||
|
||||
.profile-card-gray {
|
||||
border-left-color: #6c757d;
|
||||
}
|
||||
|
||||
.profile-card-green {
|
||||
border-left-color: #198754;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.profile-title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.profile-recommendations {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.profile-recommendations h5,
|
||||
.profile-modifiers h5 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.recommendations-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.recommendations-list li {
|
||||
margin-bottom: 6px;
|
||||
font-size: 0.95rem;
|
||||
color: #555;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.profile-modifiers {
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.modifiers-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.modifier-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.modifier-zone {
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modifier-value {
|
||||
color: #0d6efd;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Mobile styles */
|
||||
@media (max-width: 768px) {
|
||||
.profiles-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.profiles-title {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.profile-title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.recommendations-list li.mobile-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.profile-recommendations h5,
|
||||
.profile-modifiers h5 {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.recommendations-list li {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.modifier-item {
|
||||
font-size: 0.85rem;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop - full cards */
|
||||
@media (min-width: 769px) {
|
||||
.mobile-hidden {
|
||||
display: list-item !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet */
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
.profiles-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Large desktop */
|
||||
@media (min-width: 1400px) {
|
||||
.profiles-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import React from 'react';
|
||||
import './BehavioralProfile.css';
|
||||
|
||||
interface BehavioralProfileProps {
|
||||
diagnosis_type?: string[];
|
||||
has_transport?: string;
|
||||
age_years?: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface ProfileCardData {
|
||||
id: string;
|
||||
title: string;
|
||||
color: string;
|
||||
recommendations: string[];
|
||||
zoneModifiers: { zone: string; multiplier: number }[];
|
||||
}
|
||||
|
||||
const BehavioralProfile: React.FC<BehavioralProfileProps> = ({
|
||||
diagnosis_type = [],
|
||||
has_transport,
|
||||
age_years,
|
||||
reason
|
||||
}) => {
|
||||
const getActiveProfiles = (): ProfileCardData[] => {
|
||||
const profiles: ProfileCardData[] = [];
|
||||
|
||||
// РАС (Расстройство аутистического спектра)
|
||||
if (diagnosis_type.includes('РАС') || diagnosis_type.includes('аутизм')) {
|
||||
profiles.push({
|
||||
id: 'ras',
|
||||
title: 'РАС (Расстройство аутистического спектра)',
|
||||
color: 'red',
|
||||
recommendations: [
|
||||
'Не кричать имя — может спрятаться от громких звуков',
|
||||
'Приоритет: водоёмы, тихие места, укрытия',
|
||||
'Перекрыть доступ к воде (пруды, реки, колодцы)',
|
||||
'Проверить железнодорожные пути и технические помещения',
|
||||
'Искать в радиусе до 5 км, часто движется по прямой'
|
||||
],
|
||||
zoneModifiers: [
|
||||
{ zone: 'Водоём', multiplier: 3.0 },
|
||||
{ zone: 'Укрытие', multiplier: 2.0 },
|
||||
{ zone: 'Населённый пункт', multiplier: 0.4 }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Эпилепсия
|
||||
if (diagnosis_type.includes('эпилепсия')) {
|
||||
profiles.push({
|
||||
id: 'epilepsy',
|
||||
title: 'Эпилепсия',
|
||||
color: 'orange',
|
||||
recommendations: [
|
||||
'Искать близко к точке потери — радиус до 1 км',
|
||||
'Проверить канавы, овраги, низины (риск падения)',
|
||||
'Приоритет: водоёмы и опасные участки',
|
||||
'Срочная медицинская помощь при обнаружении',
|
||||
'Проверить укрытия (мог укрыться после приступа)'
|
||||
],
|
||||
zoneModifiers: [
|
||||
{ zone: 'Водоём', multiplier: 3.5 },
|
||||
{ zone: 'Укрытие', multiplier: 2.5 },
|
||||
{ zone: 'Радиус поиска', multiplier: 0.6 }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// СДВГ (Синдром дефицита внимания и гиперактивности)
|
||||
if (diagnosis_type.includes('СДВГ') || diagnosis_type.includes('гиперактивность')) {
|
||||
profiles.push({
|
||||
id: 'adhd',
|
||||
title: 'СДВГ (Гиперактивность)',
|
||||
color: 'yellow',
|
||||
recommendations: [
|
||||
'Расширенный радиус поиска — может уйти далеко',
|
||||
'Приоритет: дороги, тропы, интересные объекты',
|
||||
'Проверить игровые площадки, стройки, заброшенные здания',
|
||||
'Может отвлечься и изменить направление',
|
||||
'Искать вдоль дорог и троп'
|
||||
],
|
||||
zoneModifiers: [
|
||||
{ zone: 'Дороги', multiplier: 1.6 },
|
||||
{ zone: 'Радиус поиска', multiplier: 1.4 }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Деменция
|
||||
if (diagnosis_type.includes('деменция') || diagnosis_type.includes('Альцгеймер')) {
|
||||
profiles.push({
|
||||
id: 'dementia',
|
||||
title: 'Деменция / Альцгеймер',
|
||||
color: 'purple',
|
||||
recommendations: [
|
||||
'Искать в радиусе до 2 км от дома',
|
||||
'Проверить знакомые места (бывший дом, работа)',
|
||||
'Приоритет: дороги, тропы (идёт по привычному маршруту)',
|
||||
'Может не откликаться на имя',
|
||||
'Проверить водоёмы и опасные участки'
|
||||
],
|
||||
zoneModifiers: [
|
||||
{ zone: 'Дороги', multiplier: 1.8 },
|
||||
{ zone: 'Исторические места', multiplier: 2.0 },
|
||||
{ zone: 'Водоём', multiplier: 1.5 }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Велосипед
|
||||
if (has_transport === 'велосипед') {
|
||||
profiles.push({
|
||||
id: 'bicycle',
|
||||
title: 'Транспорт: Велосипед',
|
||||
color: 'blue',
|
||||
recommendations: [
|
||||
'Радиус поиска ×5 — может уехать на 10-25 км',
|
||||
'Приоритет: дороги, велодорожки, тропы',
|
||||
'Запросить записи с камер на дорогах',
|
||||
'Проверить популярные велосипедные маршруты',
|
||||
'Лес и бездорожье менее вероятны'
|
||||
],
|
||||
zoneModifiers: [
|
||||
{ zone: 'Радиус поиска', multiplier: 5.0 },
|
||||
{ zone: 'Дороги', multiplier: 1.8 },
|
||||
{ zone: 'Лес', multiplier: 0.8 }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Автомобиль
|
||||
if (has_transport === 'автомобиль' || has_transport === 'машина') {
|
||||
profiles.push({
|
||||
id: 'car',
|
||||
title: 'Транспорт: Автомобиль',
|
||||
color: 'blue',
|
||||
recommendations: [
|
||||
'Радиус поиска ×10 — может уехать на 50+ км',
|
||||
'Запросить записи с камер ГИБДД и дорожных камер',
|
||||
'Проверить заправки, парковки, придорожные кафе',
|
||||
'Ориентация на дороги и трассы',
|
||||
'Розыск по номеру автомобиля'
|
||||
],
|
||||
zoneModifiers: [
|
||||
{ zone: 'Радиус поиска', multiplier: 10.0 },
|
||||
{ zone: 'Дороги', multiplier: 2.5 },
|
||||
{ zone: 'Лес', multiplier: 0.3 }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Подросток, ушедший из дома
|
||||
if (age_years && age_years >= 13 && age_years <= 17 &&
|
||||
(reason?.includes('ушёл из дома') || reason?.includes('конфликт'))) {
|
||||
profiles.push({
|
||||
id: 'runaway',
|
||||
title: 'Подросток: Уход из дома',
|
||||
color: 'gray',
|
||||
recommendations: [
|
||||
'Розыск через соцсети и друзей',
|
||||
'Проверить дома друзей, торговые центры, вокзалы',
|
||||
'Лес и удалённые места менее вероятны',
|
||||
'Может иметь телефон — запросить геолокацию',
|
||||
'Проверить популярные места встреч подростков'
|
||||
],
|
||||
zoneModifiers: [
|
||||
{ zone: 'Населённый пункт', multiplier: 2.0 },
|
||||
{ zone: 'Лес', multiplier: 0.3 },
|
||||
{ zone: 'Дороги', multiplier: 1.5 }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Грибник
|
||||
if (reason?.includes('грибы') || reason?.includes('грибник')) {
|
||||
profiles.push({
|
||||
id: 'mushroom',
|
||||
title: 'Активность: Сбор грибов',
|
||||
color: 'green',
|
||||
recommendations: [
|
||||
'Приоритет: лесные массивы, особенно смешанные леса',
|
||||
'Радиус до 3 км от точки входа в лес',
|
||||
'Проверить популярные грибные места',
|
||||
'Может углубиться в лес, потеряв ориентацию',
|
||||
'Искать вдоль просек и лесных дорог'
|
||||
],
|
||||
zoneModifiers: [
|
||||
{ zone: 'Лес', multiplier: 3.0 },
|
||||
{ zone: 'Дороги', multiplier: 0.6 },
|
||||
{ zone: 'Радиус поиска', multiplier: 1.2 }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Охотник
|
||||
if (reason?.includes('охота') || reason?.includes('охотник')) {
|
||||
profiles.push({
|
||||
id: 'hunter',
|
||||
title: 'Активность: Охота',
|
||||
color: 'green',
|
||||
recommendations: [
|
||||
'Радиус до 5-10 км — охотники перемещаются активно',
|
||||
'Приоритет: лесные массивы, поля, болота',
|
||||
'Проверить охотничьи вышки, избушки, кормушки',
|
||||
'Может иметь оружие — соблюдать осторожность',
|
||||
'Искать вдоль звериных троп и просек'
|
||||
],
|
||||
zoneModifiers: [
|
||||
{ zone: 'Лес', multiplier: 2.5 },
|
||||
{ zone: 'Радиус поиска', multiplier: 2.0 },
|
||||
{ zone: 'Укрытие', multiplier: 1.5 }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
return profiles;
|
||||
};
|
||||
|
||||
const activeProfiles = getActiveProfiles();
|
||||
|
||||
if (activeProfiles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="behavioral-profiles">
|
||||
<h3 className="profiles-title">Поведенческие профили</h3>
|
||||
<div className="profiles-grid">
|
||||
{activeProfiles.map((profile) => (
|
||||
<div
|
||||
key={profile.id}
|
||||
className={`profile-card profile-card-${profile.color}`}
|
||||
>
|
||||
<div className="profile-header">
|
||||
<h4 className="profile-title">{profile.title}</h4>
|
||||
</div>
|
||||
|
||||
<div className="profile-recommendations">
|
||||
<h5>Рекомендации:</h5>
|
||||
<ul className="recommendations-list">
|
||||
{profile.recommendations.map((rec, idx) => (
|
||||
<li key={idx} className={idx >= 2 ? 'mobile-hidden' : ''}>
|
||||
{rec}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="profile-modifiers">
|
||||
<h5>Модификаторы зон:</h5>
|
||||
<div className="modifiers-list">
|
||||
{profile.zoneModifiers.map((mod, idx) => (
|
||||
<div key={idx} className="modifier-item">
|
||||
<span className="modifier-zone">{mod.zone}:</span>
|
||||
<span className="modifier-value">×{mod.multiplier.toFixed(1)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BehavioralProfile;
|
||||
@@ -0,0 +1,344 @@
|
||||
.case-form {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
background: #0a0c0f;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.form-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #21262d;
|
||||
}
|
||||
|
||||
.form-header h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
color: #e6edf3;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.step-indicator {
|
||||
font-size: 14px;
|
||||
color: #8b949e;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-content {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-step {
|
||||
background: #161b22;
|
||||
border: 1px solid #21262d;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.form-step h2 {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 20px;
|
||||
color: #e6edf3;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
color: #e6edf3;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="number"],
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #e6edf3;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #ff4d00;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.button-group button {
|
||||
padding: 10px 16px;
|
||||
background: #21262d;
|
||||
color: #e6edf3;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.button-group button:hover {
|
||||
background: #30363d;
|
||||
border-color: #ff4d00;
|
||||
}
|
||||
|
||||
.button-group button.selected {
|
||||
background: #ff4d00;
|
||||
color: #ffffff;
|
||||
border-color: #ff4d00;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.checkbox-label:hover {
|
||||
background: #161b22;
|
||||
border-color: #ff4d00;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
accent-color: #ff4d00;
|
||||
}
|
||||
|
||||
.form-navigation {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.form-navigation button {
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.form-navigation button:first-child {
|
||||
background: #21262d;
|
||||
color: #e6edf3;
|
||||
border: 1px solid #30363d;
|
||||
}
|
||||
|
||||
.form-navigation button:first-child:hover {
|
||||
background: #30363d;
|
||||
}
|
||||
|
||||
.form-navigation button:last-child {
|
||||
background: #ff4d00;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.form-navigation button:last-child:hover {
|
||||
background: #ff6a2e;
|
||||
}
|
||||
|
||||
.form-navigation button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
background: #da3633;
|
||||
color: white;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.error-banner div {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.error-banner div:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Психотип - улучшенная читаемость */
|
||||
.psychotype-section {
|
||||
background: #161b22;
|
||||
border-left: 4px solid #ff4d00;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
margin: 8px 0 0 0;
|
||||
font-size: 14px;
|
||||
color: #8b949e;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.psychotype-question {
|
||||
margin-bottom: 32px;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid #21262d;
|
||||
}
|
||||
|
||||
.psychotype-question:last-child {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.question-label {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
font-size: 16px;
|
||||
color: #e6edf3;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.button-group-vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.psychotype-btn {
|
||||
padding: 16px 20px;
|
||||
background: #0d1117;
|
||||
color: #e6edf3;
|
||||
border: 2px solid #30363d;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.psychotype-btn:hover {
|
||||
background: #161b22;
|
||||
border-color: #ff4d00;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.psychotype-btn.selected {
|
||||
background: #ff4d00;
|
||||
color: #ffffff;
|
||||
border-color: #ff4d00;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.case-form {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.form-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-step {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.form-navigation {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-navigation button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.button-group button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* GPS Button */
|
||||
.gps-button {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: #238636;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.gps-button:hover {
|
||||
background: #2ea043;
|
||||
}
|
||||
|
||||
.gps-button:disabled {
|
||||
background: #21262d;
|
||||
color: #8b949e;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Datetime input */
|
||||
input[type="datetime-local"] {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #e6edf3;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
input[type="datetime-local"]:focus {
|
||||
outline: none;
|
||||
border-color: #ff4d00;
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import React, { useState } from "react";
|
||||
import Step1Child from "./Step1Child";
|
||||
import Step2Health from "./Step2Health";
|
||||
import Step2bPsychotype from "./Step2bPsychotype";
|
||||
import Step3Circumstances from "./Step3Circumstances";
|
||||
import Step4Environment from "./Step4Environment";
|
||||
import "./CaseForm.css";
|
||||
|
||||
const INITIAL_DATA = {
|
||||
has_diagnosis: false,
|
||||
diagnosis_type: [],
|
||||
cant_swim: false,
|
||||
terrain: [],
|
||||
has_dog: false,
|
||||
extra_resources: []
|
||||
};
|
||||
|
||||
const CaseForm = ({ isMobile = false }) => {
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [formData, setFormData] = useState(INITIAL_DATA);
|
||||
const [errors, setErrors] = useState([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Мобильная: 3 шага, Десктоп: 5 шагов
|
||||
const totalSteps = isMobile ? 3 : 5;
|
||||
|
||||
const updateFormData = (updates) => {
|
||||
setFormData(prev => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
||||
const validateStep = (step) => {
|
||||
const newErrors = [];
|
||||
|
||||
if (step === 1) {
|
||||
if (!formData.age_years || formData.age_years < 0 || formData.age_years > 120) {
|
||||
newErrors.push("Укажите корректный возраст");
|
||||
}
|
||||
if (!formData.gender) {
|
||||
newErrors.push("Выберите пол");
|
||||
}
|
||||
}
|
||||
|
||||
// Для десктопа: Шаг 4 = Обстоятельства
|
||||
if (step === 4 && !isMobile) {
|
||||
if (!formData.reason) {
|
||||
newErrors.push("Укажите причину потери");
|
||||
}
|
||||
if (!formData.lost_time) {
|
||||
newErrors.push("Укажите дату и время потери");
|
||||
}
|
||||
if (!formData.last_known_place) {
|
||||
newErrors.push("Укажите последнее известное место");
|
||||
}
|
||||
}
|
||||
|
||||
// Для десктопа: Шаг 5 = Среда и местность
|
||||
if (step === 5 && !isMobile) {
|
||||
if (!formData.terrain || formData.terrain.length === 0) {
|
||||
newErrors.push("Выберите хотя бы один тип местности");
|
||||
}
|
||||
if (!formData.tnp_lat || !formData.tnp_lon) {
|
||||
newErrors.push("Укажите GPS координаты точки потери");
|
||||
}
|
||||
}
|
||||
|
||||
// Для мобильной: Шаг 3 = Обстоятельства (с GPS)
|
||||
if (step === 3 && isMobile) {
|
||||
if (!formData.reason) {
|
||||
newErrors.push("Укажите причину потери");
|
||||
}
|
||||
if (!formData.tnp_lat || !formData.tnp_lon) {
|
||||
newErrors.push("Укажите GPS координаты ТНП");
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return newErrors.length === 0;
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (validateStep(currentStep)) {
|
||||
setCurrentStep(prev => Math.min(prev + 1, totalSteps));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setCurrentStep(prev => Math.max(prev - 1, 1));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validateStep(currentStep)) return;
|
||||
|
||||
setSubmitting(true);
|
||||
setErrors([]);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
...formData,
|
||||
clothes_description: [
|
||||
formData.clothes_upper,
|
||||
formData.clothes_lower,
|
||||
formData.clothes_shoes
|
||||
].filter(Boolean).join(", ")
|
||||
};
|
||||
|
||||
// Создаем карточку
|
||||
const response = await fetch("/api/v1/cases", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || "Ошибка при создании карточки");
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log("Case created:", result);
|
||||
|
||||
// Перенаправляем на страницу анализа с ID карточки
|
||||
window.location.href = `/analysis/${result.id}`;
|
||||
|
||||
} catch (err) {
|
||||
setErrors([err.message || "Ошибка при отправке данных"]);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStep = () => {
|
||||
// Мобильная версия: 3 шага
|
||||
// 1 = Ребёнок, 2 = Здоровье, 3 = Обстоятельства
|
||||
if (isMobile) {
|
||||
switch (currentStep) {
|
||||
case 1: return <Step1Child data={formData} updateData={updateFormData} />;
|
||||
case 2: return <Step2Health data={formData} updateData={updateFormData} />;
|
||||
case 3: return <Step3Circumstances data={formData} updateData={updateFormData} />;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Десктопная версия: 5 шагов
|
||||
// 1 = Ребёнок, 2 = Здоровье, 3 = Психотип, 4 = Обстоятельства, 5 = Среда
|
||||
switch (currentStep) {
|
||||
case 1: return <Step1Child data={formData} updateData={updateFormData} />;
|
||||
case 2: return <Step2Health data={formData} updateData={updateFormData} />;
|
||||
case 3: return <Step2bPsychotype data={formData} updateData={updateFormData} />;
|
||||
case 4: return <Step3Circumstances data={formData} updateData={updateFormData} />;
|
||||
case 5: return <Step4Environment data={formData} updateData={updateFormData} />;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="case-form">
|
||||
<div className="form-header">
|
||||
<h1>Карточка поиска</h1>
|
||||
<div className="step-indicator">
|
||||
Шаг {currentStep} из {totalSteps}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errors.length > 0 && (
|
||||
<div className="error-banner">
|
||||
{errors.map((err, idx) => (
|
||||
<div key={idx}>{err}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-content">
|
||||
{renderStep()}
|
||||
</div>
|
||||
|
||||
<div className="form-navigation">
|
||||
{currentStep > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
onClick={handleBack}
|
||||
disabled={submitting}
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
)}
|
||||
|
||||
{currentStep < totalSteps ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={handleNext}
|
||||
disabled={submitting}
|
||||
>
|
||||
Далее
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary submit-btn"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "Создание карточки..." : "Создать карточку"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CaseForm;
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from "react";
|
||||
|
||||
const Step1Child = ({ data, updateData }) => {
|
||||
return (
|
||||
<div className="form-step">
|
||||
<h2>Шаг 1: Ребёнок</h2>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Имя *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.child_name || ""}
|
||||
onChange={(e) => updateData({ child_name: e.target.value })}
|
||||
placeholder="Имя ребёнка"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Возраст (лет) *</label>
|
||||
<input
|
||||
type="number"
|
||||
value={data.age_years || ""}
|
||||
onChange={(e) => updateData({ age_years: parseInt(e.target.value) })}
|
||||
min="0"
|
||||
max="120"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Пол *</label>
|
||||
<div className="button-group">
|
||||
<button
|
||||
type="button"
|
||||
className={data.gender === "М" ? "selected" : ""}
|
||||
onClick={() => updateData({ gender: "М" })}
|
||||
>
|
||||
Мужской
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={data.gender === "Ж" ? "selected" : ""}
|
||||
onClick={() => updateData({ gender: "Ж" })}
|
||||
>
|
||||
Женский
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Одежда: верх (цвет, тип) *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.clothes_upper || ""}
|
||||
onChange={(e) => updateData({ clothes_upper: e.target.value })}
|
||||
placeholder="Красная куртка"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Одежда: низ (цвет, тип)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.clothes_lower || ""}
|
||||
onChange={(e) => updateData({ clothes_lower: e.target.value })}
|
||||
placeholder="Синие джинсы"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Обувь (тип, цвет)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.clothes_shoes || ""}
|
||||
onChange={(e) => updateData({ clothes_shoes: e.target.value })}
|
||||
placeholder="Чёрные кроссовки"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Особые приметы</label>
|
||||
<textarea
|
||||
value={data.special_marks || ""}
|
||||
onChange={(e) => updateData({ special_marks: e.target.value })}
|
||||
placeholder="Родинка на щеке, носит очки..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Step1Child;
|
||||
@@ -0,0 +1,188 @@
|
||||
import React from "react";
|
||||
|
||||
const Step2Health = ({ data, updateData }) => {
|
||||
const toggleDiagnosis = (diagnosis) => {
|
||||
const current = data.diagnosis_type || [];
|
||||
const updated = current.includes(diagnosis)
|
||||
? current.filter((d) => d !== diagnosis)
|
||||
: [...current, diagnosis];
|
||||
updateData({
|
||||
diagnosis_type: updated,
|
||||
has_diagnosis: updated.length > 0
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="form-step">
|
||||
<h2>Шаг 2: Здоровье и особенности</h2>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Заболевания / диагнозы *</label>
|
||||
<div className="checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.diagnosis_type?.includes("здоров")}
|
||||
onChange={() => toggleDiagnosis("здоров")}
|
||||
/>
|
||||
Здоров
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.diagnosis_type?.includes("РАС")}
|
||||
onChange={() => toggleDiagnosis("РАС")}
|
||||
/>
|
||||
РАС / Аутизм
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.diagnosis_type?.includes("эпилепсия")}
|
||||
onChange={() => toggleDiagnosis("эпилепсия")}
|
||||
/>
|
||||
Эпилепсия
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.diagnosis_type?.includes("СДВГ")}
|
||||
onChange={() => toggleDiagnosis("СДВГ")}
|
||||
/>
|
||||
СДВГ
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.diagnosis_type?.includes("ЗПР")}
|
||||
onChange={() => toggleDiagnosis("ЗПР")}
|
||||
/>
|
||||
Задержка ПР / ЗПР
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.diagnosis_type?.includes("ДЦП")}
|
||||
onChange={() => toggleDiagnosis("ДЦП")}
|
||||
/>
|
||||
ДЦП
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.diagnosis_type?.includes("слабое_зрение")}
|
||||
onChange={() => toggleDiagnosis("слабое_зрение")}
|
||||
/>
|
||||
Слабое зрение
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.diagnosis_type?.includes("слабый_слух")}
|
||||
onChange={() => toggleDiagnosis("слабый_слух")}
|
||||
/>
|
||||
Слабый слух
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.cant_swim}
|
||||
onChange={(e) => updateData({ cant_swim: e.target.checked })}
|
||||
/>
|
||||
Не умеет плавать
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Уровень физподготовки</label>
|
||||
<div className="button-group">
|
||||
<button
|
||||
type="button"
|
||||
className={data.fitness_level === "low" ? "selected" : ""}
|
||||
onClick={() => updateData({ fitness_level: "low" })}
|
||||
>
|
||||
Низкий
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={data.fitness_level === "medium" ? "selected" : ""}
|
||||
onClick={() => updateData({ fitness_level: "medium" })}
|
||||
>
|
||||
Средний
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={data.fitness_level === "high" ? "selected" : ""}
|
||||
onClick={() => updateData({ fitness_level: "high" })}
|
||||
>
|
||||
Высокий
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Наличие транспорта *</label>
|
||||
<div className="button-group">
|
||||
<button
|
||||
type="button"
|
||||
className={data.has_transport === "none" ? "selected" : ""}
|
||||
onClick={() => updateData({ has_transport: "none" })}
|
||||
>
|
||||
Нет
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={data.has_transport === "bike" ? "selected" : ""}
|
||||
onClick={() => updateData({ has_transport: "bike" })}
|
||||
>
|
||||
Велосипед
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={data.has_transport === "scooter" ? "selected" : ""}
|
||||
onClick={() => updateData({ has_transport: "scooter" })}
|
||||
>
|
||||
Самокат
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={data.has_transport === "other" ? "selected" : ""}
|
||||
onClick={() => updateData({ has_transport: "other" })}
|
||||
>
|
||||
Другое
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Телефон *</label>
|
||||
<div className="button-group">
|
||||
<button
|
||||
type="button"
|
||||
className={data.phone_status === "answers" ? "selected" : ""}
|
||||
onClick={() => updateData({ phone_status: "answers" })}
|
||||
>
|
||||
Есть, отвечает
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={data.phone_status === "silent" ? "selected" : ""}
|
||||
onClick={() => updateData({ phone_status: "silent" })}
|
||||
>
|
||||
Есть, не отвечает
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={data.phone_status === "none" ? "selected" : ""}
|
||||
onClick={() => updateData({ phone_status: "none" })}
|
||||
>
|
||||
Нет
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Step2Health;
|
||||
@@ -0,0 +1,152 @@
|
||||
import React from "react";
|
||||
|
||||
const Step2bPsychotype = ({ data, updateData }) => {
|
||||
const updateAnswer = (question, value) => {
|
||||
updateData({
|
||||
psychotype_answers: {
|
||||
...data.psychotype_answers,
|
||||
[question]: value
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const isSelected = (question, value) => {
|
||||
return data.psychotype_answers?.[question] === value;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="form-step psychotype-section">
|
||||
<div className="section-header">
|
||||
<h2>Шаг 2б: Поведение ребёнка</h2>
|
||||
<p className="section-subtitle">
|
||||
Спросить у родителей. Помогает уточнить зону поиска. Можно пропустить.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="psychotype-question">
|
||||
<label className="question-label">
|
||||
Как ведёт себя в незнакомой обстановке?
|
||||
</label>
|
||||
<div className="button-group-vertical">
|
||||
<button
|
||||
type="button"
|
||||
className={isSelected("unfamiliar_behavior", "explore") ? "psychotype-btn selected" : "psychotype-btn"}
|
||||
onClick={() => updateAnswer("unfamiliar_behavior", "explore")}
|
||||
>
|
||||
Активно исследует
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={isSelected("unfamiliar_behavior", "wait") ? "psychotype-btn selected" : "psychotype-btn"}
|
||||
onClick={() => updateAnswer("unfamiliar_behavior", "wait")}
|
||||
>
|
||||
Ждёт и наблюдает
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={isSelected("unfamiliar_behavior", "freeze") ? "psychotype-btn selected" : "psychotype-btn"}
|
||||
onClick={() => updateAnswer("unfamiliar_behavior", "freeze")}
|
||||
>
|
||||
Замирает
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={isSelected("unfamiliar_behavior", "panic") ? "psychotype-btn selected" : "psychotype-btn"}
|
||||
onClick={() => updateAnswer("unfamiliar_behavior", "panic")}
|
||||
>
|
||||
Паникует
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="psychotype-question">
|
||||
<label className="question-label">
|
||||
Реакция на стресс и неудачи?
|
||||
</label>
|
||||
<div className="button-group-vertical">
|
||||
<button
|
||||
type="button"
|
||||
className={isSelected("stress_reaction", "angry") ? "psychotype-btn selected" : "psychotype-btn"}
|
||||
onClick={() => updateAnswer("stress_reaction", "angry")}
|
||||
>
|
||||
Злится, кричит
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={isSelected("stress_reaction", "cry") ? "psychotype-btn selected" : "psychotype-btn"}
|
||||
onClick={() => updateAnswer("stress_reaction", "cry")}
|
||||
>
|
||||
Плачет, замыкается
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={isSelected("stress_reaction", "calm") ? "psychotype-btn selected" : "psychotype-btn"}
|
||||
onClick={() => updateAnswer("stress_reaction", "calm")}
|
||||
>
|
||||
Спокойно ищет выход
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="psychotype-question">
|
||||
<label className="question-label">
|
||||
Лидер или ведомый?
|
||||
</label>
|
||||
<div className="button-group-vertical">
|
||||
<button
|
||||
type="button"
|
||||
className={isSelected("leadership", "always_leader") ? "psychotype-btn selected" : "psychotype-btn"}
|
||||
onClick={() => updateAnswer("leadership", "always_leader")}
|
||||
>
|
||||
Всегда лидер
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={isSelected("leadership", "sometimes") ? "psychotype-btn selected" : "psychotype-btn"}
|
||||
onClick={() => updateAnswer("leadership", "sometimes")}
|
||||
>
|
||||
Иногда
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={isSelected("leadership", "always_follower") ? "psychotype-btn selected" : "psychotype-btn"}
|
||||
onClick={() => updateAnswer("leadership", "always_follower")}
|
||||
>
|
||||
Всегда ведомый
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="psychotype-question">
|
||||
<label className="question-label">
|
||||
Любит рисковать?
|
||||
</label>
|
||||
<div className="button-group-vertical">
|
||||
<button
|
||||
type="button"
|
||||
className={isSelected("risk_taking", "very") ? "psychotype-btn selected" : "psychotype-btn"}
|
||||
onClick={() => updateAnswer("risk_taking", "very")}
|
||||
>
|
||||
Очень
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={isSelected("risk_taking", "sometimes") ? "psychotype-btn selected" : "psychotype-btn"}
|
||||
onClick={() => updateAnswer("risk_taking", "sometimes")}
|
||||
>
|
||||
Иногда
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={isSelected("risk_taking", "no_cautious") ? "psychotype-btn selected" : "psychotype-btn"}
|
||||
onClick={() => updateAnswer("risk_taking", "no_cautious")}
|
||||
>
|
||||
Нет, осторожный
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Step2bPsychotype;
|
||||
@@ -0,0 +1,162 @@
|
||||
import React from "react";
|
||||
|
||||
const Step3Circumstances = ({ data, updateData }) => {
|
||||
return (
|
||||
<div className="form-step">
|
||||
<h2>Шаг 3: Обстоятельства потери</h2>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Причина потери *</label>
|
||||
<div className="button-group">
|
||||
<button
|
||||
type="button"
|
||||
className={data.reason === "ушёл_сам" ? "selected" : ""}
|
||||
onClick={() => updateData({ reason: "ушёл_сам" })}
|
||||
>
|
||||
Ушёл сам
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={data.reason === "заблудился" ? "selected" : ""}
|
||||
onClick={() => updateData({ reason: "заблудился" })}
|
||||
>
|
||||
Заблудился
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={data.reason === "отстал_от_группы" ? "selected" : ""}
|
||||
onClick={() => updateData({ reason: "отстал_от_группы" })}
|
||||
>
|
||||
Отстал от группы
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={data.reason === "конфликт" ? "selected" : ""}
|
||||
onClick={() => updateData({ reason: "конфликт" })}
|
||||
>
|
||||
После конфликта
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={data.reason === "неизвестно" ? "selected" : ""}
|
||||
onClick={() => updateData({ reason: "неизвестно" })}
|
||||
>
|
||||
Неизвестно
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Дата и время потери *</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={data.lost_time || ""}
|
||||
onChange={(e) => updateData({ lost_time: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Прошло времени с момента потери (часов)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={data.elapsed_hours || ""}
|
||||
onChange={(e) => updateData({ elapsed_hours: parseFloat(e.target.value) })}
|
||||
min="0"
|
||||
step="0.5"
|
||||
placeholder="Например: 2.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Последнее известное место *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.last_known_place || ""}
|
||||
onChange={(e) => updateData({ last_known_place: e.target.value })}
|
||||
placeholder="Деревня Заречье, у магазина"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Предполагаемое направление движения</label>
|
||||
<div className="button-group">
|
||||
<button
|
||||
type="button"
|
||||
className={data.direction === "N" ? "selected" : ""}
|
||||
onClick={() => updateData({ direction: "N" })}
|
||||
>
|
||||
Север
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={data.direction === "NE" ? "selected" : ""}
|
||||
onClick={() => updateData({ direction: "NE" })}
|
||||
>
|
||||
Северо-Восток
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={data.direction === "E" ? "selected" : ""}
|
||||
onClick={() => updateData({ direction: "E" })}
|
||||
>
|
||||
Восток
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={data.direction === "SE" ? "selected" : ""}
|
||||
onClick={() => updateData({ direction: "SE" })}
|
||||
>
|
||||
Юго-Восток
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={data.direction === "S" ? "selected" : ""}
|
||||
onClick={() => updateData({ direction: "S" })}
|
||||
>
|
||||
Юг
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={data.direction === "SW" ? "selected" : ""}
|
||||
onClick={() => updateData({ direction: "SW" })}
|
||||
>
|
||||
Юго-Запад
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={data.direction === "W" ? "selected" : ""}
|
||||
onClick={() => updateData({ direction: "W" })}
|
||||
>
|
||||
Запад
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={data.direction === "NW" ? "selected" : ""}
|
||||
onClick={() => updateData({ direction: "NW" })}
|
||||
>
|
||||
Северо-Запад
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={data.direction === "unknown" ? "selected" : ""}
|
||||
onClick={() => updateData({ direction: "unknown" })}
|
||||
>
|
||||
Неизвестно
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Описание обстоятельств</label>
|
||||
<textarea
|
||||
value={data.circumstances_description || ""}
|
||||
onChange={(e) => updateData({ circumstances_description: e.target.value })}
|
||||
placeholder="Подробное описание: что произошло, кто видел последним, что говорил ребёнок..."
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Step3Circumstances;
|
||||
@@ -0,0 +1,274 @@
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
const Step4Environment = ({ data, updateData }) => {
|
||||
const toggleTerrain = (terrain) => {
|
||||
const current = data.terrain || [];
|
||||
const updated = current.includes(terrain)
|
||||
? current.filter((t) => t !== terrain)
|
||||
: [...current, terrain];
|
||||
updateData({ terrain: updated });
|
||||
};
|
||||
|
||||
// Автоматически определяем сезон из даты потери
|
||||
useEffect(() => {
|
||||
if (data.lost_time && !data.season) {
|
||||
const date = new Date(data.lost_time);
|
||||
const month = date.getMonth() + 1; // 1-12
|
||||
|
||||
let season = 'лето';
|
||||
if (month >= 3 && month <= 5) season = 'весна';
|
||||
else if (month >= 6 && month <= 8) season = 'лето';
|
||||
else if (month >= 9 && month <= 11) season = 'осень';
|
||||
else season = 'зима'; // 12, 1, 2
|
||||
|
||||
updateData({ season });
|
||||
}
|
||||
}, [data.lost_time]);
|
||||
|
||||
const handleGetLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
alert("Геолокация не поддерживается вашим браузером");
|
||||
return;
|
||||
}
|
||||
|
||||
updateData({ gps_loading: true });
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
updateData({
|
||||
tnp_lat: position.coords.latitude.toFixed(6),
|
||||
tnp_lon: position.coords.longitude.toFixed(6),
|
||||
gps_loading: false
|
||||
});
|
||||
},
|
||||
(error) => {
|
||||
alert("Ошибка получения координат: " + error.message);
|
||||
updateData({ gps_loading: false });
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
maximumAge: 0
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="form-step">
|
||||
<h2>Шаг 5: Среда и местность</h2>
|
||||
|
||||
{/* Сезон определяется автоматически, показываем только для информации */}
|
||||
{data.season && (
|
||||
<div className="form-group">
|
||||
<label>Сезон (определен автоматически)</label>
|
||||
<div style={{ padding: "12px", background: "#161b22", borderRadius: "6px", color: "#8b949e" }}>
|
||||
{data.season.charAt(0).toUpperCase() + data.season.slice(1)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label>Температура (°C)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={data.temperature || ""}
|
||||
onChange={(e) => updateData({ temperature: parseFloat(e.target.value) })}
|
||||
min="-40"
|
||||
max="40"
|
||||
step="1"
|
||||
placeholder="Например: 15"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Осадки</label>
|
||||
<div className="button-group">
|
||||
<button
|
||||
type="button"
|
||||
className={data.precipitation === "нет" ? "selected" : ""}
|
||||
onClick={() => updateData({ precipitation: "нет" })}
|
||||
>
|
||||
Нет
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={data.precipitation === "дождь" ? "selected" : ""}
|
||||
onClick={() => updateData({ precipitation: "дождь" })}
|
||||
>
|
||||
Дождь
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={data.precipitation === "снег" ? "selected" : ""}
|
||||
onClick={() => updateData({ precipitation: "снег" })}
|
||||
>
|
||||
Снег
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={data.precipitation === "туман" ? "selected" : ""}
|
||||
onClick={() => updateData({ precipitation: "туман" })}
|
||||
>
|
||||
Туман
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Видимость</label>
|
||||
<div className="button-group">
|
||||
<button
|
||||
type="button"
|
||||
className={data.visibility === "хорошая" ? "selected" : ""}
|
||||
onClick={() => updateData({ visibility: "хорошая" })}
|
||||
>
|
||||
Хорошая
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={data.visibility === "средняя" ? "selected" : ""}
|
||||
onClick={() => updateData({ visibility: "средняя" })}
|
||||
>
|
||||
Средняя
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={data.visibility === "плохая" ? "selected" : ""}
|
||||
onClick={() => updateData({ visibility: "плохая" })}
|
||||
>
|
||||
Плохая
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Типы местности (можно выбрать несколько) *</label>
|
||||
<div className="checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.terrain?.includes("лес")}
|
||||
onChange={() => toggleTerrain("лес")}
|
||||
/>
|
||||
Лес
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.terrain?.includes("поле")}
|
||||
onChange={() => toggleTerrain("поле")}
|
||||
/>
|
||||
Поле
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.terrain?.includes("болото")}
|
||||
onChange={() => toggleTerrain("болото")}
|
||||
/>
|
||||
Болото
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.terrain?.includes("водоём")}
|
||||
onChange={() => toggleTerrain("водоём")}
|
||||
/>
|
||||
Водоём (река, озеро)
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.terrain?.includes("населённый_пункт")}
|
||||
onChange={() => toggleTerrain("населённый_пункт")}
|
||||
/>
|
||||
Населённый пункт
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.terrain?.includes("дороги")}
|
||||
onChange={() => toggleTerrain("дороги")}
|
||||
/>
|
||||
Дороги / тропы
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.terrain?.includes("заброшенные_здания")}
|
||||
onChange={() => toggleTerrain("заброшенные_здания")}
|
||||
/>
|
||||
Заброшенные здания
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.terrain?.includes("сельхоз")}
|
||||
onChange={() => toggleTerrain("сельхоз")}
|
||||
/>
|
||||
Сельхоз угодья
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.terrain?.includes("овраги")}
|
||||
onChange={() => toggleTerrain("овраги")}
|
||||
/>
|
||||
Овраги / ямы
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Координаты точки потери (GPS) *</label>
|
||||
<div style={{ marginBottom: "12px" }}>
|
||||
<button
|
||||
type="button"
|
||||
className="gps-button"
|
||||
onClick={handleGetLocation}
|
||||
disabled={data.gps_loading}
|
||||
>
|
||||
{data.gps_loading ? "Получение координат..." : "📍 Получить текущие координаты"}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "12px" }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: "12px", color: "#8b949e" }}>Широта</label>
|
||||
<input
|
||||
type="number"
|
||||
value={data.tnp_lat || ""}
|
||||
onChange={(e) => updateData({ tnp_lat: e.target.value })}
|
||||
placeholder="53.9000"
|
||||
step="0.000001"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: "12px", color: "#8b949e" }}>Долгота</label>
|
||||
<input
|
||||
type="number"
|
||||
value={data.tnp_lon || ""}
|
||||
onChange={(e) => updateData({ tnp_lon: e.target.value })}
|
||||
placeholder="27.5667"
|
||||
step="0.000001"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Или введите адрес</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.tnp_address || ""}
|
||||
onChange={(e) => updateData({ tnp_address: e.target.value })}
|
||||
placeholder="Минск, ул. Ленина 10"
|
||||
/>
|
||||
<p style={{ fontSize: "12px", color: "#8b949e", marginTop: "4px" }}>
|
||||
Адрес будет преобразован в координаты автоматически
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Step4Environment;
|
||||
@@ -0,0 +1,159 @@
|
||||
import React from "react";
|
||||
|
||||
const Step5Resources = ({ data, updateData }) => {
|
||||
const toggleEquipment = (equipment) => {
|
||||
const current = data.equipment || [];
|
||||
const updated = current.includes(equipment)
|
||||
? current.filter((e) => e !== equipment)
|
||||
: [...current, equipment];
|
||||
updateData({ equipment: updated });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="form-step">
|
||||
<h2>Шаг 5: Ресурсы для поиска</h2>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Количество поисковых групп</label>
|
||||
<input
|
||||
type="number"
|
||||
value={data.search_groups || ""}
|
||||
onChange={(e) => updateData({ search_groups: parseInt(e.target.value) })}
|
||||
min="0"
|
||||
max="100"
|
||||
placeholder="Например: 3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Количество человек в поиске</label>
|
||||
<input
|
||||
type="number"
|
||||
value={data.search_people || ""}
|
||||
onChange={(e) => updateData({ search_people: parseInt(e.target.value) })}
|
||||
min="0"
|
||||
max="1000"
|
||||
placeholder="Например: 25"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Поисковая собака</label>
|
||||
<div className="button-group">
|
||||
<button
|
||||
type="button"
|
||||
className={data.has_dog === true ? "selected" : ""}
|
||||
onClick={() => updateData({ has_dog: true })}
|
||||
>
|
||||
Да
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={data.has_dog === false ? "selected" : ""}
|
||||
onClick={() => updateData({ has_dog: false })}
|
||||
>
|
||||
Нет
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Дополнительное оборудование (можно выбрать несколько)</label>
|
||||
<div className="checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.equipment?.includes("дрон")}
|
||||
onChange={() => toggleEquipment("дрон")}
|
||||
/>
|
||||
Дрон / БПЛА
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.equipment?.includes("тепловизор")}
|
||||
onChange={() => toggleEquipment("тепловизор")}
|
||||
/>
|
||||
Тепловизор
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.equipment?.includes("квадроцикл")}
|
||||
onChange={() => toggleEquipment("квадроцикл")}
|
||||
/>
|
||||
Квадроцикл / мотовездеход
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.equipment?.includes("лодка")}
|
||||
onChange={() => toggleEquipment("лодка")}
|
||||
/>
|
||||
Лодка
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.equipment?.includes("вертолёт")}
|
||||
onChange={() => toggleEquipment("вертолёт")}
|
||||
/>
|
||||
Вертолёт
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.equipment?.includes("рация")}
|
||||
onChange={() => toggleEquipment("рация")}
|
||||
/>
|
||||
Рации
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.equipment?.includes("gps")}
|
||||
onChange={() => toggleEquipment("gps")}
|
||||
/>
|
||||
GPS навигаторы
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.equipment?.includes("медик")}
|
||||
onChange={() => toggleEquipment("медик")}
|
||||
/>
|
||||
Медицинская бригада
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.equipment?.includes("прожектор")}
|
||||
onChange={() => toggleEquipment("прожектор")}
|
||||
/>
|
||||
Прожекторы / фонари
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.equipment?.includes("мегафон")}
|
||||
onChange={() => toggleEquipment("мегафон")}
|
||||
/>
|
||||
Мегафон / громкоговоритель
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Дополнительная информация о ресурсах</label>
|
||||
<textarea
|
||||
value={data.resources_notes || ""}
|
||||
onChange={(e) => updateData({ resources_notes: e.target.value })}
|
||||
placeholder="Особенности организации поиска, доступные ресурсы, ограничения..."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Step5Resources;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./CaseForm";
|
||||
@@ -0,0 +1,146 @@
|
||||
.map-view-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
background: #0a0c0f;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #21262d;
|
||||
}
|
||||
|
||||
.map-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-controls {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
padding: 10px 16px;
|
||||
background: #161b22;
|
||||
color: #e6edf3;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.control-btn:hover:not(:disabled) {
|
||||
background: #21262d;
|
||||
border-color: #ff4d00;
|
||||
}
|
||||
|
||||
.control-btn.active {
|
||||
background: #ff4d00;
|
||||
color: #ffffff;
|
||||
border-color: #ff4d00;
|
||||
}
|
||||
|
||||
.control-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ТНП маркер */
|
||||
.tnp-marker {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.tnp-marker-inner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #ff4d00;
|
||||
color: #ffffff;
|
||||
border: 3px solid #ffffff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 8px rgba(255, 77, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Стрелка направления */
|
||||
.direction-arrow {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.direction-arrow div {
|
||||
font-size: 30px;
|
||||
color: #ff4d00;
|
||||
text-shadow: 0 0 4px rgba(255, 77, 0, 0.8);
|
||||
}
|
||||
|
||||
/* Leaflet popup стилизация */
|
||||
.leaflet-popup-content-wrapper {
|
||||
background: #161b22;
|
||||
color: #e6edf3;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.leaflet-popup-content {
|
||||
margin: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.leaflet-popup-content b {
|
||||
color: #ff4d00;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.leaflet-popup-content small {
|
||||
color: #8b949e;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.leaflet-popup-tip {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
}
|
||||
|
||||
.leaflet-popup-close-button {
|
||||
color: #e6edf3 !important;
|
||||
}
|
||||
|
||||
.leaflet-popup-close-button:hover {
|
||||
color: #ff4d00 !important;
|
||||
}
|
||||
|
||||
/* Адаптивность */
|
||||
@media (max-width: 768px) {
|
||||
.map-view-container {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.map-controls {
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tnp-marker-inner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import L from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import "leaflet.heat";
|
||||
import "./MapView.css";
|
||||
|
||||
// Fix Leaflet default marker icons
|
||||
delete L.Icon.Default.prototype._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
|
||||
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
|
||||
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||
});
|
||||
|
||||
const MapView = ({
|
||||
tnpLat,
|
||||
tnpLon,
|
||||
zones = [],
|
||||
direction = null,
|
||||
heatmapData = [],
|
||||
showHeatmap = false
|
||||
}) => {
|
||||
const mapRef = useRef(null);
|
||||
const mapInstanceRef = useRef(null);
|
||||
const zonesLayerRef = useRef(null);
|
||||
const heatmapLayerRef = useRef(null);
|
||||
const tnpMarkerRef = useRef(null);
|
||||
const directionArrowRef = useRef(null);
|
||||
|
||||
const [heatmapVisible, setHeatmapVisible] = useState(showHeatmap);
|
||||
|
||||
// Цвета зон из §5 ЦОУ палитры
|
||||
const ZONE_COLORS = {
|
||||
0: "#ff4d00", // Зона 1 (0-500м) - красный
|
||||
1: "#ff9f1a", // Зона 2 (500-1500м) - оранжевый
|
||||
2: "#ffd166", // Зона 3 (1500-2500м) - желтый
|
||||
};
|
||||
|
||||
// Направления в градусы
|
||||
const DIRECTION_ANGLES = {
|
||||
N: 0,
|
||||
NE: 45,
|
||||
E: 90,
|
||||
SE: 135,
|
||||
S: 180,
|
||||
SW: 225,
|
||||
W: 270,
|
||||
NW: 315,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || mapInstanceRef.current) return;
|
||||
|
||||
// Инициализация карты
|
||||
const map = L.map(mapRef.current).setView([tnpLat || 53.9, tnpLon || 27.56], 13);
|
||||
|
||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
attribution: "© OpenStreetMap contributors",
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
mapInstanceRef.current = map;
|
||||
|
||||
return () => {
|
||||
if (mapInstanceRef.current) {
|
||||
mapInstanceRef.current.remove();
|
||||
mapInstanceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Обновление ТНП маркера
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current || !tnpLat || !tnpLon) return;
|
||||
|
||||
// Удаляем старый маркер
|
||||
if (tnpMarkerRef.current) {
|
||||
mapInstanceRef.current.removeLayer(tnpMarkerRef.current);
|
||||
}
|
||||
|
||||
// Создаем красный маркер ТНП
|
||||
const tnpIcon = L.divIcon({
|
||||
className: "tnp-marker",
|
||||
html: "<div class=\"tnp-marker-inner\">ТНП</div>",
|
||||
iconSize: [40, 40],
|
||||
iconAnchor: [20, 20],
|
||||
});
|
||||
|
||||
const marker = L.marker([tnpLat, tnpLon], { icon: tnpIcon }).addTo(mapInstanceRef.current);
|
||||
marker.bindPopup("<b>Точка начала поиска</b><br>" + tnpLat.toFixed(6) + ", " + tnpLon.toFixed(6));
|
||||
tnpMarkerRef.current = marker;
|
||||
|
||||
// Центрируем карту
|
||||
mapInstanceRef.current.setView([tnpLat, tnpLon], 13);
|
||||
}, [tnpLat, tnpLon]);
|
||||
|
||||
// Обновление стрелки направления
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current || !tnpLat || !tnpLon || !direction || direction === "unknown") {
|
||||
if (directionArrowRef.current) {
|
||||
mapInstanceRef.current.removeLayer(directionArrowRef.current);
|
||||
directionArrowRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Удаляем старую стрелку
|
||||
if (directionArrowRef.current) {
|
||||
mapInstanceRef.current.removeLayer(directionArrowRef.current);
|
||||
}
|
||||
|
||||
const angle = DIRECTION_ANGLES[direction];
|
||||
if (angle === undefined) return;
|
||||
|
||||
// Создаем стрелку (линия 1км в направлении)
|
||||
const distance = 1000; // метров
|
||||
const endPoint = calculateDestination(tnpLat, tnpLon, angle, distance);
|
||||
|
||||
const arrow = L.polyline(
|
||||
[[tnpLat, tnpLon], endPoint],
|
||||
{
|
||||
color: "#ff4d00",
|
||||
weight: 3,
|
||||
opacity: 0.8,
|
||||
dashArray: "10, 5",
|
||||
}
|
||||
).addTo(mapInstanceRef.current);
|
||||
|
||||
// Добавляем стрелку на конце
|
||||
const arrowHead = L.marker(endPoint, {
|
||||
icon: L.divIcon({
|
||||
className: "direction-arrow",
|
||||
html: "<div style=\"transform: rotate(" + angle + "deg)\">➤</div>",
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15],
|
||||
}),
|
||||
}).addTo(mapInstanceRef.current);
|
||||
|
||||
directionArrowRef.current = L.layerGroup([arrow, arrowHead]).addTo(mapInstanceRef.current);
|
||||
}, [tnpLat, tnpLon, direction]);
|
||||
|
||||
// Обновление зон (секторные полигоны)
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current || !tnpLat || !tnpLon || zones.length === 0) {
|
||||
if (zonesLayerRef.current) {
|
||||
mapInstanceRef.current.removeLayer(zonesLayerRef.current);
|
||||
zonesLayerRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Удаляем старые зоны
|
||||
if (zonesLayerRef.current) {
|
||||
mapInstanceRef.current.removeLayer(zonesLayerRef.current);
|
||||
}
|
||||
|
||||
const zonePolygons = [];
|
||||
|
||||
zones.forEach((zone, index) => {
|
||||
const color = ZONE_COLORS[index] || "#cccccc";
|
||||
const directionAngle = DIRECTION_ANGLES[zone.direction] || 0;
|
||||
|
||||
// Создаем секторный полигон (угол ±45° от направления)
|
||||
const sectorAngle = 90; // ширина сектора
|
||||
const startAngle = directionAngle - sectorAngle / 2;
|
||||
const endAngle = directionAngle + sectorAngle / 2;
|
||||
const radiusMeters = zone.distance_km * 1000;
|
||||
|
||||
const sectorPoints = [[tnpLat, tnpLon]]; // Начало в ТНП
|
||||
|
||||
// Генерируем точки по дуге
|
||||
for (let angle = startAngle; angle <= endAngle; angle += 5) {
|
||||
const point = calculateDestination(tnpLat, tnpLon, angle, radiusMeters);
|
||||
sectorPoints.push(point);
|
||||
}
|
||||
|
||||
sectorPoints.push([tnpLat, tnpLon]); // Замыкаем полигон
|
||||
|
||||
const polygon = L.polygon(sectorPoints, {
|
||||
color: color,
|
||||
fillColor: color,
|
||||
fillOpacity: 0.2,
|
||||
weight: 2,
|
||||
opacity: 0.6,
|
||||
});
|
||||
|
||||
polygon.bindPopup(
|
||||
"<b>" + zone.name + "</b><br>" +
|
||||
"Приоритет: " + zone.priority + "<br>" +
|
||||
"Оценка: " + zone.score + "<br>" +
|
||||
"Расстояние: " + zone.distance_km.toFixed(2) + " км<br>" +
|
||||
"<small>" + zone.reasoning + "</small>"
|
||||
);
|
||||
|
||||
zonePolygons.push(polygon);
|
||||
});
|
||||
|
||||
zonesLayerRef.current = L.layerGroup(zonePolygons).addTo(mapInstanceRef.current);
|
||||
}, [tnpLat, tnpLon, zones]);
|
||||
|
||||
// Обновление тепловой карты
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current) return;
|
||||
|
||||
// Удаляем старый слой
|
||||
if (heatmapLayerRef.current) {
|
||||
mapInstanceRef.current.removeLayer(heatmapLayerRef.current);
|
||||
heatmapLayerRef.current = null;
|
||||
}
|
||||
|
||||
if (!heatmapVisible || heatmapData.length === 0) return;
|
||||
|
||||
// Создаем heatmap слой
|
||||
const heatPoints = heatmapData.map(point => [
|
||||
point.lat,
|
||||
point.lon,
|
||||
point.intensity || 1.0
|
||||
]);
|
||||
|
||||
const heatLayer = L.heatLayer(heatPoints, {
|
||||
radius: 25,
|
||||
blur: 15,
|
||||
maxZoom: 17,
|
||||
max: 1.0,
|
||||
gradient: {
|
||||
0.0: "#0d1117",
|
||||
0.3: "#238636",
|
||||
0.5: "#ffd166",
|
||||
0.7: "#ff9f1a",
|
||||
1.0: "#ff4d00"
|
||||
}
|
||||
}).addTo(mapInstanceRef.current);
|
||||
|
||||
heatmapLayerRef.current = heatLayer;
|
||||
}, [heatmapVisible, heatmapData]);
|
||||
|
||||
// Вычисление точки назначения (lat, lon, angle, distance)
|
||||
const calculateDestination = (lat, lon, bearing, distance) => {
|
||||
const R = 6371000; // Радиус Земли в метрах
|
||||
const φ1 = (lat * Math.PI) / 180;
|
||||
const λ1 = (lon * Math.PI) / 180;
|
||||
const θ = (bearing * Math.PI) / 180;
|
||||
|
||||
const φ2 = Math.asin(
|
||||
Math.sin(φ1) * Math.cos(distance / R) +
|
||||
Math.cos(φ1) * Math.sin(distance / R) * Math.cos(θ)
|
||||
);
|
||||
|
||||
const λ2 = λ1 + Math.atan2(
|
||||
Math.sin(θ) * Math.sin(distance / R) * Math.cos(φ1),
|
||||
Math.cos(distance / R) - Math.sin(φ1) * Math.sin(φ2)
|
||||
);
|
||||
|
||||
return [(φ2 * 180) / Math.PI, (λ2 * 180) / Math.PI];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="map-view-container">
|
||||
<div className="map-controls">
|
||||
<button
|
||||
className={heatmapVisible ? "control-btn active" : "control-btn"}
|
||||
onClick={() => setHeatmapVisible(!heatmapVisible)}
|
||||
disabled={heatmapData.length === 0}
|
||||
>
|
||||
{heatmapVisible ? "🔥 Скрыть тепловую карту" : "🔥 Показать тепловую карту"}
|
||||
</button>
|
||||
</div>
|
||||
<div ref={mapRef} className="map-view" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MapView;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./MapView";
|
||||
@@ -0,0 +1,353 @@
|
||||
/* Мобильная форма — палитра из §5 контекста */
|
||||
.mobile-form {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: var(--bg-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.mobile-form h1 {
|
||||
margin: 0 0 24px 0;
|
||||
font-size: 24px;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: var(--bg-secondary);
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-section h2 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 18px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
margin: 8px 0 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input[type=number],
|
||||
.form-group input[type=text],
|
||||
.form-group input[type=time],
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: var(--input-bg);
|
||||
color: var(--text-primary);
|
||||
border: 2px solid var(--input-border);
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
transition: border-color 0.2s;
|
||||
min-height: var(--btn-min-height, 52px);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.button-group button {
|
||||
padding: 12px;
|
||||
background: var(--input-bg);
|
||||
color: var(--text-secondary);
|
||||
border: 2px solid var(--input-border);
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
min-height: var(--btn-min-height, 52px);
|
||||
}
|
||||
|
||||
.button-group button:hover {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.button-group button.selected {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: var(--input-bg);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
min-height: var(--btn-min-height, 52px);
|
||||
border: 1px solid var(--input-border);
|
||||
}
|
||||
|
||||
.checkbox-label:hover {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.checkbox-label input[type=checkbox] {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 12px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.psychotype-section {
|
||||
background: var(--bg-secondary);
|
||||
border-left: 4px solid var(--accent);
|
||||
}
|
||||
|
||||
.psychotype-question {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.psychotype-question:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.question-label {
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.button-group-vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.psychotype-btn {
|
||||
padding: 14px 16px;
|
||||
background: var(--input-bg);
|
||||
color: var(--text-secondary);
|
||||
border: 2px solid var(--input-border);
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
min-height: var(--btn-min-height, 52px);
|
||||
}
|
||||
|
||||
.psychotype-btn:hover {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.psychotype-btn.selected {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* GPS кнопка */
|
||||
.btn-gps {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: var(--btn-gps, #0a84ff);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
min-height: var(--btn-min-height, 52px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-gps:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Кнопка подтверждения */
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: var(--btn-confirm, var(--accent));
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-top: 24px;
|
||||
min-height: var(--btn-min-height, 52px);
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.submit-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
background: var(--text-secondary);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Success and Error messages */
|
||||
.success-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: var(--success);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 48px;
|
||||
margin-bottom: 24px;
|
||||
animation: scaleIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
transform: scale(0);
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.success-message h2 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 24px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.success-message p {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
padding: 16px;
|
||||
background: var(--danger);
|
||||
border-left: 4px solid var(--danger);
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
margin-bottom: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Responsive для больших экранов */
|
||||
@media (min-width: 768px) {
|
||||
.mobile-form {
|
||||
padding: 40px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Оптимизация для маленьких экранов */
|
||||
@media (max-width: 480px) {
|
||||
.mobile-form {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.mobile-form h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.form-section h2 {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Зоны поиска с цветами из палитры */
|
||||
.zone-card {
|
||||
border-left: 4px solid;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.zone-card.priority-1 {
|
||||
border-left-color: var(--zone-1);
|
||||
}
|
||||
|
||||
.zone-card.priority-2 {
|
||||
border-left-color: var(--zone-2);
|
||||
}
|
||||
|
||||
.zone-card.priority-3 {
|
||||
border-left-color: var(--zone-3);
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import React, { useState } from 'react';
|
||||
import './MobileForm.css';
|
||||
|
||||
const MobileForm = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
age: '',
|
||||
gender: '',
|
||||
terrain: '',
|
||||
weather: '',
|
||||
time_missing: '',
|
||||
last_location: '',
|
||||
circumstances: '',
|
||||
physical_condition: '',
|
||||
experience: '',
|
||||
season: '',
|
||||
diagnosis_type: [],
|
||||
psychotype_answers: {}
|
||||
});
|
||||
|
||||
const handlePsychotypeAnswer = (question, value) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
psychotype_answers: {
|
||||
...prev.psychotype_answers,
|
||||
[question]: value
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const isPsychotypeSelected = (question, value) => {
|
||||
return formData.psychotype_answers?.[question] === value;
|
||||
};
|
||||
|
||||
const handleDiagnosisChange = (diagnosis, checked) => {
|
||||
const current = formData.diagnosis_type || [];
|
||||
setFormData({
|
||||
...formData,
|
||||
diagnosis_type: checked
|
||||
? [...current, diagnosis]
|
||||
: current.filter(d => d !== diagnosis)
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
console.log('Form data:', formData);
|
||||
|
||||
// Отправка на backend
|
||||
try {
|
||||
const response = await fetch('http://192.168.0.99:8000/api/v1/analyze/combined', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
const result = await response.json();
|
||||
console.log('Analysis result:', result);
|
||||
alert('Анализ выполнен! Смотри консоль для результатов.');
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Ошибка при отправке данных');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mobile-form">
|
||||
<h1>Карточка пропавшего</h1>
|
||||
|
||||
{/* Базовая информация */}
|
||||
<section className="form-section">
|
||||
<h2>Основная информация</h2>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Возраст</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Лет"
|
||||
value={formData.age}
|
||||
onChange={(e) => setFormData({...formData, age: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Пол</label>
|
||||
<div className="button-group">
|
||||
<button
|
||||
className={formData.gender === 'мужской' ? 'selected' : ''}
|
||||
onClick={() => setFormData({...formData, gender: 'мужской'})}
|
||||
>
|
||||
Мужской
|
||||
</button>
|
||||
<button
|
||||
className={formData.gender === 'женский' ? 'selected' : ''}
|
||||
onClick={() => setFormData({...formData, gender: 'женский'})}
|
||||
>
|
||||
Женский
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Сезон</label>
|
||||
<div className="button-group">
|
||||
<button
|
||||
className={formData.season === 'winter' ? 'selected' : ''}
|
||||
onClick={() => setFormData({...formData, season: 'winter'})}
|
||||
>
|
||||
Зима
|
||||
</button>
|
||||
<button
|
||||
className={formData.season === 'spring' ? 'selected' : ''}
|
||||
onClick={() => setFormData({...formData, season: 'spring'})}
|
||||
>
|
||||
Весна
|
||||
</button>
|
||||
<button
|
||||
className={formData.season === 'summer' ? 'selected' : ''}
|
||||
onClick={() => setFormData({...formData, season: 'summer'})}
|
||||
>
|
||||
Лето
|
||||
</button>
|
||||
<button
|
||||
className={formData.season === 'autumn' ? 'selected' : ''}
|
||||
onClick={() => setFormData({...formData, season: 'autumn'})}
|
||||
>
|
||||
Осень
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Физическое состояние</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Например: хорошее, слабое, травмы"
|
||||
value={formData.physical_condition}
|
||||
onChange={(e) => setFormData({...formData, physical_condition: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Обстоятельства пропажи */}
|
||||
<section className="form-section">
|
||||
<h2>Обстоятельства пропажи</h2>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Местность</label>
|
||||
<select
|
||||
value={formData.terrain}
|
||||
onChange={(e) => setFormData({...formData, terrain: e.target.value})}
|
||||
>
|
||||
<option value="">Выберите...</option>
|
||||
<option value="лес">Лес</option>
|
||||
<option value="поле">Поле</option>
|
||||
<option value="горы">Горы</option>
|
||||
<option value="город">Город</option>
|
||||
<option value="болото">Болото</option>
|
||||
<option value="водоём">Водоём</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Погода</label>
|
||||
<select
|
||||
value={formData.weather}
|
||||
onChange={(e) => setFormData({...formData, weather: e.target.value})}
|
||||
>
|
||||
<option value="">Выберите...</option>
|
||||
<option value="ясно">Ясно</option>
|
||||
<option value="облачно">Облачно</option>
|
||||
<option value="дождь">Дождь</option>
|
||||
<option value="снег">Снег</option>
|
||||
<option value="туман">Туман</option>
|
||||
<option value="жара">Жара</option>
|
||||
<option value="холод">Холод</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Время пропажи</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Например: 2 часа, 6 часов, 1 день"
|
||||
value={formData.time_missing}
|
||||
onChange={(e) => setFormData({...formData, time_missing: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Последнее известное местоположение</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Например: лесная тропа у озера"
|
||||
value={formData.last_location}
|
||||
onChange={(e) => setFormData({...formData, last_location: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Обстоятельства</label>
|
||||
<textarea
|
||||
placeholder="Например: вышел за грибами, не вернулся к вечеру"
|
||||
value={formData.circumstances}
|
||||
onChange={(e) => setFormData({...formData, circumstances: e.target.value})}
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Опыт нахождения на природе</label>
|
||||
<select
|
||||
value={formData.experience}
|
||||
onChange={(e) => setFormData({...formData, experience: e.target.value})}
|
||||
>
|
||||
<option value="">Выберите...</option>
|
||||
<option value="опытный">Опытный</option>
|
||||
<option value="средний">Средний опыт</option>
|
||||
<option value="новичок">Новичок</option>
|
||||
<option value="нет опыта">Нет опыта</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Диагнозы */}
|
||||
<section className="form-section">
|
||||
<h2>Диагнозы (если есть)</h2>
|
||||
<div className="checkbox-group">
|
||||
{['РАС', 'эпилепсия', 'СДВГ', 'деменция', 'Альцгеймер'].map(diagnosis => (
|
||||
<label key={diagnosis} className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.diagnosis_type?.includes(diagnosis)}
|
||||
onChange={(e) => handleDiagnosisChange(diagnosis, e.target.checked)}
|
||||
/>
|
||||
{diagnosis}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Блок психотипа */}
|
||||
<section className="form-section psychotype-section">
|
||||
<div className="section-header">
|
||||
<h2>Поведение ребёнка (спросить родителей)</h2>
|
||||
<p className="section-subtitle">Помогает уточнить зону поиска</p>
|
||||
</div>
|
||||
|
||||
{/* Вопрос 1: Незнакомая обстановка */}
|
||||
<div className="psychotype-question">
|
||||
<label className="question-label">Как ведёт себя в незнакомой обстановке?</label>
|
||||
<div className="button-group-vertical">
|
||||
<button
|
||||
className={`psychotype-btn ${isPsychotypeSelected('unfamiliar_env', 'explore') ? 'selected' : ''}`}
|
||||
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'explore')}
|
||||
>
|
||||
Активно исследует
|
||||
</button>
|
||||
<button
|
||||
className={`psychotype-btn ${isPsychotypeSelected('unfamiliar_env', 'wait') ? 'selected' : ''}`}
|
||||
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'wait')}
|
||||
>
|
||||
Ждёт
|
||||
</button>
|
||||
<button
|
||||
className={`psychotype-btn ${isPsychotypeSelected('unfamiliar_env', 'freeze') ? 'selected' : ''}`}
|
||||
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'freeze')}
|
||||
>
|
||||
Замирает
|
||||
</button>
|
||||
<button
|
||||
className={`psychotype-btn ${isPsychotypeSelected('unfamiliar_env', 'panic') ? 'selected' : ''}`}
|
||||
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'panic')}
|
||||
>
|
||||
Паникует
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Вопрос 2: Реакция на неудачи */}
|
||||
<div className="psychotype-question">
|
||||
<label className="question-label">Реакция на неудачи?</label>
|
||||
<div className="button-group-vertical">
|
||||
<button
|
||||
className={`psychotype-btn ${isPsychotypeSelected('stress_reaction', 'active') ? 'selected' : ''}`}
|
||||
onClick={() => handlePsychotypeAnswer('stress_reaction', 'active')}
|
||||
>
|
||||
Злится, кричит
|
||||
</button>
|
||||
<button
|
||||
className={`psychotype-btn ${isPsychotypeSelected('stress_reaction', 'cry') ? 'selected' : ''}`}
|
||||
onClick={() => handlePsychotypeAnswer('stress_reaction', 'cry')}
|
||||
>
|
||||
Плачет, замыкается
|
||||
</button>
|
||||
<button
|
||||
className={`psychotype-btn ${isPsychotypeSelected('stress_reaction', 'calm') ? 'selected' : ''}`}
|
||||
onClick={() => handlePsychotypeAnswer('stress_reaction', 'calm')}
|
||||
>
|
||||
Спокойно ищет выход
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Вопрос 3: Лидер или ведомый */}
|
||||
<div className="psychotype-question">
|
||||
<label className="question-label">Лидер или ведомый?</label>
|
||||
<div className="button-group-vertical">
|
||||
<button
|
||||
className={`psychotype-btn ${isPsychotypeSelected('group_role', 'leader') ? 'selected' : ''}`}
|
||||
onClick={() => handlePsychotypeAnswer('group_role', 'leader')}
|
||||
>
|
||||
Лидер
|
||||
</button>
|
||||
<button
|
||||
className={`psychotype-btn ${isPsychotypeSelected('group_role', 'sometimes') ? 'selected' : ''}`}
|
||||
onClick={() => handlePsychotypeAnswer('group_role', 'sometimes')}
|
||||
>
|
||||
Иногда
|
||||
</button>
|
||||
<button
|
||||
className={`psychotype-btn ${isPsychotypeSelected('group_role', 'follower') ? 'selected' : ''}`}
|
||||
onClick={() => handlePsychotypeAnswer('group_role', 'follower')}
|
||||
>
|
||||
Всегда ведомый
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Вопрос 4: Любит рисковать */}
|
||||
<div className="psychotype-question">
|
||||
<label className="question-label">Любит рисковать?</label>
|
||||
<div className="button-group-vertical">
|
||||
<button
|
||||
className={`psychotype-btn ${isPsychotypeSelected('risk_taking', 'high') ? 'selected' : ''}`}
|
||||
onClick={() => handlePsychotypeAnswer('risk_taking', 'high')}
|
||||
>
|
||||
Очень
|
||||
</button>
|
||||
<button
|
||||
className={`psychotype-btn ${isPsychotypeSelected('risk_taking', 'medium') ? 'selected' : ''}`}
|
||||
onClick={() => handlePsychotypeAnswer('risk_taking', 'medium')}
|
||||
>
|
||||
Иногда
|
||||
</button>
|
||||
<button
|
||||
className={`psychotype-btn ${isPsychotypeSelected('risk_taking', 'low') ? 'selected' : ''}`}
|
||||
onClick={() => handlePsychotypeAnswer('risk_taking', 'low')}
|
||||
>
|
||||
Нет, осторожный
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Кнопка отправки */}
|
||||
<button className="submit-btn" onClick={handleSubmit}>
|
||||
Создать карточку и получить анализ
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileForm;
|
||||
@@ -0,0 +1,296 @@
|
||||
import React, { useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import './MobileForm.css';
|
||||
|
||||
interface PsychotypeAnswers {
|
||||
unfamiliar_env?: string;
|
||||
stress_reaction?: string;
|
||||
group_role?: string;
|
||||
risk_taking?: string;
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
age?: number;
|
||||
gender?: string;
|
||||
diagnosis_type?: string[];
|
||||
psychotype_answers?: PsychotypeAnswers;
|
||||
}
|
||||
|
||||
const MobileForm: React.FC = () => {
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
diagnosis_type: [],
|
||||
psychotype_answers: {}
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handlePsychotypeAnswer = (question: keyof PsychotypeAnswers, value: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
psychotype_answers: {
|
||||
...prev.psychotype_answers,
|
||||
[question]: value
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const isPsychotypeSelected = (question: keyof PsychotypeAnswers, value: string) => {
|
||||
return formData.psychotype_answers?.[question] === value;
|
||||
};
|
||||
|
||||
const validateForm = (): string | null => {
|
||||
if (!formData.age || formData.age < 0 || formData.age > 120) {
|
||||
return 'Укажите корректный возраст';
|
||||
}
|
||||
if (!formData.gender) {
|
||||
return 'Выберите пол';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const validationError = validateForm();
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/v1/cases', formData);
|
||||
console.log('Case created:', response.data);
|
||||
setSuccess(true);
|
||||
|
||||
setTimeout(() => {
|
||||
setFormData({
|
||||
diagnosis_type: [],
|
||||
psychotype_answers: {}
|
||||
});
|
||||
setSuccess(false);
|
||||
}, 2000);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Ошибка при создании карточки');
|
||||
console.error('Submit error:', err);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="mobile-form">
|
||||
<div className="success-message">
|
||||
<div className="success-icon">✓</div>
|
||||
<h2>Карточка создана!</h2>
|
||||
<p>Данные успешно сохранены</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mobile-form">
|
||||
<h1>Карточка пропавшего</h1>
|
||||
|
||||
{error && (
|
||||
<div className="error-banner">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="form-section">
|
||||
<h2>Основная информация</h2>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Возраст *</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Лет"
|
||||
value={formData.age || ''}
|
||||
onChange={(e) => setFormData({...formData, age: parseInt(e.target.value)})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Пол *</label>
|
||||
<div className="button-group">
|
||||
<button
|
||||
type="button"
|
||||
className={formData.gender === 'male' ? 'selected' : ''}
|
||||
onClick={() => setFormData({...formData, gender: 'male'})}
|
||||
>
|
||||
Мужской
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={formData.gender === 'female' ? 'selected' : ''}
|
||||
onClick={() => setFormData({...formData, gender: 'female'})}
|
||||
>
|
||||
Женский
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="form-section">
|
||||
<h2>Диагнозы (если есть)</h2>
|
||||
<div className="checkbox-group">
|
||||
{['РАС', 'эпилепсия', 'СДВГ', 'деменция'].map(diagnosis => (
|
||||
<label key={diagnosis} className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.diagnosis_type?.includes(diagnosis)}
|
||||
onChange={(e) => {
|
||||
const current = formData.diagnosis_type || [];
|
||||
setFormData({
|
||||
...formData,
|
||||
diagnosis_type: e.target.checked
|
||||
? [...current, diagnosis]
|
||||
: current.filter(d => d !== diagnosis)
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{diagnosis}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="form-section psychotype-section">
|
||||
<div className="section-header">
|
||||
<h2>Поведение ребёнка (спросить родителей)</h2>
|
||||
<p className="section-subtitle">Помогает уточнить зону поиска</p>
|
||||
</div>
|
||||
|
||||
<div className="psychotype-question">
|
||||
<label className="question-label">Как ведёт себя в незнакомой обстановке?</label>
|
||||
<div className="button-group-vertical">
|
||||
<button
|
||||
type="button"
|
||||
className={'psychotype-btn ' + (isPsychotypeSelected('unfamiliar_env', 'explore') ? 'selected' : '')}
|
||||
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'explore')}
|
||||
>
|
||||
Активно исследует
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={'psychotype-btn ' + (isPsychotypeSelected('unfamiliar_env', 'wait') ? 'selected' : '')}
|
||||
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'wait')}
|
||||
>
|
||||
Ждёт
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={'psychotype-btn ' + (isPsychotypeSelected('unfamiliar_env', 'freeze') ? 'selected' : '')}
|
||||
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'freeze')}
|
||||
>
|
||||
Замирает
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={'psychotype-btn ' + (isPsychotypeSelected('unfamiliar_env', 'panic') ? 'selected' : '')}
|
||||
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'panic')}
|
||||
>
|
||||
Паникует
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="psychotype-question">
|
||||
<label className="question-label">Реакция на неудачи?</label>
|
||||
<div className="button-group-vertical">
|
||||
<button
|
||||
type="button"
|
||||
className={'psychotype-btn ' + (isPsychotypeSelected('stress_reaction', 'active') ? 'selected' : '')}
|
||||
onClick={() => handlePsychotypeAnswer('stress_reaction', 'active')}
|
||||
>
|
||||
Злится, кричит
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={'psychotype-btn ' + (isPsychotypeSelected('stress_reaction', 'cry') ? 'selected' : '')}
|
||||
onClick={() => handlePsychotypeAnswer('stress_reaction', 'cry')}
|
||||
>
|
||||
Плачет, замыкается
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={'psychotype-btn ' + (isPsychotypeSelected('stress_reaction', 'calm') ? 'selected' : '')}
|
||||
onClick={() => handlePsychotypeAnswer('stress_reaction', 'calm')}
|
||||
>
|
||||
Спокойно ищет выход
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="psychotype-question">
|
||||
<label className="question-label">Лидер или ведомый?</label>
|
||||
<div className="button-group-vertical">
|
||||
<button
|
||||
type="button"
|
||||
className={'psychotype-btn ' + (isPsychotypeSelected('group_role', 'leader') ? 'selected' : '')}
|
||||
onClick={() => handlePsychotypeAnswer('group_role', 'leader')}
|
||||
>
|
||||
Лидер
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={'psychotype-btn ' + (isPsychotypeSelected('group_role', 'sometimes') ? 'selected' : '')}
|
||||
onClick={() => handlePsychotypeAnswer('group_role', 'sometimes')}
|
||||
>
|
||||
Иногда
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={'psychotype-btn ' + (isPsychotypeSelected('group_role', 'follower') ? 'selected' : '')}
|
||||
onClick={() => handlePsychotypeAnswer('group_role', 'follower')}
|
||||
>
|
||||
Всегда ведомый
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="psychotype-question">
|
||||
<label className="question-label">Любит рисковать?</label>
|
||||
<div className="button-group-vertical">
|
||||
<button
|
||||
type="button"
|
||||
className={'psychotype-btn ' + (isPsychotypeSelected('risk_taking', 'high') ? 'selected' : '')}
|
||||
onClick={() => handlePsychotypeAnswer('risk_taking', 'high')}
|
||||
>
|
||||
Очень
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={'psychotype-btn ' + (isPsycotypeSelected('risk_taking', 'medium') ? 'selected' : '')}
|
||||
onClick={() => handlePsychotypeAnswer('risk_taking', 'medium')}
|
||||
>
|
||||
Иногда
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={'psychotype-btn ' + (isPsychotypeSelected('risk_taking', 'low') ? 'selected' : '')}
|
||||
onClick={() => handlePsychotypeAnswer('risk_taking', 'low')}
|
||||
>
|
||||
Нет, осторожный
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button
|
||||
className="submit-btn"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Создание...' : 'Создать карточку'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileForm;
|
||||
@@ -0,0 +1,416 @@
|
||||
.result-panel {
|
||||
background: #0a0c0f;
|
||||
border: 1px solid #21262d;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.result-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #21262d;
|
||||
}
|
||||
|
||||
.result-header h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
color: #e6edf3;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.execution-time {
|
||||
font-size: 13px;
|
||||
color: #8b949e;
|
||||
padding: 4px 10px;
|
||||
background: #161b22;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.services-badge {
|
||||
font-size: 13px;
|
||||
color: #e6edf3;
|
||||
padding: 4px 10px;
|
||||
background: #238636;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Блок срочности */
|
||||
.urgency-block {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.urgency-icon {
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.urgency-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.urgency-label {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.urgency-summary {
|
||||
font-size: 14px;
|
||||
color: #e6edf3;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Behavioral Profile карточки */
|
||||
.behavioral-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.behavioral-section h3 {
|
||||
font-size: 18px;
|
||||
color: #e6edf3;
|
||||
margin: 0 0 16px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.behavioral-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.behavioral-card {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-left: 4px solid;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.behavioral-card:hover {
|
||||
background: #1c2128;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #e6edf3;
|
||||
}
|
||||
|
||||
.card-points {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
.card-points li {
|
||||
font-size: 13px;
|
||||
color: #8b949e;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.card-points li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Психотип */
|
||||
.psychotype-section {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.psychotype-section h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 18px;
|
||||
color: #ff9f1a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.psychotype-description {
|
||||
font-size: 14px;
|
||||
color: #8b949e;
|
||||
margin-bottom: 16px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.psychotype-recommendations {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.recommendation-item {
|
||||
font-size: 13px;
|
||||
color: #e6edf3;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.recommendation-item strong {
|
||||
color: #ff9f1a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Немедленные действия */
|
||||
.actions-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.actions-section h3 {
|
||||
font-size: 18px;
|
||||
color: #e6edf3;
|
||||
margin: 0 0 16px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.actions-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-left: 3px solid #ff4d00;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.action-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: #ff4d00;
|
||||
color: #ffffff;
|
||||
border-radius: 50%;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-text {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #e6edf3;
|
||||
line-height: 1.6;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
/* Зоны */
|
||||
.zones-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.zones-section h3 {
|
||||
font-size: 18px;
|
||||
color: #e6edf3;
|
||||
margin: 0 0 16px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.zones-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.zone-item {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.zone-item:hover {
|
||||
background: #1c2128;
|
||||
border-color: #ff4d00;
|
||||
}
|
||||
|
||||
.zone-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.zone-priority {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: #ff4d00;
|
||||
color: #ffffff;
|
||||
border-radius: 6px;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.zone-name {
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #e6edf3;
|
||||
}
|
||||
|
||||
.zone-score {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #ff9f1a;
|
||||
}
|
||||
|
||||
.zone-details {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.zone-distance,
|
||||
.zone-direction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.zone-reasoning {
|
||||
font-size: 13px;
|
||||
color: #8b949e;
|
||||
line-height: 1.5;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Ключевые объекты */
|
||||
.locations-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.locations-section h3 {
|
||||
font-size: 18px;
|
||||
color: #e6edf3;
|
||||
margin: 0 0 16px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.locations-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.location-tag {
|
||||
padding: 8px 14px;
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #e6edf3;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.location-tag:hover {
|
||||
background: #21262d;
|
||||
border-color: #ff4d00;
|
||||
}
|
||||
|
||||
/* Прогноз поведения */
|
||||
.prediction-section {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.prediction-section h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 18px;
|
||||
color: #e6edf3;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prediction-text {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #8b949e;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Адаптивность */
|
||||
@media (max-width: 768px) {
|
||||
.result-panel {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.result-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.behavioral-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.urgency-block {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.zone-header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.locations-tags {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.location-tag {
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import React from "react";
|
||||
import "./ResultPanel.css";
|
||||
|
||||
const ResultPanel = ({ analysisResult }) => {
|
||||
if (!analysisResult) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
urgency,
|
||||
zones = [],
|
||||
key_locations = [],
|
||||
immediate_actions = [],
|
||||
psychotype,
|
||||
psychotype_modifiers,
|
||||
psychotype_recommendations,
|
||||
behavioral_prediction,
|
||||
summary,
|
||||
execution_time_ms,
|
||||
services_used = []
|
||||
} = analysisResult;
|
||||
|
||||
// Цвета срочности
|
||||
const urgencyConfig = {
|
||||
"критическая": {
|
||||
color: "#ff4d00",
|
||||
label: "КРИТИЧЕСКАЯ",
|
||||
icon: "🚨"
|
||||
},
|
||||
"высокая": {
|
||||
color: "#ff9f1a",
|
||||
label: "ВЫСОКАЯ",
|
||||
icon: "⚠️"
|
||||
},
|
||||
"средняя": {
|
||||
color: "#ffd166",
|
||||
label: "УМЕРЕННАЯ",
|
||||
icon: "ℹ️"
|
||||
},
|
||||
"низкая": {
|
||||
color: "#238636",
|
||||
label: "НИЗКАЯ",
|
||||
icon: "✓"
|
||||
}
|
||||
};
|
||||
|
||||
const urgencyInfo = urgencyConfig[urgency] || urgencyConfig["средняя"];
|
||||
|
||||
// Behavioral Profile карточки
|
||||
const getBehavioralCards = () => {
|
||||
const cards = [];
|
||||
|
||||
// РАС карточка (красная)
|
||||
if (analysisResult.diagnosis_type?.includes("РАС")) {
|
||||
cards.push({
|
||||
type: "РАС",
|
||||
color: "#ff4d00",
|
||||
icon: "🧩",
|
||||
title: "Расстройство аутистического спектра",
|
||||
points: [
|
||||
"Может не откликаться на имя",
|
||||
"Избегает зрительного контакта",
|
||||
"Предпочитает укромные места",
|
||||
"Не боится опасности"
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Эпилепсия карточка (оранжевая)
|
||||
if (analysisResult.diagnosis_type?.includes("эпилепсия")) {
|
||||
cards.push({
|
||||
type: "Эпилепсия",
|
||||
color: "#ff9f1a",
|
||||
icon: "⚡",
|
||||
title: "Эпилепсия",
|
||||
points: [
|
||||
"Риск приступа при стрессе",
|
||||
"Может быть дезориентирован",
|
||||
"Требуется медицинская помощь",
|
||||
"Проверить водоёмы и ямы"
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Велосипед карточка (синяя)
|
||||
if (analysisResult.has_transport === "bike") {
|
||||
cards.push({
|
||||
type: "Велосипед",
|
||||
color: "#58a6ff",
|
||||
icon: "🚴",
|
||||
title: "Есть велосипед",
|
||||
points: [
|
||||
"Радиус поиска увеличен в 3-5 раз",
|
||||
"Проверить дороги и тропы",
|
||||
"Может преодолеть большое расстояние",
|
||||
"Искать вдоль маршрутов"
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Самокат карточка (синяя)
|
||||
if (analysisResult.has_transport === "scooter") {
|
||||
cards.push({
|
||||
type: "Самокат",
|
||||
color: "#58a6ff",
|
||||
icon: "🛴",
|
||||
title: "Есть самокат",
|
||||
points: [
|
||||
"Радиус поиска увеличен в 2-3 раза",
|
||||
"Проверить асфальтированные дороги",
|
||||
"Ограничен рельефом местности",
|
||||
"Искать вдоль ровных путей"
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Не умеет плавать (красная)
|
||||
if (analysisResult.cant_swim) {
|
||||
cards.push({
|
||||
type: "Не плавает",
|
||||
color: "#ff4d00",
|
||||
icon: "🌊",
|
||||
title: "Не умеет плавать",
|
||||
points: [
|
||||
"КРИТИЧНО: проверить водоёмы первыми",
|
||||
"Высокий риск утопления",
|
||||
"Привлечь водолазов",
|
||||
"Обследовать берега и мосты"
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
return cards;
|
||||
};
|
||||
|
||||
const behavioralCards = getBehavioralCards();
|
||||
|
||||
return (
|
||||
<div className="result-panel">
|
||||
<div className="result-header">
|
||||
<h2>Результаты анализа</h2>
|
||||
<div className="result-meta">
|
||||
<span className="execution-time">
|
||||
⏱ {(execution_time_ms / 1000).toFixed(2)}с
|
||||
</span>
|
||||
<span className="services-badge">
|
||||
{services_used.length} сервисов
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Блок срочности */}
|
||||
<div
|
||||
className="urgency-block"
|
||||
style={{
|
||||
borderLeftColor: urgencyInfo.color,
|
||||
background: urgencyInfo.color + "15"
|
||||
}}
|
||||
>
|
||||
<div className="urgency-icon">{urgencyInfo.icon}</div>
|
||||
<div className="urgency-content">
|
||||
<div
|
||||
className="urgency-label"
|
||||
style={{ color: urgencyInfo.color }}
|
||||
>
|
||||
{urgencyInfo.label}
|
||||
</div>
|
||||
<div className="urgency-summary">{summary}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Behavioral Profile карточки */}
|
||||
{behavioralCards.length > 0 && (
|
||||
<div className="behavioral-section">
|
||||
<h3>Поведенческий профиль</h3>
|
||||
<div className="behavioral-cards">
|
||||
{behavioralCards.map((card, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="behavioral-card"
|
||||
style={{ borderLeftColor: card.color }}
|
||||
>
|
||||
<div className="card-header">
|
||||
<span className="card-icon">{card.icon}</span>
|
||||
<span className="card-title">{card.title}</span>
|
||||
</div>
|
||||
<ul className="card-points">
|
||||
{card.points.map((point, i) => (
|
||||
<li key={i}>{point}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Психотип */}
|
||||
{psychotype && (
|
||||
<div className="psychotype-section">
|
||||
<h3>Психотип: {psychotype}</h3>
|
||||
{psychotype_modifiers && (
|
||||
<div className="psychotype-description">
|
||||
{psychotype_modifiers.description}
|
||||
</div>
|
||||
)}
|
||||
{psychotype_recommendations && (
|
||||
<div className="psychotype-recommendations">
|
||||
<div className="recommendation-item">
|
||||
<strong>Приоритетные зоны:</strong> {psychotype_recommendations.priority_zones}
|
||||
</div>
|
||||
<div className="recommendation-item">
|
||||
<strong>Паттерн поиска:</strong> {psychotype_recommendations.search_pattern}
|
||||
</div>
|
||||
<div className="recommendation-item">
|
||||
<strong>Ключевые локации:</strong> {psychotype_recommendations.key_locations}
|
||||
</div>
|
||||
<div className="recommendation-item">
|
||||
<strong>Коммуникация:</strong> {psychotype_recommendations.communication}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Немедленные действия */}
|
||||
{immediate_actions.length > 0 && (
|
||||
<div className="actions-section">
|
||||
<h3>Немедленные действия</h3>
|
||||
<ol className="actions-list">
|
||||
{immediate_actions.slice(0, 3).map((action, index) => (
|
||||
<li key={index} className="action-item">
|
||||
<span className="action-number">{index + 1}</span>
|
||||
<span className="action-text">{action}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Зоны с приоритетом */}
|
||||
{zones.length > 0 && (
|
||||
<div className="zones-section">
|
||||
<h3>Приоритетные зоны поиска</h3>
|
||||
<div className="zones-list">
|
||||
{zones.slice(0, 5).map((zone, index) => (
|
||||
<div key={index} className="zone-item">
|
||||
<div className="zone-header">
|
||||
<span className="zone-priority">#{zone.priority}</span>
|
||||
<span className="zone-name">{zone.name}</span>
|
||||
<span className="zone-score">{zone.score}%</span>
|
||||
</div>
|
||||
<div className="zone-details">
|
||||
<span className="zone-distance">📍 {zone.distance_km.toFixed(2)} км</span>
|
||||
<span className="zone-direction">🧭 {zone.direction}</span>
|
||||
</div>
|
||||
<div className="zone-reasoning">{zone.reasoning}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ключевые объекты */}
|
||||
{key_locations.length > 0 && (
|
||||
<div className="locations-section">
|
||||
<h3>Ключевые объекты для проверки</h3>
|
||||
<div className="locations-tags">
|
||||
{key_locations.map((location, index) => (
|
||||
<span key={index} className="location-tag">
|
||||
{location}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Прогноз поведения */}
|
||||
{behavioral_prediction && (
|
||||
<div className="prediction-section">
|
||||
<h3>Прогноз поведения</h3>
|
||||
<p className="prediction-text">{behavioral_prediction}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResultPanel;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./ResultPanel";
|
||||
@@ -0,0 +1,204 @@
|
||||
import React from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Popup, Polygon } from 'react-leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import L from 'leaflet';
|
||||
|
||||
// Fix default marker icon issue with webpack
|
||||
delete L.Icon.Default.prototype._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
|
||||
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
|
||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
||||
});
|
||||
|
||||
const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones }) => {
|
||||
const tnpPosition = [tnpLat || 53.9, tnpLon || 27.56];
|
||||
const distance = maxDistance || 5.0;
|
||||
|
||||
// Функция для создания точек сектора
|
||||
const createSectorPoints = (centerLat, centerLon, radiusKm, startAngle, endAngle, points = 20) => {
|
||||
const coords = [[centerLat, centerLon]]; // Начинаем с центра
|
||||
|
||||
// Конвертируем км в градусы (приблизительно)
|
||||
const latPerKm = 1 / 111.0;
|
||||
const lonPerKm = 1 / (111.0 * Math.cos(centerLat * Math.PI / 180));
|
||||
|
||||
// Создаем точки дуги
|
||||
for (let i = 0; i <= points; i++) {
|
||||
const angle = startAngle + (endAngle - startAngle) * (i / points);
|
||||
const angleRad = angle * Math.PI / 180;
|
||||
|
||||
const lat = centerLat + radiusKm * latPerKm * Math.cos(angleRad);
|
||||
const lon = centerLon + radiusKm * lonPerKm * Math.sin(angleRad);
|
||||
|
||||
coords.push([lat, lon]);
|
||||
}
|
||||
|
||||
coords.push([centerLat, centerLon]); // Замыкаем в центре
|
||||
return coords;
|
||||
};
|
||||
|
||||
// Направления и их углы
|
||||
const directionAngles = {
|
||||
'N': 0,
|
||||
'NE': 45,
|
||||
'E': 90,
|
||||
'SE': 135,
|
||||
'S': 180,
|
||||
'SW': 225,
|
||||
'W': 270,
|
||||
'NW': 315
|
||||
};
|
||||
|
||||
// Создаем секторы на основе зон
|
||||
const sectors = zones && zones.length > 0 ? zones.slice(0, 8).map((zone, idx) => {
|
||||
const angle = directionAngles[zone.direction] || 0;
|
||||
const startAngle = angle - 22.5; // ±22.5° = 45° сектор
|
||||
const endAngle = angle + 22.5;
|
||||
|
||||
// Определяем радиус на основе score
|
||||
let radius = distance;
|
||||
if (zone.score >= 90) radius = distance * 1.0;
|
||||
else if (zone.score >= 70) radius = distance * 0.8;
|
||||
else if (zone.score >= 50) radius = distance * 0.6;
|
||||
else radius = distance * 0.4;
|
||||
|
||||
// Цвет на основе приоритета
|
||||
let color, fillOpacity;
|
||||
if (idx === 0) {
|
||||
color = '#ff4d00';
|
||||
fillOpacity = 0.35;
|
||||
} else if (idx === 1) {
|
||||
color = '#ff9f1a';
|
||||
fillOpacity = 0.25;
|
||||
} else if (idx === 2) {
|
||||
color = '#ffd166';
|
||||
fillOpacity = 0.2;
|
||||
} else {
|
||||
color = '#8b949e';
|
||||
fillOpacity = 0.1;
|
||||
}
|
||||
|
||||
return {
|
||||
points: createSectorPoints(tnpPosition[0], tnpPosition[1], radius, startAngle, endAngle),
|
||||
color,
|
||||
fillOpacity,
|
||||
zone
|
||||
};
|
||||
}) : [];
|
||||
|
||||
// Если зон нет, создаем базовые концентрические круги
|
||||
const createCirclePoints = (centerLat, centerLon, radiusKm, points = 40) => {
|
||||
const coords = [];
|
||||
const latPerKm = 1 / 111.0;
|
||||
const lonPerKm = 1 / (111.0 * Math.cos(centerLat * Math.PI / 180));
|
||||
|
||||
for (let i = 0; i <= points; i++) {
|
||||
const angle = (i / points) * 2 * Math.PI;
|
||||
const lat = centerLat + radiusKm * latPerKm * Math.cos(angle);
|
||||
const lon = centerLon + radiusKm * lonPerKm * Math.sin(angle);
|
||||
coords.push([lat, lon]);
|
||||
}
|
||||
|
||||
return coords;
|
||||
};
|
||||
|
||||
const defaultZones = [
|
||||
{
|
||||
points: createCirclePoints(tnpPosition[0], tnpPosition[1], distance),
|
||||
color: '#ffd166',
|
||||
fillOpacity: 0.15,
|
||||
label: `Зона 3 (${distance.toFixed(1)} км)`,
|
||||
probability: 10
|
||||
},
|
||||
{
|
||||
points: createCirclePoints(tnpPosition[0], tnpPosition[1], distance * 0.75),
|
||||
color: '#ff9f1a',
|
||||
fillOpacity: 0.2,
|
||||
label: `Зона 2 (${(distance * 0.75).toFixed(1)} км)`,
|
||||
probability: 20
|
||||
},
|
||||
{
|
||||
points: createCirclePoints(tnpPosition[0], tnpPosition[1], distance * 0.5),
|
||||
color: '#ff4d00',
|
||||
fillOpacity: 0.3,
|
||||
label: `Зона 1 (${(distance * 0.5).toFixed(1)} км)`,
|
||||
probability: 70
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={tnpPosition}
|
||||
zoom={12}
|
||||
style={{ width: '100%', height: '500px', borderRadius: '8px' }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution=""
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
|
||||
{/* Рисуем секторы или круги */}
|
||||
{sectors.length > 0 ? (
|
||||
sectors.map((sector, idx) => (
|
||||
<Polygon
|
||||
key={idx}
|
||||
positions={sector.points}
|
||||
pathOptions={{
|
||||
color: sector.color,
|
||||
fillColor: sector.color,
|
||||
fillOpacity: sector.fillOpacity,
|
||||
weight: 2
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<strong>#{idx + 1} {sector.zone.name}</strong><br/>
|
||||
Направление: {sector.zone.direction}<br/>
|
||||
Приоритет: {sector.zone.score}%<br/>
|
||||
{sector.zone.reasoning}
|
||||
</Popup>
|
||||
</Polygon>
|
||||
))
|
||||
) : (
|
||||
defaultZones.map((zone, idx) => (
|
||||
<Polygon
|
||||
key={idx}
|
||||
positions={zone.points}
|
||||
pathOptions={{
|
||||
color: zone.color,
|
||||
fillColor: zone.color,
|
||||
fillOpacity: zone.fillOpacity,
|
||||
weight: 2
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<strong>{zone.label}</strong><br/>
|
||||
Вероятность: {zone.probability}%
|
||||
</Popup>
|
||||
</Polygon>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* ТНП - красный маркер */}
|
||||
<Marker
|
||||
position={tnpPosition}
|
||||
icon={L.icon({
|
||||
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png',
|
||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
shadowSize: [41, 41]
|
||||
})}
|
||||
>
|
||||
<Popup>
|
||||
<strong>ТНП - Точка начала поиска</strong><br/>
|
||||
Координаты: {tnpLat?.toFixed(6)}, {tnpLon?.toFixed(6)}<br/>
|
||||
Максимальная дистанция: {distance.toFixed(1)} км
|
||||
</Popup>
|
||||
</Marker>
|
||||
</MapContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchMap;
|
||||
@@ -0,0 +1,180 @@
|
||||
.case-detail {
|
||||
padding: 32px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
padding: 8px 16px;
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
background: #cbd5e1;
|
||||
}
|
||||
|
||||
.detail-header h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.detail-section h2 {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 20px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.info-item label {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.info-item span {
|
||||
font-size: 16px;
|
||||
color: #1e293b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.diagnosis-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.diagnosis-badge {
|
||||
padding: 8px 16px;
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.psychotype-section {
|
||||
background: linear-gradient(135deg, #dbeafe 0%, #e0e7ff 100%);
|
||||
border-left: 4px solid #3b82f6;
|
||||
}
|
||||
|
||||
.psychotype-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.psychotype-description {
|
||||
font-size: 16px;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.search-tips {
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid #10b981;
|
||||
}
|
||||
|
||||
.search-tips h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 15px;
|
||||
color: #059669;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-tips p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #334155;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.answers-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.answer-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: #f8fafc;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.answer-item label {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.answer-item span {
|
||||
font-size: 14px;
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.case-detail {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.answer-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import './CaseDetail.css';
|
||||
|
||||
const CaseDetail = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [caseData, setCase] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCase();
|
||||
}, [id]);
|
||||
|
||||
const fetchCase = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await axios.get(`/api/v1/cases/${id}`);
|
||||
setCase(response.data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Ошибка загрузки карточки');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return 'Не указано';
|
||||
return new Date(dateString).toLocaleString('ru-RU');
|
||||
};
|
||||
|
||||
const getPsychotypeDescription = (psychotype) => {
|
||||
const descriptions = {
|
||||
'explorer': {
|
||||
title: 'Исследователь',
|
||||
description: 'Активно исследует территорию, может уйти далеко от точки потери',
|
||||
searchTips: 'Расширенный радиус поиска, проверка интересных объектов'
|
||||
},
|
||||
'cautious': {
|
||||
title: 'Осторожный',
|
||||
description: 'Осторожен, держится знакомых мест',
|
||||
searchTips: 'Поиск вблизи точки потери, знакомые маршруты'
|
||||
},
|
||||
'passive': {
|
||||
title: 'Пассивный',
|
||||
description: 'Ждёт помощи, малоподвижен',
|
||||
searchTips: 'Тщательный осмотр укрытий, проверка мест для отдыха'
|
||||
},
|
||||
'panicker': {
|
||||
title: 'Паникёр',
|
||||
description: 'Паникует, может двигаться хаотично',
|
||||
searchTips: 'Проверка опасных мест, водоёмов, обрывов'
|
||||
}
|
||||
};
|
||||
return descriptions[psychotype] || { title: psychotype, description: '', searchTips: '' };
|
||||
};
|
||||
|
||||
if (loading) return <div className="loading-state">Загрузка...</div>;
|
||||
if (error) return <div className="error-state">{error}</div>;
|
||||
if (!caseData) return <div className="error-state">Карточка не найдена</div>;
|
||||
|
||||
const psychotypeInfo = caseData.psychotype_result
|
||||
? getPsychotypeDescription(caseData.psychotype_result)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="case-detail">
|
||||
<div className="detail-header">
|
||||
<button className="btn-back" onClick={() => navigate('/admin/cases')}>
|
||||
← Назад к списку
|
||||
</button>
|
||||
<h1>Карточка #{caseData.id}</h1>
|
||||
</div>
|
||||
|
||||
<div className="detail-content">
|
||||
{/* Основная информация */}
|
||||
<section className="detail-section">
|
||||
<h2>Основная информация</h2>
|
||||
<div className="info-grid">
|
||||
<div className="info-item">
|
||||
<label>Возраст:</label>
|
||||
<span>{caseData.age || '—'} лет</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<label>Пол:</label>
|
||||
<span>{caseData.gender === 'male' ? 'Мужской' : caseData.gender === 'female' ? 'Женский' : '—'}</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<label>Дата создания:</label>
|
||||
<span>{formatDate(caseData.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Диагнозы */}
|
||||
{caseData.diagnosis_type && caseData.diagnosis_type.length > 0 && (
|
||||
<section className="detail-section">
|
||||
<h2>Диагнозы</h2>
|
||||
<div className="diagnosis-list">
|
||||
{caseData.diagnosis_type.map((diagnosis, idx) => (
|
||||
<span key={idx} className="diagnosis-badge">{diagnosis}</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Психотип */}
|
||||
{psychotypeInfo && (
|
||||
<section className="detail-section psychotype-section">
|
||||
<h2>Психотип: {psychotypeInfo.title}</h2>
|
||||
<div className="psychotype-info">
|
||||
<p className="psychotype-description">{psychotypeInfo.description}</p>
|
||||
<div className="search-tips">
|
||||
<h3>Рекомендации по поиску:</h3>
|
||||
<p>{psychotypeInfo.searchTips}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Ответы на вопросы психотипа */}
|
||||
{caseData.psychotype_answers && Object.keys(caseData.psychotype_answers).length > 0 && (
|
||||
<section className="detail-section">
|
||||
<h2>Ответы на вопросы</h2>
|
||||
<div className="answers-list">
|
||||
{caseData.psychotype_answers.unfamiliar_env && (
|
||||
<div className="answer-item">
|
||||
<label>Поведение в незнакомой обстановке:</label>
|
||||
<span>{caseData.psychotype_answers.unfamiliar_env}</span>
|
||||
</div>
|
||||
)}
|
||||
{caseData.psychotype_answers.stress_reaction && (
|
||||
<div className="answer-item">
|
||||
<label>Реакция на стресс:</label>
|
||||
<span>{caseData.psychotype_answers.stress_reaction}</span>
|
||||
</div>
|
||||
)}
|
||||
{caseData.psychotype_answers.group_role && (
|
||||
<div className="answer-item">
|
||||
<label>Роль в группе:</label>
|
||||
<span>{caseData.psychotype_answers.group_role}</span>
|
||||
</div>
|
||||
)}
|
||||
{caseData.psychotype_answers.risk_taking && (
|
||||
<div className="answer-item">
|
||||
<label>Склонность к риску:</label>
|
||||
<span>{caseData.psychotype_answers.risk_taking}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CaseDetail;
|
||||
@@ -0,0 +1,203 @@
|
||||
.cases-list {
|
||||
padding: 32px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.cases-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.cases-header h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 10px 20px;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 10px 20px;
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #cbd5e1;
|
||||
}
|
||||
|
||||
.filters-panel {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.filter-group select,
|
||||
.filter-group input {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.filter-group input[type=number] {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cases-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.cases-table thead {
|
||||
background: #f8fafc;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.cases-table th {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.cases-table td {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
font-size: 14px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.cases-table tbody tr:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.psychotype-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state,
|
||||
.empty-state {
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.error-state {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.cases-footer {
|
||||
margin-top: 20px;
|
||||
padding: 16px 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.cases-footer p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.cases-list {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.cases-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.filters-panel {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.cases-table {
|
||||
min-width: 800px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import './CasesList.css';
|
||||
|
||||
const CasesList = () => {
|
||||
const [cases, setCases] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [filters, setFilters] = useState({
|
||||
gender: '',
|
||||
ageFrom: '',
|
||||
ageTo: '',
|
||||
diagnosis: '',
|
||||
psychotype: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchCases();
|
||||
}, [filters]);
|
||||
|
||||
const fetchCases = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.gender) params.append('gender', filters.gender);
|
||||
if (filters.ageFrom) params.append('age_from', filters.ageFrom);
|
||||
if (filters.ageTo) params.append('age_to', filters.ageTo);
|
||||
if (filters.diagnosis) params.append('diagnosis', filters.diagnosis);
|
||||
if (filters.psychotype) params.append('psychotype', filters.psychotype);
|
||||
|
||||
const response = await axios.get(`/api/v1/cases?${params}`);
|
||||
setCases(response.data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Ошибка загрузки данных');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return 'Не указано';
|
||||
return new Date(dateString).toLocaleDateString('ru-RU');
|
||||
};
|
||||
|
||||
const getPsychotypeLabel = (psychotype) => {
|
||||
const labels = {
|
||||
'explorer': 'Исследователь',
|
||||
'cautious': 'Осторожный',
|
||||
'passive': 'Пассивный',
|
||||
'panicker': 'Паникёр'
|
||||
};
|
||||
return labels[psychotype] || psychotype;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="cases-list">
|
||||
<div className="cases-header">
|
||||
<h1>Карточки пропавших</h1>
|
||||
<div className="header-actions">
|
||||
<button className="btn-primary" onClick={fetchCases}>
|
||||
🔄 Обновить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Фильтры */}
|
||||
<div className="filters-panel">
|
||||
<div className="filter-group">
|
||||
<label>Пол:</label>
|
||||
<select value={filters.gender} onChange={(e) => setFilters({...filters, gender: e.target.value})}>
|
||||
<option value="">Все</option>
|
||||
<option value="male">Мужской</option>
|
||||
<option value="female">Женский</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label>Возраст от:</label>
|
||||
<input
|
||||
type="number"
|
||||
value={filters.ageFrom}
|
||||
onChange={(e) => setFilters({...filters, ageFrom: e.target.value})}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label>до:</label>
|
||||
<input
|
||||
type="number"
|
||||
value={filters.ageTo}
|
||||
onChange={(e) => setFilters({...filters, ageTo: e.target.value})}
|
||||
placeholder="100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label>Диагноз:</label>
|
||||
<select value={filters.diagnosis} onChange={(e) => setFilters({...filters, diagnosis: e.target.value})}>
|
||||
<option value="">Все</option>
|
||||
<option value="РАС">РАС</option>
|
||||
<option value="эпилепсия">Эпилепсия</option>
|
||||
<option value="СДВГ">СДВГ</option>
|
||||
<option value="деменция">Деменция</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button className="btn-secondary" onClick={() => setFilters({
|
||||
gender: '', ageFrom: '', ageTo: '', diagnosis: '', psychotype: ''
|
||||
})}>
|
||||
Сбросить фильтры
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Таблица */}
|
||||
{loading ? (
|
||||
<div className="loading-state">Загрузка...</div>
|
||||
) : error ? (
|
||||
<div className="error-state">{error}</div>
|
||||
) : cases.length === 0 ? (
|
||||
<div className="empty-state">Карточки не найдены</div>
|
||||
) : (
|
||||
<div className="table-container">
|
||||
<table className="cases-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Возраст</th>
|
||||
<th>Пол</th>
|
||||
<th>Диагнозы</th>
|
||||
<th>Психотип</th>
|
||||
<th>Дата создания</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cases.map(caseItem => (
|
||||
<tr key={caseItem.id}>
|
||||
<td>{caseItem.id}</td>
|
||||
<td>{caseItem.age || '—'}</td>
|
||||
<td>{caseItem.gender === 'male' ? 'М' : caseItem.gender === 'female' ? 'Ж' : '—'}</td>
|
||||
<td>
|
||||
{caseItem.diagnosis_type && caseItem.diagnosis_type.length > 0
|
||||
? caseItem.diagnosis_type.join(', ')
|
||||
: '—'}
|
||||
</td>
|
||||
<td>
|
||||
{caseItem.psychotype_result
|
||||
? <span className="psychotype-badge">{getPsychotypeLabel(caseItem.psychotype_result)}</span>
|
||||
: '—'}
|
||||
</td>
|
||||
<td>{formatDate(caseItem.created_at)}</td>
|
||||
<td>
|
||||
<Link to={`/admin/cases/${caseItem.id}`} className="btn-link">
|
||||
Подробнее
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="cases-footer">
|
||||
<p>Всего карточек: <strong>{cases.length}</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CasesList;
|
||||
@@ -0,0 +1,157 @@
|
||||
.heatmap-view {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
|
||||
}
|
||||
|
||||
.heatmap-controls {
|
||||
width: 320px;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
border-right: 1px solid #ddd;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.heatmap-controls h2 {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 1.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.button-group button {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #ccc;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.button-group button:hover {
|
||||
background: #e9e9e9;
|
||||
}
|
||||
|
||||
.button-group button.active {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.control-group select {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.year-range {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.year-range input {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.year-range span {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.control-group input[type="checkbox"] {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.stats-info {
|
||||
padding: 15px;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stats-info p {
|
||||
margin: 5px 0;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.stats-info strong {
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: #007bff;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc3545;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.legend {
|
||||
padding: 15px;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.legend h3 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 1rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.legend-gradient {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.gradient-bar {
|
||||
height: 20px;
|
||||
background: linear-gradient(to right, blue, lime, yellow, red);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Leaflet overrides */
|
||||
.leaflet-container {
|
||||
font-family: inherit;
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import 'leaflet.heat';
|
||||
import './HeatmapView.css';
|
||||
|
||||
const HeatmapView = () => {
|
||||
const mapRef = useRef(null);
|
||||
const mapInstanceRef = useRef(null);
|
||||
const heatLayerRef = useRef(null);
|
||||
|
||||
const [heatmapData, setHeatmapData] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
// Filters
|
||||
const [filterType, setFilterType] = useState('all');
|
||||
const [ageGroup, setAgeGroup] = useState('');
|
||||
const [season, setSeason] = useState('');
|
||||
const [yearFrom, setYearFrom] = useState('');
|
||||
const [yearTo, setYearTo] = useState('');
|
||||
|
||||
// Layer visibility
|
||||
const [showHeatmap, setShowHeatmap] = useState(true);
|
||||
|
||||
// Initialize map
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || mapInstanceRef.current) return;
|
||||
|
||||
// Create map centered on Russia
|
||||
const map = L.map(mapRef.current).setView([55.7558, 37.6173], 5);
|
||||
|
||||
// Add OpenStreetMap tiles
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
maxZoom: 18
|
||||
}).addTo(map);
|
||||
|
||||
mapInstanceRef.current = map;
|
||||
|
||||
return () => {
|
||||
if (mapInstanceRef.current) {
|
||||
mapInstanceRef.current.remove();
|
||||
mapInstanceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fetch heatmap data
|
||||
const fetchHeatmapData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
type: filterType
|
||||
});
|
||||
|
||||
if (filterType === 'age' && ageGroup) {
|
||||
params.append('age_group', ageGroup);
|
||||
}
|
||||
if (filterType === 'season' && season) {
|
||||
params.append('season', season);
|
||||
}
|
||||
if (yearFrom) {
|
||||
params.append('year_from', yearFrom);
|
||||
}
|
||||
if (yearTo) {
|
||||
params.append('year_to', yearTo);
|
||||
}
|
||||
|
||||
const response = await fetch(`http://192.168.0.99:8000/api/v1/stats/heatmap?${params}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch heatmap data');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setHeatmapData(data.points);
|
||||
setTotal(data.total);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update heatmap layer
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current) return;
|
||||
|
||||
// Remove existing heat layer
|
||||
if (heatLayerRef.current) {
|
||||
mapInstanceRef.current.removeLayer(heatLayerRef.current);
|
||||
heatLayerRef.current = null;
|
||||
}
|
||||
|
||||
// Add new heat layer if visible and data exists
|
||||
if (showHeatmap && heatmapData.length > 0) {
|
||||
const heatData = heatmapData.map(point => [point.lat, point.lon, point.intensity]);
|
||||
|
||||
const heatLayer = L.heatLayer(heatData, {
|
||||
radius: 25,
|
||||
blur: 15,
|
||||
maxZoom: 10,
|
||||
max: 1.0,
|
||||
gradient: {
|
||||
0.0: 'blue',
|
||||
0.5: 'lime',
|
||||
0.7: 'yellow',
|
||||
1.0: 'red'
|
||||
}
|
||||
});
|
||||
|
||||
heatLayer.addTo(mapInstanceRef.current);
|
||||
heatLayerRef.current = heatLayer;
|
||||
}
|
||||
}, [heatmapData, showHeatmap]);
|
||||
|
||||
// Load data on mount and filter change
|
||||
useEffect(() => {
|
||||
fetchHeatmapData();
|
||||
}, [filterType, ageGroup, season, yearFrom, yearTo]);
|
||||
|
||||
const handleFilterTypeChange = (type) => {
|
||||
setFilterType(type);
|
||||
// Reset specific filters
|
||||
if (type !== 'age') setAgeGroup('');
|
||||
if (type !== 'season') setSeason('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="heatmap-view">
|
||||
<div className="heatmap-controls">
|
||||
<h2>Тепловая карта находок</h2>
|
||||
|
||||
<div className="control-group">
|
||||
<label>Тип фильтра:</label>
|
||||
<div className="button-group">
|
||||
<button
|
||||
className={filterType === 'all' ? 'active' : ''}
|
||||
onClick={() => handleFilterTypeChange('all')}
|
||||
>
|
||||
Все
|
||||
</button>
|
||||
<button
|
||||
className={filterType === 'age' ? 'active' : ''}
|
||||
onClick={() => handleFilterTypeChange('age')}
|
||||
>
|
||||
По возрасту
|
||||
</button>
|
||||
<button
|
||||
className={filterType === 'season' ? 'active' : ''}
|
||||
onClick={() => handleFilterTypeChange('season')}
|
||||
>
|
||||
По сезону
|
||||
</button>
|
||||
<button
|
||||
className={filterType === 'outcome' ? 'active' : ''}
|
||||
onClick={() => handleFilterTypeChange('outcome')}
|
||||
>
|
||||
По исходу
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filterType === 'age' && (
|
||||
<div className="control-group">
|
||||
<label>Возрастная группа:</label>
|
||||
<select value={ageGroup} onChange={(e) => setAgeGroup(e.target.value)}>
|
||||
<option value="">Все</option>
|
||||
<option value="0-6">0-6 лет</option>
|
||||
<option value="7-10">7-10 лет</option>
|
||||
<option value="11-14">11-14 лет</option>
|
||||
<option value="15-17">15-17 лет</option>
|
||||
<option value="18+">18+ лет</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filterType === 'season' && (
|
||||
<div className="control-group">
|
||||
<label>Сезон:</label>
|
||||
<select value={season} onChange={(e) => setSeason(e.target.value)}>
|
||||
<option value="">Все</option>
|
||||
<option value="зима">Зима</option>
|
||||
<option value="весна">Весна</option>
|
||||
<option value="лето">Лето</option>
|
||||
<option value="осень">Осень</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="control-group">
|
||||
<label>Период:</label>
|
||||
<div className="year-range">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="От года"
|
||||
value={yearFrom}
|
||||
onChange={(e) => setYearFrom(e.target.value)}
|
||||
min="2000"
|
||||
max="2100"
|
||||
/>
|
||||
<span>—</span>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="До года"
|
||||
value={yearTo}
|
||||
onChange={(e) => setYearTo(e.target.value)}
|
||||
min="2000"
|
||||
max="2100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showHeatmap}
|
||||
onChange={(e) => setShowHeatmap(e.target.checked)}
|
||||
/>
|
||||
Показать тепловую карту
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="stats-info">
|
||||
<p>Всего точек: <strong>{total}</strong></p>
|
||||
{loading && <p className="loading">Загрузка...</p>}
|
||||
{error && <p className="error">Ошибка: {error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="legend">
|
||||
<h3>Легенда</h3>
|
||||
<div className="legend-gradient">
|
||||
<span className="legend-label">Низкая плотность</span>
|
||||
<div className="gradient-bar"></div>
|
||||
<span className="legend-label">Высокая плотность</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="map-container">
|
||||
<div ref={mapRef} className="map"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeatmapView;
|
||||
@@ -0,0 +1,98 @@
|
||||
.statistics {
|
||||
padding: 32px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.stats-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.stats-header h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.stat-card.wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 16px;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
color: #3b82f6;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-breakdown {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: #f8fafc;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.stat-item span {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.stat-item strong {
|
||||
font-size: 18px;
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.statistics {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stat-card.wide {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import './Statistics.css';
|
||||
|
||||
const Statistics = () => {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatistics();
|
||||
}, []);
|
||||
|
||||
const fetchStatistics = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await axios.get('/api/v1/stats/summary');
|
||||
setStats(response.data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Ошибка загрузки статистики');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="loading-state">Загрузка...</div>;
|
||||
if (error) return <div className="error-state">{error}</div>;
|
||||
if (!stats) return <div className="error-state">Нет данных</div>;
|
||||
|
||||
return (
|
||||
<div className="statistics">
|
||||
<div className="stats-header">
|
||||
<h1>Статистика</h1>
|
||||
<button className="btn-primary" onClick={fetchStatistics}>
|
||||
🔄 Обновить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid">
|
||||
{/* Общая статистика */}
|
||||
<div className="stat-card">
|
||||
<h3>Всего карточек</h3>
|
||||
<div className="stat-value">{stats.total_cases || 0}</div>
|
||||
</div>
|
||||
|
||||
{/* По полу */}
|
||||
<div className="stat-card">
|
||||
<h3>По полу</h3>
|
||||
<div className="stat-breakdown">
|
||||
<div className="stat-item">
|
||||
<span>Мужской:</span>
|
||||
<strong>{stats.by_gender?.male || 0}</strong>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span>Женский:</span>
|
||||
<strong>{stats.by_gender?.female || 0}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* По возрасту */}
|
||||
<div className="stat-card">
|
||||
<h3>По возрасту</h3>
|
||||
<div className="stat-breakdown">
|
||||
{stats.by_age && Object.entries(stats.by_age).map(([range, count]) => (
|
||||
<div key={range} className="stat-item">
|
||||
<span>{range}:</span>
|
||||
<strong>{count}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* По психотипам */}
|
||||
<div className="stat-card">
|
||||
<h3>По психотипам</h3>
|
||||
<div className="stat-breakdown">
|
||||
{stats.by_psychotype && Object.entries(stats.by_psychotype).map(([type, count]) => (
|
||||
<div key={type} className="stat-item">
|
||||
<span>{type}:</span>
|
||||
<strong>{count}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* По диагнозам */}
|
||||
<div className="stat-card wide">
|
||||
<h3>По диагнозам</h3>
|
||||
<div className="stat-breakdown">
|
||||
{stats.by_diagnosis && Object.entries(stats.by_diagnosis).map(([diagnosis, count]) => (
|
||||
<div key={diagnosis} className="stat-item">
|
||||
<span>{diagnosis}:</span>
|
||||
<strong>{count}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Statistics;
|
||||
@@ -0,0 +1,184 @@
|
||||
@import 'leaflet/dist/leaflet.css';
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/*
|
||||
Цветовые палитры ВЕКТОР согласно §5 контекста
|
||||
Три интерфейса: ЦОУ Десктоп, Мобильный, Админка
|
||||
*/
|
||||
|
||||
/* ЦОУ Десктоп — аналитика, работа часами */
|
||||
:root[data-theme="desktop"],
|
||||
:root {
|
||||
--bg-primary: #0a0c0f;
|
||||
--bg-secondary: #12161b;
|
||||
--text-primary: #e6edf3;
|
||||
--text-secondary: #8b949e;
|
||||
--accent: #ff4d00;
|
||||
--accent-hover: #ff7a33;
|
||||
--zone-1: #ff4d00;
|
||||
--zone-2: #ff9f1a;
|
||||
--zone-3: #ffd166;
|
||||
--success: #2ea043;
|
||||
--warning: #d29922;
|
||||
--danger: #f85149;
|
||||
--border: #30363d;
|
||||
--input-bg: #161b22;
|
||||
--input-border: #30363d;
|
||||
}
|
||||
|
||||
/* Мобильный — быстрое действие под стрессом */
|
||||
:root[data-theme="mobile"] {
|
||||
--bg-primary: #000000;
|
||||
--bg-secondary: #0d1117;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #b1bac4;
|
||||
--accent: #ff3b30;
|
||||
--accent-hover: #ff6b60;
|
||||
--btn-confirm: #34c759;
|
||||
--btn-gps: #0a84ff;
|
||||
--zone-1: #ff3b30;
|
||||
--zone-2: #ff9500;
|
||||
--zone-3: #ffcc00;
|
||||
--success: #34c759;
|
||||
--warning: #ff9500;
|
||||
--danger: #ff3b30;
|
||||
--border: #1c1c1e;
|
||||
--input-bg: #1c1c1e;
|
||||
--input-border: #38383a;
|
||||
|
||||
/* Мобильные кнопки минимум 52px высота */
|
||||
--btn-min-height: 52px;
|
||||
}
|
||||
|
||||
/* Админка — спокойная верификация */
|
||||
:root[data-theme="admin"] {
|
||||
--bg-primary: #0d1117;
|
||||
--bg-secondary: #161b22;
|
||||
--text-primary: #e6edf3;
|
||||
--text-secondary: #8b949e;
|
||||
--accent: #2f81f7;
|
||||
--accent-hover: #58a6ff;
|
||||
--chart-distance: #2f81f7;
|
||||
--chart-terrain: #3fb950;
|
||||
--chart-outcome: #f85149;
|
||||
--success: #3fb950;
|
||||
--warning: #d29922;
|
||||
--danger: #f85149;
|
||||
--border: #30363d;
|
||||
--input-bg: #161b22;
|
||||
--input-border: #30363d;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* Базовые стили для всех интерфейсов */
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
font-family: inherit;
|
||||
background-color: var(--input-bg);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
input:focus, textarea:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Мобильные кнопки */
|
||||
[data-theme="mobile"] button {
|
||||
min-height: var(--btn-min-height);
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Кнопки подтверждения */
|
||||
.btn-primary {
|
||||
background-color: var(--accent);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: var(--success);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--danger);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Мобильная кнопка GPS */
|
||||
[data-theme="mobile"] .btn-gps {
|
||||
background-color: var(--btn-gps);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Мобильная кнопка подтверждения */
|
||||
[data-theme="mobile"] .btn-confirm {
|
||||
background-color: var(--btn-confirm);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Карточки */
|
||||
.card {
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Зоны поиска */
|
||||
.zone-priority-1 {
|
||||
color: var(--zone-1);
|
||||
border-color: var(--zone-1);
|
||||
}
|
||||
|
||||
.zone-priority-2 {
|
||||
color: var(--zone-2);
|
||||
border-color: var(--zone-2);
|
||||
}
|
||||
|
||||
.zone-priority-3 {
|
||||
color: var(--zone-3);
|
||||
border-color: var(--zone-3);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,332 @@
|
||||
/* Админка — палитра из §5 контекста */
|
||||
.admin-dashboard {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-nav {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.admin-nav button {
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.admin-nav button:hover {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.admin-nav button.active {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stat-change {
|
||||
font-size: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.stat-change.positive {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.stat-change.negative {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.cases-table {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 8px 16px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
thead th {
|
||||
padding: 12px 20px;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: 16px 20px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-badge.active {
|
||||
background: var(--warning);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.status-badge.closed {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.archived {
|
||||
background: var(--text-secondary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Графики */
|
||||
.chart-container {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.chart-container h3 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Цвета для графиков из палитры админки */
|
||||
.chart-distance {
|
||||
color: var(--chart-distance);
|
||||
}
|
||||
|
||||
.chart-terrain {
|
||||
color: var(--chart-terrain);
|
||||
}
|
||||
|
||||
.chart-outcome {
|
||||
color: var(--chart-outcome);
|
||||
}
|
||||
|
||||
/* Форма загрузки документов */
|
||||
.upload-zone {
|
||||
background: var(--bg-secondary);
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.upload-zone:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.upload-zone.dragging {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 48px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.admin-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.admin-nav {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-nav button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.table-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
thead th,
|
||||
tbody td {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||
import CasesList from '../components/admin/CasesList';
|
||||
import CaseDetail from '../components/admin/CaseDetail';
|
||||
import HeatmapView from '../components/admin/HeatmapView';
|
||||
import Statistics from '../components/admin/Statistics';
|
||||
import './AdminDashboard.css';
|
||||
|
||||
const AdminDashboard = () => {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<div className="admin-dashboard">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<h1>SAR МЧС</h1>
|
||||
<p className="subtitle">Система анализа поисков</p>
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
<Link
|
||||
to="/admin/cases"
|
||||
className={location.pathname.startsWith('/admin/cases') ? 'active' : ''}
|
||||
>
|
||||
<span className="icon">📋</span>
|
||||
Карточки
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/heatmap"
|
||||
className={location.pathname === '/admin/heatmap' ? 'active' : ''}
|
||||
>
|
||||
<span className="icon">🗺️</span>
|
||||
Тепловая карта
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/statistics"
|
||||
className={location.pathname === '/admin/statistics' ? 'active' : ''}
|
||||
>
|
||||
<span className="icon">📊</span>
|
||||
Статистика
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<a href="/mobile" target="_blank" rel="noopener noreferrer" className="mobile-link">
|
||||
<span className="icon">📱</span>
|
||||
Мобильная форма
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="main-content">
|
||||
<Routes>
|
||||
<Route path="/" element={<CasesList />} />
|
||||
<Route path="/cases" element={<CasesList />} />
|
||||
<Route path="/cases/:id" element={<CaseDetail />} />
|
||||
<Route path="/heatmap" element={<HeatmapView />} />
|
||||
<Route path="/statistics" element={<Statistics />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDashboard;
|
||||
@@ -0,0 +1,58 @@
|
||||
.desktop-sar {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.sar-header {
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sar-header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sar-header .logo {
|
||||
color: var(--accent);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.sar-status {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--success);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.desktop-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import CaseForm from "../components/CaseForm";
|
||||
import "../pages/DesktopSAR.css";
|
||||
|
||||
const DesktopSARPage = () => {
|
||||
return (
|
||||
<div className="desktop-sar">
|
||||
<div className="sar-header">
|
||||
<h1><span className="logo">ВЕКТОР</span> ЦОУ</h1>
|
||||
<div className="sar-status">
|
||||
<div className="status-indicator">
|
||||
<span className="status-dot"></span>
|
||||
Система активна
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="desktop-content">
|
||||
<CaseForm isMobile={false} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DesktopSARPage;
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
import CaseForm from "../components/CaseForm";
|
||||
|
||||
const MobileFormPage = () => {
|
||||
return (
|
||||
<div className="mobile-page">
|
||||
<CaseForm isMobile={true} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileFormPage;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user