import React, { useState, useEffect } from "react";
import { useParams } from "react-router-dom";
import SearchMap from '../../components/SearchMap';
import { apiFetch } from "../../api/client";
import { LOCATION_TYPES, WHO_FOUND_TYPES, FOUND_DIRECTIONS } from "../../constants";
import "./AnalysisResult.css";
const GENDER_LABEL = { m: 'М', f: 'Ж' };
const DIRECTION_RU = { N: 'С', NE: 'СВ', E: 'В', SE: 'ЮВ', S: 'Ю', SW: 'ЮЗ', W: 'З', NW: 'СЗ' };
// Маппинг уровня срочности бэкенда → CSS-класс (фронтовые классы в Cyrillic)
const urgencyClass = (urgency) => {
const u = (urgency || '').toLowerCase();
if (u.includes('крит')) return 'критический';
if (u.includes('высок')) return 'высокий';
if (u.includes('сред')) return 'средний';
return 'стандартный';
};
// Адаптер зон бэкенда (PrimaryZone: priority/name/direction/distance/reason)
// → формат SearchMap (нужен score для радиуса и reasoning для попапа).
// score выводим из приоритета — даёт визуальный градиент (топ-зоны крупнее),
// как в прежней синтетической версии.
const adaptZones = (primaryZones) => (primaryZones || []).map(z => ({
priority: z.priority,
name: z.name,
direction: z.direction,
distance: `${z.distance} км`,
score: Math.max(10, 100 - ((z.priority || 1) - 1) * 10),
reasoning: z.reason || '',
}));
const AnalysisResult = () => {
const { caseId } = useParams();
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [caseData, setCaseData] = useState(null);
const [analysisData, setAnalysisData] = useState(null);
const [waterData, setWaterData] = useState(null);
const [operation, setOperation] = useState(null);
const [contourId, setContourId] = useState('');
const [contourStatus, setContourStatus] = useState(null); // {ok, message}
const [showComplete, setShowComplete] = useState(false);
const [completeForm, setCompleteForm] = useState({
found_alive: 'true', found_distance_km: '', found_direction: '',
found_location_type: '', search_duration_hours: '', who_found: '', notes: '',
});
const [completing, setCompleting] = useState(false);
const [completeResult, setCompleteResult] = useState(null);
useEffect(() => {
const loadCaseAndAnalyze = async () => {
try {
setLoading(true);
setError(null);
// 1. Карточка случая — для шапки и обстоятельств
const caseResponse = await apiFetch(`/api/v1/cases/${caseId}`);
if (!caseResponse.ok) throw new Error("Не удалось загрузить карточку");
const caseResult = await caseResponse.json();
setCaseData(caseResult);
// E4: операция этого кейса (для КОНТУР-отправки)
try {
const opsResp = await apiFetch(`/api/v1/operations?case_id=${caseId}`);
if (opsResp.ok) {
const opsData = await opsResp.json();
if (opsData.items?.length) {
setOperation(opsData.items[0]);
setContourId(opsData.items[0].contour_operation_id || '');
}
}
} catch { /* операции может не быть — панель скрыта */ }
// 2. Анализ — единый источник правды на бэкенде
const analyzeResponse = await apiFetch(`/api/v1/analyze`, {
method: 'POST',
body: JSON.stringify({ case_id: caseId }),
});
if (!analyzeResponse.ok) throw new Error("Не удалось запустить анализ");
const analysis = await analyzeResponse.json();
setAnalysisData(analysis);
// 3. Водоёмы вокруг ТНП (слой на карте; 404/409 — нет координат, не ошибка)
try {
const waterResponse = await apiFetch(`/api/v1/water/${caseId}`);
if (waterResponse.ok) setWaterData(await waterResponse.json());
} catch {
/* водоёмы — опциональный слой, не блокируют анализ */
}
} catch (err) {
console.error("Error loading case:", err);
setError(err.message);
} finally {
setLoading(false);
}
};
if (caseId) {
loadCaseAndAnalyze();
}
}, [caseId]);
if (loading) {
return (
Загрузка данных и расчёт зон поиска...
);
}
if (error) {
return (
Ошибка загрузки
{error}
);
}
if (!caseData) {
return (
Карточка не найдена
ID: {caseId}
);
}
const zones = adaptZones(analysisData?.primary_zones);
const coeffs = analysisData?.coefficients || {};
const psyMods = analysisData?.psychotype_modifiers;
const psyRec = analysisData?.psychotype_recommendations;
const genderLabel = GENDER_LABEL[caseData.gender] || caseData.gender || '';
const urgencyLevel = urgencyClass(analysisData?.urgency);
const profiles = analysisData?.active_profiles || [];
const warnings = analysisData?.critical_warnings || [];
const unmodeled = analysisData?.unmodeled_profiles || [];
const tnpLat = caseData.tnp_lat || caseData.gps_lat;
const tnpLon = caseData.tnp_lon || caseData.gps_lon;
const waterCount = (waterData?.features || []).filter(f => f.layer !== 'railway').length;
const railCount = (waterData?.features || []).filter(f => f.layer === 'railway').length;
// Коэффициенты формулы Экстремум — компактные чипы (название: значение)
const coeffChips = [
{ label: 'Время', value: `${caseData.elapsed_hours || 1} ч`, hint: 'от потери' },
{ label: 'НормС', value: coeffs.base_speed, hint: `баз. скорость, ${caseData.age_years} лет` },
{ label: 'СП', value: coeffs.terrain, hint: caseData.terrain?.join(', ') || 'местность' },
{ label: 'СКД', value: coeffs.diagnosis, hint: caseData.diagnosis_type?.join(', ') || 'без диагноза' },
{ label: 'СУ', value: coeffs.urgency, hint: caseData.has_transport && caseData.has_transport !== 'none' ? caseData.has_transport : 'без транспорта' },
{ label: 'СУТ', value: coeffs.fatigue?.toFixed(2), hint: 'усталость' },
{ label: 'ВВС', value: coeffs.time_of_day, hint: analysisData?.time_of_day || 'время суток' },
{ label: 'ВП', value: coeffs.weather, hint: caseData.weather || caseData.precipitation || 'без осадков' },
];
// Средний ряд: карта + зоны рядом
const hasMap = tnpLat && tnpLon;
return (
{/* ===== Шапка: кейс + срочность + радиус ===== */}
ВЕКТОР Результаты анализа
{caseData.child_name || 'Ребёнок'}, {caseData.age_years} лет{genderLabel ? ` (${genderLabel})` : ''}
•
Время потери: {caseData.loss_time || 'не указано'}
•
Прошло: {caseData.elapsed_hours || 1} ч
{analysisData && (
<>
•
Локальный расчёт
>
)}
{/* Панель решения: срочность | радиус | действия */}
Срочность
{analysisData?.urgency || '—'}
Радиус поиска
{analysisData?.max_distance_km} км
{analysisData?.distance_multiplier !== 1 && (
×{analysisData?.distance_multiplier}
)}
Критические действия
{warnings.length > 0 ? (
{warnings.map((w, i) => - {w.warning}
)}
) : (
Нет критических предупреждений
)}
{analysisData?.summary &&
{analysisData.summary}
}
{/* ===== Ряд 1: Карта + зоны ===== */}
Карта зон поиска
{(waterData?.count > 0) && (
💧 Водоёмы: {waterCount}
{railCount > 0 && 🚂 Ж/д: {railCount}}
)}
{waterData?.warning && (
🚨 {waterData.warning}
)}
{waterData?.railway_warning && railCount > 0 && (
🚂 {waterData.railway_warning}
)}
{hasMap ? (
) : (
ТНП не задана — карта недоступна
)}
{operation && (
🔗 КОНТУР (полевая координация)
setContourId(e.target.value)}
/>
{contourStatus && (
{contourStatus.message}
)}
)}
{operation && operation.status !== 'completed' && (
Внесение исхода → кейс в архив + прогон матмодели
)}
{operation && operation.status === 'completed' && (
✓ Поиск завершён {operation.closed_at ? new Date(operation.closed_at).toLocaleString('ru-RU') : ''} — запись в архиве завершённых
)}
ТНП: {tnpLat ? Number(tnpLat).toFixed(5) : '—'}, {tnpLon ? Number(tnpLon).toFixed(5) : '—'}
{caseData.tnp_address && ·}
{caseData.tnp_address && {caseData.tnp_address}}
Зоны поиска
{zones.length} зон
{zones.slice(0, 8).map((zone, idx) => (
{idx + 1}
{zone.name}
{DIRECTION_RU[zone.direction] || zone.direction}
{zone.distance}
{zone.score}%
{zone.reasoning &&
{zone.reasoning}
}
))}
{zones.length === 0 &&
Зоны не рассчитаны
}
{/* ===== Ряд 2: Поведение + действия ===== */}
Поведение и профиль
{(profiles.length > 0 || unmodeled.length > 0) && (
{profiles.map((p, i) => {p.name})}
{unmodeled.map((u, i) => {u.profile}*)}
)}
{analysisData?.behavioral_prediction && (
{analysisData.behavioral_prediction}
)}
{psyRec && (
Приоритетные зоны
{psyRec.priority_zones}
Паттерн поиска
{psyRec.search_pattern}
Ключевые объекты
{psyRec.key_locations}
Связь
{psyRec.communication}
)}
{unmodeled.length > 0 && (
{unmodeled.map((u, i) => (
• {u.profile}: {u.note}
))}
)}
Немедленные действия
{(analysisData?.immediate_actions || []).map((a, i) => (
- {a}
))}
{/* ===== Ряд 3: Обоснование радиуса (формула Экстремум) ===== */}
{analysisData && (
Обоснование радиуса
Distance = Time × НормС × СП × СКД × СУ × СУТ × ВВС × ВП
{coeffChips.map((c, i) => (
{c.label} {c.value}
{c.hint}
))}
{psyMods && (
Психотип {psychoRu(analysisData?.psychotype)} — поправки полос:
{['0–500 м', '500–1500 м', '1500–2500 м', '2500 м+'].map((band, i) => (
{band}: ×{[psyMods.zone_0_500, psyMods.zone_500_1500, psyMods.zone_1500_2500, psyMods.zone_2500plus][i]}
))}
)}
)}
{/* ===== Ряд 4: Справочные данные (3 колонки) ===== */}
Обстоятельства потери
Причина: {caseData.reason || caseData.loss_reason || 'не указана'}
Последнее известное место: {caseData.last_known_place || 'не указано'}
{caseData.direction &&
Направление движения: {caseData.direction}
}
{caseData.behavior_description &&
Описание: {caseData.behavior_description}
}
Здоровье и особенности
{caseData.has_diagnosis ? (
Диагнозы: {caseData.diagnosis_type?.join(', ')}
) : (
Диагнозы: нет
)}
{caseData.fitness_level &&
Физподготовка: {caseData.fitness_level}
}
{caseData.cant_swim &&
⚠️ Не умеет плавать — приоритет ближайшим водоёмам
}
{caseData.has_transport && caseData.has_transport !== 'none' && (
Транспорт: {caseData.has_transport}
)}
Условия среды
Сезон: {caseData.season || 'не указан'}
{caseData.temperature_c != null &&
Температура: {caseData.temperature_c}°C
}
Осадки: {caseData.weather || caseData.precipitation || 'нет'}
{caseData.visibility &&
Видимость: {caseData.visibility}
}
{showComplete && (
setShowComplete(false)}>
e.stopPropagation()}>
Завершение поиска
Исход вносится в карточку, кейс уходит в архив завершённых, операция помечается завершённой.
{completeResult && (
{completeResult.message}
)}
)}
);
};
// РУ-названия психотипов (psychotype_service отдаёт slug'и)
const PSY_RU = {
dominant: 'Доминантный',
harmonic: 'Гармоничный',
anxious: 'Тревожный',
introvert_passive: 'Интроверт пассивный',
introvert_active: 'Интроверт активный',
};
const psychoRu = (p) => PSY_RU[p] || p || '';
// Класс вёрстки главного ряда: карта+зоны / только зоны
function hasMapClass(hasMap) {
return hasMap ? 'with-map' : 'no-map';
}
export default AnalysisResult;