Files
vector/frontend/src/pages/analysis/AnalysisResult.jsx
T

418 lines
18 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from "react";
import { useParams } from "react-router-dom";
import SearchMap from '../../components/SearchMap';
import "./AnalysisResult.css";
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);
useEffect(() => {
const loadCaseAndAnalyze = async () => {
try {
setLoading(true);
setError(null);
const caseResponse = await fetch(`/api/v1/cases/${caseId}`);
if (!caseResponse.ok) {
throw new Error("Не удалось загрузить карточку");
}
const caseResult = await caseResponse.json();
setCaseData(caseResult);
const analysis = calculateFullDistance(caseResult);
setAnalysisData(analysis);
} catch (err) {
console.error("Error loading case:", err);
setError(err.message);
} finally {
setLoading(false);
}
};
if (caseId) {
loadCaseAndAnalyze();
}
}, [caseId]);
const getTimeOfDay = (lostTime) => {
if (!lostTime) return 'день';
// Значение может прийти как "YYYY-MM-DDTHH:MM" (input type="datetime-local")
// или как "HH:MM" — берём часы из обоих форматов.
const timePart = lostTime.includes('T') ? lostTime.split('T')[1] : lostTime;
const hour = parseInt(timePart.split(':')[0]);
if (hour >= 6 && hour < 18) return 'день';
if (hour >= 18 && hour < 22) return 'сумерки';
return 'ночь';
};
const getPsychotypeModifiers = (psychotype) => {
const modifiers = {
'dominant': {
zone_0_500: 0.7,
zone_500_1500: 1.2,
zone_1500_2500: 1.4,
zone_2500plus: 1.1,
description: 'Доминантный: активное движение, большие расстояния'
},
'harmonic': {
zone_0_500: 0.8,
zone_500_1500: 1.0,
zone_1500_2500: 1.1,
zone_2500plus: 0.9,
description: 'Гармоничный: рациональное движение по ориентирам'
},
'anxious': {
zone_0_500: 1.4,
zone_500_1500: 1.1,
zone_1500_2500: 0.5,
zone_2500plus: 0.3,
description: 'Тревожный: минимальное движение, остается близко'
},
'introvert_passive': {
zone_0_500: 1.3,
zone_500_1500: 0.9,
zone_1500_2500: 0.6,
zone_2500plus: 0.2,
description: 'Интроверт пассивный: прячется, минимальное движение'
},
'introvert_active': {
zone_0_500: 0.8,
zone_500_1500: 1.1,
zone_1500_2500: 1.2,
zone_2500plus: 0.9,
description: 'Интроверт активный: целенаправленное движение'
}
};
return modifiers[psychotype] || modifiers['harmonic'];
};
const calculateFullDistance = (caseData) => {
const age = caseData.age_years || 10;
const elapsed = caseData.elapsed_hours || 1;
const terrain = caseData.terrain?.[0] || 'лес';
const timeOfDay = getTimeOfDay(caseData.lost_time);
const weather = caseData.precipitation || 'нет';
// НормС - Скорость смещения потерявшегося ребёнка (км/ч)
// Не скорость ходьбы, а реальное смещение: испуг, кружение, остановки
// Основа: ПСО ЭКСТРЕМУМ, 400 случаев — 94% найдены в пределах 3 км
let baseSpeed = 1.5;
if (age <= 2) baseSpeed = 0.3;
else if (age <= 5) baseSpeed = 0.7;
else if (age <= 8) baseSpeed = 1.2;
else if (age <= 12) baseSpeed = 1.5;
else if (age <= 15) baseSpeed = 2.0;
else if (age <= 17) baseSpeed = 2.5;
else if (age <= 64) baseSpeed = 2.5;
else baseSpeed = 1.5;
// СП - Коэффициент местности
let terrainCoef = 0.5;
const terrainLower = terrain.toLowerCase();
if (terrainLower.includes('дорог') || terrainLower.includes('троп')) terrainCoef = 0.8;
else if (terrainLower.includes('поле') || terrainLower.includes('луг')) terrainCoef = 0.9;
else if (terrainLower.includes('болот')) terrainCoef = 0.2;
else if (terrainLower.includes('лес')) terrainCoef = 0.5;
else if (terrainLower.includes('населённый')) terrainCoef = 1.0;
else if (terrainLower.includes('овраг')) terrainCoef = 0.3;
// СКД - Коэффициент диагноза (влияние на физическую способность двигаться)
let diagnosisCoef = 1.0;
const diagTypes = (caseData.diagnosis_type || [])
.filter(d => d && d !== 'нет' && d !== 'none')
.map(d => d.toLowerCase());
if (caseData.has_diagnosis && diagTypes.length > 0) {
if (diagTypes.some(d => d.includes('эпилепси'))) diagnosisCoef = 0.6;
else if (diagTypes.some(d => d.includes('зпр'))) diagnosisCoef = 0.8;
else if (diagTypes.some(d => d.includes('рас') || d.includes('аутизм'))) diagnosisCoef = 1.0;
else if (diagTypes.some(d => d.includes('сдвг'))) diagnosisCoef = 1.1;
else diagnosisCoef = 0.9;
}
// СУ - Коэффициент транспорта
// 'none' — это строка, проверяем явно чтобы не получить false positive
let urgencyCoef = 1.0;
if (caseData.has_transport && caseData.has_transport !== 'none') {
if (caseData.has_transport === 'bike') urgencyCoef = 5.0;
else if (caseData.has_transport === 'car') urgencyCoef = 8.0;
else urgencyCoef = 3.0;
}
// СУТ - Коэффициент усталости
const fatigueCoef = Math.max(0.3, 1.0 - (0.05 * elapsed));
// ВВС - Коэффициент времени суток
let timeCoef = 1.0;
if (timeOfDay === 'ночь') timeCoef = 0.5;
else if (timeOfDay === 'сумерки') timeCoef = 0.7;
// ВП - Коэффициент погоды
let weatherCoef = 1.0;
const weatherLower = weather.toLowerCase();
if (weatherLower.includes('дождь')) weatherCoef = 0.8;
else if (weatherLower.includes('снег')) weatherCoef = 0.6;
else if (weatherLower.includes('туман')) weatherCoef = 0.7;
// Полная формула
const distance = elapsed * baseSpeed * terrainCoef * diagnosisCoef * urgencyCoef * fatigueCoef * timeCoef * weatherCoef;
// Получаем модификаторы психотипа
const psychotypeModifiers = caseData.psychotype ? getPsychotypeModifiers(caseData.psychotype) : null;
return {
max_distance_km: distance.toFixed(2),
coefficients: {
base_speed: baseSpeed,
terrain: terrainCoef,
diagnosis: diagnosisCoef,
urgency: urgencyCoef,
fatigue: fatigueCoef,
time_of_day: timeCoef,
weather: weatherCoef
},
time_of_day: timeOfDay,
psychotype_modifiers: psychotypeModifiers,
zones: generateZonesWithPsychotype(distance, caseData, psychotypeModifiers)
};
};
const generateZonesWithPsychotype = (maxDist, caseData, psychotypeModifiers) => {
const directions = [
{ dir: 'N', name: 'Север' },
{ dir: 'NE', name: 'Северо-Восток' },
{ dir: 'E', name: 'Восток' },
{ dir: 'SE', name: 'Юго-Восток' },
{ dir: 'S', name: 'Юг' },
{ dir: 'SW', name: 'Юго-Запад' },
{ dir: 'W', name: 'Запад' },
{ dir: 'NW', name: 'Северо-Запад' }
];
const preferredDir = caseData.direction;
return directions.map((d, i) => {
let score = 100 - (i * 10);
// Применяем модификатор направления
if (preferredDir && d.dir === preferredDir) {
score += 30;
}
// Применяем модификатор психотипа в зависимости от дистанции
if (psychotypeModifiers) {
const distKm = maxDist * 0.75; // средняя дистанция сектора
let psychoMod = 1.0;
if (distKm < 0.5) psychoMod = psychotypeModifiers.zone_0_500;
else if (distKm < 1.5) psychoMod = psychotypeModifiers.zone_500_1500;
else if (distKm < 2.5) psychoMod = psychotypeModifiers.zone_1500_2500;
else psychoMod = psychotypeModifiers.zone_2500plus;
score = score * psychoMod;
}
let reasoning = 'Расчет по алгоритму Экстремум';
if (preferredDir === d.dir) reasoning += ' (указанное направление)';
if (psychotypeModifiers) reasoning += ` (психотип учтен)`;
return {
priority: i + 1,
name: `Сектор ${d.name}`,
direction: d.dir,
distance: `${(maxDist * 0.5).toFixed(1)}-${maxDist.toFixed(1)} км`,
score: Math.min(100, Math.round(score)),
reasoning
};
}).sort((a, b) => b.score - a.score);
};
if (loading) {
return (
<div className="analysis-result">
<div className="loading-state">
<div className="spinner"></div>
<p>Загрузка данных и расчет зон поиска...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="analysis-result">
<div className="error-state">
<h2>Ошибка загрузки</h2>
<p>{error}</p>
<button onClick={() => window.location.reload()}>Попробовать снова</button>
</div>
</div>
);
}
if (!caseData) {
return (
<div className="analysis-result">
<div className="error-state">
<h2>Карточка не найдена</h2>
<p>ID: {caseId}</p>
</div>
</div>
);
}
const urgencyLevel = caseData.elapsed_hours > 6 ? "ВЫСОКИЙ" : caseData.elapsed_hours > 3 ? "СРЕДНИЙ" : "СТАНДАРТНЫЙ";
const urgencyReason = `Прошло ${caseData.elapsed_hours || 1} часов. ${caseData.has_transport ? `Транспорт: ${caseData.has_transport} - расширенный радиус.` : ''} ${caseData.has_diagnosis ? 'Есть диагноз - требуется особое внимание.' : ''}`;
const zones = analysisData?.zones || [];
const coeffs = analysisData?.coefficients || {};
return (
<div className="analysis-result">
<div className="result-header">
<h1><span className="logo">ВЕКТОР</span> Результаты анализа</h1>
<div className="case-info">
<span>{caseData.child_name || 'Ребенок'}, {caseData.age_years} лет ({caseData.gender})</span>
<span className="separator">•</span>
<span>Время потери: {caseData.lost_time || 'не указано'}</span>
<span className="separator">•</span>
<span>Прошло: {caseData.elapsed_hours || 1} ч</span>
</div>
</div>
<div className="result-content">
{/* Блок 1: Уровень срочности */}
<div className={`urgency-block urgency-${urgencyLevel.toLowerCase()}`}>
<div className="urgency-level">{urgencyLevel}</div>
<div className="urgency-reason">{urgencyReason}</div>
</div>
{/* Блок 2: Расчет по алгоритму Экстремум */}
<div className="info-card">
<h3>📏 Расчет по алгоритму Экстремум</h3>
<div className="distance-info">
<div className="distance-value">{analysisData?.max_distance_km} км</div>
<div className="distance-details">
<p><strong>Формула:</strong> Distance = Time × НормС × СП × СКД × СУ × СУТ × ВВС × ВП</p>
<hr style={{margin: '8px 0', border: 'none', borderTop: '1px solid #30363d'}} />
<p><strong>Время (Time):</strong> {caseData.elapsed_hours || 1} ч</p>
<p><strong>Базовая скорость (НормС):</strong> {coeffs.base_speed} км/ч (возраст {caseData.age_years} лет)</p>
<p><strong>Местность (СП):</strong> {coeffs.terrain} ({caseData.terrain?.join(', ') || 'не указана'})</p>
<p><strong>Диагноз (СКД):</strong> {coeffs.diagnosis} {caseData.has_diagnosis ? `(${caseData.diagnosis_type?.join(', ')})` : '(нет)'}</p>
<p><strong>Транспорт (СУ):</strong> {coeffs.urgency} {caseData.has_transport ? `(${caseData.has_transport})` : '(нет)'}</p>
<p><strong>Усталость (СУТ):</strong> {coeffs.fatigue?.toFixed(2)} (снижение 5%/час)</p>
<p><strong>Время суток (ВВС):</strong> {coeffs.time_of_day} ({analysisData?.time_of_day})</p>
<p><strong>Погода (ВП):</strong> {coeffs.weather} ({caseData.precipitation || 'нет'})</p>
</div>
</div>
</div>
{/* Блок 3: Психотип */}
{caseData.psychotype && analysisData?.psychotype_modifiers && (
<div className="info-card">
<h3>🧠 Психотип и модель поведения</h3>
<p><strong>Психотип:</strong> {caseData.psychotype}</p>
<p><strong>Модель движения:</strong> {analysisData.psychotype_modifiers.description}</p>
<div style={{marginTop: '12px', padding: '12px', background: '#161b22', borderRadius: '6px'}}>
<p style={{fontSize: '14px', color: '#8b949e', margin: 0}}>
<strong>Модификаторы зон:</strong>
</p>
<p style={{fontSize: '13px', color: '#8b949e', margin: '4px 0 0 0'}}>
• 0-500м: ×{analysisData.psychotype_modifiers.zone_0_500}<br/>
• 500-1500м: ×{analysisData.psychotype_modifiers.zone_500_1500}<br/>
• 1500-2500м: ×{analysisData.psychotype_modifiers.zone_1500_2500}<br/>
• 2500м+: ×{analysisData.psychotype_modifiers.zone_2500plus}
</p>
</div>
</div>
)}
{/* Блок 4: Обстоятельства */}
<div className="info-card">
<h3>📋 Обстоятельства потери</h3>
<p><strong>Причина:</strong> {caseData.reason || 'не указана'}</p>
<p><strong>Последнее известное место:</strong> {caseData.last_known_place || 'не указано'}</p>
{caseData.direction && <p><strong>Направление движения:</strong> {caseData.direction}</p>}
{caseData.circumstances_description && (
<p><strong>Описание:</strong> {caseData.circumstances_description}</p>
)}
</div>
{/* Блок 5: Зоны поиска */}
<div className="zones-section">
<h2>Приоритетные зоны поиска</h2>
<div className="zones-list">
{zones.slice(0, 8).map((zone, idx) => (
<div key={idx} className={`zone-card priority-${Math.min(idx + 1, 3)}`}>
<div className="zone-header">
<div className="zone-title">
<span className="zone-priority">#{idx + 1}</span>
<span className="zone-name">{zone.name}</span>
</div>
<div className="zone-score">{zone.score}%</div>
</div>
<div className="zone-details">
<span><strong>Направление:</strong> {zone.direction}</span>
<span className="separator">•</span>
<span><strong>Дистанция:</strong> {zone.distance}</span>
</div>
<div className="zone-reasoning">{zone.reasoning}</div>
</div>
))}
</div>
</div>
{/* Блок 6: Карта */}
{caseData.tnp_lat && caseData.tnp_lon && (
<div className="map-section">
<h2>Карта зон поиска</h2>
<SearchMap zones={zones}
maxDistance={parseFloat(analysisData?.max_distance_km)}
tnpLat={parseFloat(caseData.tnp_lat)}
tnpLon={parseFloat(caseData.tnp_lon)}
/>
<div className="map-info" style={{ marginTop: "12px", fontSize: "14px", color: "#8b949e" }}>
<p>Координаты ТНП: {caseData.tnp_lat}, {caseData.tnp_lon}</p>
{caseData.tnp_address && <p>Адрес: {caseData.tnp_address}</p>}
</div>
</div>
)}
{/* Блок 7: Здоровье и особенности */}
{(caseData.has_diagnosis || caseData.cant_swim || caseData.fitness_level) && (
<div className="info-card">
<h3>🏥 Здоровье и особенности</h3>
{caseData.has_diagnosis && (
<p><strong>Диагнозы:</strong> {caseData.diagnosis_type?.join(', ') || 'есть'}</p>
)}
{caseData.fitness_level && (
<p><strong>Физическая подготовка:</strong> {caseData.fitness_level}</p>
)}
{caseData.cant_swim && <p>⚠️ <strong>Не умеет плавать</strong></p>}
{caseData.has_transport && (
<p><strong>Транспорт:</strong> {caseData.has_transport}</p>
)}
</div>
)}
{/* Блок 8: Условия среды */}
<div className="info-card">
<h3>🌤 Условия среды</h3>
<p><strong>Сезон:</strong> {caseData.season || 'не указан'}</p>
{caseData.temperature && <p><strong>Температура:</strong> {caseData.temperature}°C</p>}
<p><strong>Осадки:</strong> {caseData.precipitation || 'нет'}</p>
{caseData.visibility && <p><strong>Видимость:</strong> {caseData.visibility}</p>}
</div>
</div>
</div>
);
};
export default AnalysisResult;