Files
vector/frontend/src/pages/analysis/AnalysisResult.jsx
T
Sadmin 1513bad538 Согласование радиуса: summary = max_distance × средний психотип-множитель
- search_engine: max_distance_km передаётся в analyzer (case_data).
- rules_analysis: search_radius = max_distance × _psychotype_radius_multiplier
  (среднее по полосам 0-500/0.5-1.5/1.5-2.5/2.5+). Убран двойной учёт
  age/season: score_zone.distance_multiplier их уже содержит, а max_distance
  их тоже учёл — раньше radius = multiplier×2.0 (хардкод) давал 1.6 км при
  радиусе 2.4 км.
- UI: подсказка у «×0.8» — коэффициент скоринга, не радиуса.
- Тест: согласованность summary↔формула.
2026-09-09 23:25:06 +03:00

549 lines
27 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 { 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 (
<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 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 (
<div className="analysis-result">
{/* ===== Шапка: кейс + срочность + радиус ===== */}
<div className="result-header">
<div className="header-top">
<h1><span className="logo">ВЕКТОР</span> Результаты анализа</h1>
<div className="case-info">
<span>{caseData.child_name || 'Ребёнок'}, {caseData.age_years} лет{genderLabel ? ` (${genderLabel})` : ''}</span>
<span className="separator">•</span>
<span>Время потери: {caseData.loss_time || 'не указано'}</span>
<span className="separator">•</span>
<span>Прошло: {caseData.elapsed_hours || 1} ч</span>
{analysisData && (
<>
<span className="separator">•</span>
<span style={{ color: 'var(--success)' }}>
Локальный расчёт
</span>
</>
)}
</div>
</div>
{/* Панель решения: срочность | радиус | действия */}
<div className="decision-strip">
<div className={`decision-cell urgency-cell urgency-${urgencyLevel}`}>
<div className="decision-label">Срочность</div>
<div className="decision-value">{analysisData?.urgency || '—'}</div>
</div>
<div className="decision-cell">
<div className="decision-label">Радиус поиска</div>
<div className="decision-value radius">
{analysisData?.max_distance_km} км
{analysisData?.distance_multiplier !== 1 && (
<span
className="radius-mult"
title="Суммарный коэффициент скоринга (возраст/сезон/профили) — влияет на сравнение секторов, не на радиус."
>×{analysisData?.distance_multiplier}</span>
)}
</div>
</div>
<div className="decision-cell warnings-cell">
<div className="decision-label">Критические действия</div>
{warnings.length > 0 ? (
<ul className="decision-warnings">
{warnings.map((w, i) => <li key={i}>{w.warning}</li>)}
</ul>
) : (
<div className="decision-warnings muted">Нет критических предупреждений</div>
)}
</div>
</div>
{analysisData?.summary && <p className="header-summary">{analysisData.summary}</p>}
</div>
<div className="result-content">
{/* ===== Ряд 1: Карта + зоны ===== */}
<div className={`main-row ${hasMapClass(hasMap)}`}>
<div className="map-section">
<div className="section-head">
<h2>Карта зон поиска</h2>
{(waterData?.count > 0) && (
<div className="geo-chips">
<span className="geo-chip water">💧 Водоёмы: {waterCount}</span>
{railCount > 0 && <span className="geo-chip rail">🚂 Ж/д: {railCount}</span>}
</div>
)}
</div>
{waterData?.warning && (
<div className="geo-warning">
🚨 {waterData.warning}
</div>
)}
{waterData?.railway_warning && railCount > 0 && (
<div className="geo-warning rail">
🚂 {waterData.railway_warning}
</div>
)}
{hasMap ? (
<SearchMap zones={zones}
maxDistance={parseFloat(analysisData?.max_distance_km)}
tnpLat={parseFloat(tnpLat)}
tnpLon={parseFloat(tnpLon)}
waterFeatures={waterData?.features || []}
/>
) : (
<div className="map-empty">ТНП не задана — карта недоступна</div>
)}
{operation && (
<div className="contour-panel">
<div className="contour-title">🔗 КОНТУР (полевая координация)</div>
<div className="contour-row">
<input
className="contour-input"
placeholder="UUID операции в КОНТУРе (создаётся в штабной панели)"
value={contourId}
onChange={(e) => setContourId(e.target.value)}
/>
<button
className="contour-save"
onClick={async () => {
const r = await apiFetch(`/api/v1/operations/${operation.id}`, {
method: 'PATCH',
body: JSON.stringify({ contour_operation_id: contourId }),
});
setContourStatus(r.ok
? { ok: true, message: 'ID операции КОНТУРа сохранён' }
: { ok: false, message: 'Не удалось сохранить ID' });
}}
>Сохранить ID</button>
</div>
<div className="contour-row">
<button
className="contour-send"
disabled={!contourId || !analysisData}
onClick={async () => {
const r = await apiFetch(`/api/v1/operations/${operation.id}/send-to-contour`, { method: 'POST' });
const body = await r.json().catch(() => ({}));
setContourStatus(r.ok
? { ok: true, message: `Отправлено зон: ${body.sent_zones}. Статус в КОНТУРе: ${body.contour_response?.status}` }
: { ok: false, message: body.detail || 'Ошибка отправки' });
}}
>Отправить зоны в КОНТУР</button>
</div>
{contourStatus && (
<div className={contourStatus.ok ? 'contour-ok' : 'contour-err'}>
{contourStatus.message}
</div>
)}
</div>
)}
{operation && operation.status !== 'completed' && (
<div className="complete-row">
<button
className="complete-btn"
onClick={() => { setShowComplete(true); setCompleteResult(null); }}
>✓ Завершить поиск</button>
<span className="complete-hint">Внесение исхода → кейс в архив + прогон матмодели</span>
</div>
)}
{operation && operation.status === 'completed' && (
<div className="complete-done">✓ Поиск завершён {operation.closed_at ? new Date(operation.closed_at).toLocaleString('ru-RU') : ''} — запись в архиве завершённых</div>
)}
<div className="map-info">
<span>ТНП: {tnpLat ? Number(tnpLat).toFixed(5) : '—'}, {tnpLon ? Number(tnpLon).toFixed(5) : '—'}</span>
{caseData.tnp_address && <span className="sep">·</span>}
{caseData.tnp_address && <span>{caseData.tnp_address}</span>}
</div>
</div>
<div className="zones-panel">
<div className="section-head">
<h2>Зоны поиска</h2>
<span className="zones-count">{zones.length} зон</span>
</div>
<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-meta">
<span className="zone-dir">{DIRECTION_RU[zone.direction] || zone.direction}</span>
<span className="zone-dist">{zone.distance}</span>
<div className="zone-score">{zone.score}%</div>
</div>
</div>
{zone.reasoning && <div className="zone-reasoning">{zone.reasoning}</div>}
</div>
))}
{zones.length === 0 && <div className="zones-empty">Зоны не рассчитаны</div>}
</div>
</div>
</div>
{/* ===== Ряд 2: Поведение + действия ===== */}
<div className="behavior-row">
<div className="info-card behavior-card">
<div className="section-head">
<h3>Поведение и профиль</h3>
{(profiles.length > 0 || unmodeled.length > 0) && (
<div className="profile-tags">
{profiles.map((p, i) => <span key={i} className="profile-tag">{p.name}</span>)}
{unmodeled.map((u, i) => <span key={`u${i}`} className="profile-tag unmodeled" title={u.note}>{u.profile}*</span>)}
</div>
)}
</div>
{analysisData?.behavioral_prediction && (
<p className="prediction">{analysisData.behavioral_prediction}</p>
)}
{psyRec && (
<div className="tactics-grid">
<div className="tactic"><div className="tactic-label">Приоритетные зоны</div><div>{psyRec.priority_zones}</div></div>
<div className="tactic"><div className="tactic-label">Паттерн поиска</div><div>{psyRec.search_pattern}</div></div>
<div className="tactic"><div className="tactic-label">Ключевые объекты</div><div>{psyRec.key_locations}</div></div>
<div className="tactic"><div className="tactic-label">Связь</div><div>{psyRec.communication}</div></div>
</div>
)}
{unmodeled.length > 0 && (
<div className="unmodeled-note">
{unmodeled.map((u, i) => (
<p key={i}>• {u.profile}: {u.note}</p>
))}
</div>
)}
</div>
<div className="info-card actions-card">
<h3>Немедленные действия</h3>
<ol className="actions-list">
{(analysisData?.immediate_actions || []).map((a, i) => (
<li key={i} className={a.startsWith('КРИТИЧНО') ? 'critical' : ''}>{a}</li>
))}
</ol>
</div>
</div>
{/* ===== Ряд 3: Обоснование радиуса (формула Экстремум) ===== */}
{analysisData && (
<div className="info-card radius-card">
<div className="section-head">
<h3>Обоснование радиуса</h3>
<span className="radius-formula">Distance = Time × НормС × СП × СКД × СУ × СУТ × ВВС × ВП</span>
</div>
<div className="coeff-grid">
{coeffChips.map((c, i) => (
<div key={i} className="coeff-chip">
<div className="coeff-name">{c.label} <span className="coeff-value">{c.value}</span></div>
<div className="coeff-hint">{c.hint}</div>
</div>
))}
</div>
{psyMods && (
<div className="psy-mods">
<span className="psy-mods-title">Психотип {psychoRu(analysisData?.psychotype)} — поправки полос:</span>
{['0–500 м', '500–1500 м', '1500–2500 м', '2500 м+'].map((band, i) => (
<span key={i} className="psy-mod-chip">{band}: ×{[psyMods.zone_0_500, psyMods.zone_500_1500, psyMods.zone_1500_2500, psyMods.zone_2500plus][i]}</span>
))}
</div>
)}
</div>
)}
{/* ===== Ряд 4: Справочные данные (3 колонки) ===== */}
<div className="reference-grid">
<div className="info-card">
<h3>Обстоятельства потери</h3>
<p><strong>Причина:</strong> {caseData.reason || caseData.loss_reason || 'не указана'}</p>
<p><strong>Последнее известное место:</strong> {caseData.last_known_place || 'не указано'}</p>
{caseData.direction && <p><strong>Направление движения:</strong> {caseData.direction}</p>}
{caseData.behavior_description && <p><strong>Описание:</strong> {caseData.behavior_description}</p>}
</div>
<div className="info-card">
<h3>Здоровье и особенности</h3>
{caseData.has_diagnosis ? (
<p><strong>Диагнозы:</strong> {caseData.diagnosis_type?.join(', ')}</p>
) : (
<p><strong>Диагнозы:</strong> нет</p>
)}
{caseData.fitness_level && <p><strong>Физподготовка:</strong> {caseData.fitness_level}</p>}
{caseData.cant_swim && <p>⚠️ <strong>Не умеет плавать</strong> — приоритет ближайшим водоёмам</p>}
{caseData.has_transport && caseData.has_transport !== 'none' && (
<p><strong>Транспорт:</strong> {caseData.has_transport}</p>
)}
</div>
<div className="info-card">
<h3>Условия среды</h3>
<p><strong>Сезон:</strong> {caseData.season || 'не указан'}</p>
{caseData.temperature_c != null && <p><strong>Температура:</strong> {caseData.temperature_c}°C</p>}
<p><strong>Осадки:</strong> {caseData.weather || caseData.precipitation || 'нет'}</p>
{caseData.visibility && <p><strong>Видимость:</strong> {caseData.visibility}</p>}
</div>
</div>
</div>
{showComplete && (
<div className="modal-overlay" onClick={() => setShowComplete(false)}>
<div className="modal-box" onClick={(e) => e.stopPropagation()}>
<h3>Завершение поиска</h3>
<p className="modal-sub">Исход вносится в карточку, кейс уходит в архив завершённых, операция помечается завершённой.</p>
<div className="modal-grid">
<label>Найден?
<select value={completeForm.found_alive} onChange={(e) => setCompleteForm({ ...completeForm, found_alive: e.target.value })}>
<option value="true">Живым</option>
<option value="false">Погибшим</option>
</select>
</label>
<label>Дистанция, км
<input type="number" step="0.1" value={completeForm.found_distance_km} onChange={(e) => setCompleteForm({ ...completeForm, found_distance_km: e.target.value })} />
</label>
<label>Направление от ТНП
<select value={completeForm.found_direction} onChange={(e) => setCompleteForm({ ...completeForm, found_direction: e.target.value })}>
<option value="">—</option>
{FOUND_DIRECTIONS.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
</label>
<label>Кто нашёл
<select value={completeForm.who_found} onChange={(e) => setCompleteForm({ ...completeForm, who_found: e.target.value })}>
<option value="">—</option>
{WHO_FOUND_TYPES.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
</label>
<label>Длительность поиска, ч
<input type="number" step="0.5" value={completeForm.search_duration_hours} onChange={(e) => setCompleteForm({ ...completeForm, search_duration_hours: e.target.value })} />
</label>
<label>Тип места находки
<select value={completeForm.found_location_type} onChange={(e) => setCompleteForm({ ...completeForm, found_location_type: e.target.value })}>
<option value="">—</option>
{LOCATION_TYPES.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
</label>
</div>
{completeResult && (
<div className={completeResult.ok ? 'complete-ok' : 'complete-err'}>{completeResult.message}</div>
)}
<div className="modal-actions">
<button className="modal-cancel" onClick={() => setShowComplete(false)}>Отмена</button>
<button
className="modal-confirm"
disabled={completing}
onClick={async () => {
setCompleting(true);
setCompleteResult(null);
try {
const f = completeForm;
const body = {
found_alive: f.found_alive === 'true',
found_distance_km: f.found_distance_km ? Number(f.found_distance_km) : null,
found_direction: f.found_direction || null,
found_location_type: f.found_location_type || null,
search_duration_hours: f.search_duration_hours ? Number(f.search_duration_hours) : null,
who_found: f.who_found || null,
};
const r = await apiFetch(`/api/v1/operations/${operation.id}/complete`, {
method: 'POST', body: JSON.stringify(body),
});
const d = await r.json().catch(() => ({}));
if (r.ok) {
const acc = d.accuracy_note ? ` Точность прогноза: ${d.accuracy_note}` : '';
setCompleteResult({ ok: true, message: `Поиск завершён, кейс в архиве.${acc}` });
setOperation({ ...operation, status: 'completed', closed_at: d.closed_at });
setTimeout(() => setShowComplete(false), 1800);
} else {
setCompleteResult({ ok: false, message: d.detail || 'Ошибка завершения' });
}
} catch (e) {
setCompleteResult({ ok: false, message: e.message });
} finally {
setCompleting(false);
}
}}
>{completing ? 'Завершение...' : 'Завершить поиск'}</button>
</div>
</div>
</div>
)}
</div>
);
};
// РУ-названия психотипов (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;