ui: переработка страницы результатов — логические блоки, та же палитра

Проблема: 10 инфо-карточек в один столбец, карта с зонами — в самом
низу, немедленные действия терялись в середине, формула занимала
пол-экрана раньше вывода.

Новая структура (сверху вниз = порядок чтения штабом):
1. Шапка-панель решения: срочность | радиус (+множитель профилей
   отдельным чипом) | критические действия списком — решение видно
   без прокрутки; summary свёрнут в одну строку.
2. Главный ряд: КАРТА (2/3) + список ЗОН (1/3) рядом — зоны и карта
   теперь одно визуальное целое; geo-чипы (💧/🚂) и предупреждения
   геослоя компактными плашками в шапке карты.
3. Ряд поведение+действия: психотип (тактика 2×2 сеткой), теги
   профилей, unmodeled-пометки; немедленные действия нумерованным
   списком, КРИТИЧНО-пункты акцентной рамкой.
4. Ряд обоснование радиуса: формула моно-шрифтом + коэффициенты
   чипами (НормС/СП/СКД/СУ/СУТ/ВВС/ВП с подсказками) + психотип-
   поправки полос чипами.
5. Низ: справка (обстоятельства/здоровье/среда) сеткой 3 колонки.

Цветовая схема не тронута: var(--accent) #ff4d00, зоны #ff4d00/
#ff9f1a/#ffd166, тёмная тема ЦОУ. Направления зон — кириллицей
(С/СВ/В/...). Все поля бэкенда рендерятся (проверено живым analyze).
This commit is contained in:
2026-09-09 13:53:17 +03:00
parent 4531ac50b9
commit 32af8975f9
2 changed files with 714 additions and 395 deletions
+228 -193
View File
@@ -5,10 +5,12 @@ import { apiFetch } from "../../api/client";
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 'стандартный';
@@ -119,228 +121,246 @@ const AnalysisResult = () => {
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">
<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: analysisData.fallback_used ? '#8b949e' : '#34c759' }}>
{analysisData.fallback_used ? 'Локальный расчёт' : 'ИИ-анализ'}
</span>
</>
)}
<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: analysisData.fallback_used ? 'var(--text-secondary)' : 'var(--success)' }}>
{analysisData.fallback_used ? 'Локальный расчёт' : 'ИИ-анализ'}
</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">×{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: Уровень срочности */}
{analysisData?.urgency && (
<div className={`urgency-block urgency-${urgencyLevel}`}>
<div className="urgency-level">{analysisData.urgency}</div>
<div className="urgency-reason">{analysisData.summary || ''}</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 !== 'none' ? `(${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.weather || caseData.precipitation || 'нет'})</p>
<hr style={{ margin: '8px 0', border: 'none', borderTop: '1px solid #30363d' }} />
<p><strong>Радиус поиска:</strong> {analysisData?.search_radius_km} км × {analysisData?.distance_multiplier} (профили)</p>
</div>
</div>
</div>
{/* Блок 3: Активные профили и критические предупреждения */}
{(profiles.length > 0 || warnings.length > 0) && (
<div className="info-card">
<h3>⚠️ Активные профили</h3>
{profiles.map((p, i) => (
<div key={i} style={{ marginBottom: '10px', padding: '10px', background: '#161b22', borderRadius: '6px' }}>
<p style={{ margin: '0 0 4px 0' }}><strong>{p.name}</strong></p>
{p.note && <p style={{ fontSize: '13px', color: '#8b949e', margin: '2px 0' }}>{p.note}</p>}
{p.critical_warning && (
<p style={{ fontSize: '13px', color: '#ff4d00', margin: '4px 0 0 0' }}>🚨 {p.critical_warning}</p>
)}
</div>
))}
</div>
)}
{/* Блок 4: Психотип и модель поведения */}
{analysisData?.psychotype && psyMods && (
<div className="info-card">
<h3>🧠 Психотип и модель поведения</h3>
<p><strong>Психотип:</strong> {analysisData.psychotype}</p>
<p><strong>Модель движения:</strong> {psyMods.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м: ×{psyMods.zone_0_500}<br />
• 500-1500м: ×{psyMods.zone_500_1500}<br />
• 1500-2500м: ×{psyMods.zone_1500_2500}<br />
• 2500м+: ×{psyMods.zone_2500plus}
</p>
</div>
{psyRec && (
<div style={{ marginTop: '10px' }}>
<p style={{ fontSize: '14px', color: '#8b949e', margin: '0 0 4px 0' }}><strong>Тактика поиска:</strong></p>
<p style={{ fontSize: '13px', margin: '2px 0' }}><strong>Приоритетные зоны:</strong> {psyRec.priority_zones}</p>
<p style={{ fontSize: '13px', margin: '2px 0' }}><strong>Патрун:</strong> {psyRec.search_pattern}</p>
<p style={{ fontSize: '13px', margin: '2px 0' }}><strong>Ключевые объекты:</strong> {psyRec.key_locations}</p>
<p style={{ fontSize: '13px', margin: '2px 0' }}><strong>Связь:</strong> {psyRec.communication}</p>
</div>
)}
</div>
)}
{/* Блок 5: Обстоятельства потери */}
<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>
)}
{analysisData?.behavioral_prediction && (
<p><strong>Поведенческий прогноз:</strong> {analysisData.behavioral_prediction}</p>
)}
</div>
{/* Блок 6: Зоны поиска */}
<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>
{/* Блок 7: Немедленные действия */}
{analysisData?.immediate_actions?.length > 0 && (
<div className="info-card">
<h3>🚑 Немедленные действия</h3>
<ul style={{ margin: 0, paddingLeft: '20px' }}>
{analysisData.immediate_actions.map((a, i) => (
<li key={i} style={{ marginBottom: '4px' }}>{a}</li>
))}
</ul>
</div>
)}
{/* Блок 8: Карта */}
{tnpLat && tnpLon && (
{/* ===== Ряд 1: Карта + зоны ===== */}
<div className={`main-row ${hasMapClass(hasMap)}`}>
<div className="map-section">
<h2>Карта зон поиска</h2>
<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 style={{ marginBottom: '10px', padding: '10px', background: '#2d1b0e', border: '1px solid #ff4d00', borderRadius: '6px' }}>
<p style={{ margin: 0, fontSize: '13px', color: '#ff4d00' }}>
🚨 {waterData.warning}
</p>
<div className="geo-warning">
🚨 {waterData.warning}
</div>
)}
{waterData?.railway_warning && (waterData.features || []).some((f) => f.layer === 'railway') && (
<div style={{ marginBottom: '10px', padding: '10px', background: '#2d1b0e', border: '1px solid #8a2f2f', borderRadius: '6px' }}>
<p style={{ margin: 0, fontSize: '13px', color: '#ff7b72' }}>
🚂 {waterData.railway_warning}
</p>
{waterData?.railway_warning && railCount > 0 && (
<div className="geo-warning rail">
🚂 {waterData.railway_warning}
</div>
)}
{waterData?.count > 0 && (
<p style={{ margin: '0 0 8px 0', fontSize: '13px', color: '#8b949e' }}>
💧 Водоёмы в зоне поиска: {waterData.features.filter(f => f.layer !== 'railway').length}
{' '}· 🚂 Ж/д линий: {waterData.features.filter(f => f.layer === 'railway').length}
{waterData.warning && ' — приоритетная проверка'} (данные OSM)
</p>
{hasMap ? (
<SearchMap zones={zones}
maxDistance={parseFloat(analysisData?.max_distance_km)}
tnpLat={parseFloat(tnpLat)}
tnpLon={parseFloat(tnpLon)}
waterFeatures={waterData?.features || []}
/>
) : (
<div className="map-empty">ТНП не задана — карта недоступна</div>
)}
<SearchMap zones={zones}
maxDistance={parseFloat(analysisData?.max_distance_km)}
tnpLat={parseFloat(tnpLat)}
tnpLon={parseFloat(tnpLon)}
waterFeatures={waterData?.features || []}
/>
<div className="map-info" style={{ marginTop: "12px", fontSize: "14px", color: "#8b949e" }}>
<p>Координаты ТНП: {tnpLat}, {tnpLon}</p>
{caseData.tnp_address && <p>Адрес: {caseData.tnp_address}</p>}
<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>
)}
{/* Блок 9: Здоровье и особенности */}
{(caseData.has_diagnosis || caseData.cant_swim || caseData.fitness_level || caseData.has_transport) && (
<div className="info-card">
<h3>🏥 Здоровье и особенности</h3>
{caseData.has_diagnosis && (
<p><strong>Диагнозы:</strong> {caseData.diagnosis_type?.join(', ') || 'есть'}</p>
<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>
)}
{caseData.fitness_level && (
<p><strong>Физическая подготовка:</strong> {caseData.fitness_level}</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>
)}
{caseData.cant_swim && <p>⚠️ <strong>Не умеет плавать</strong> — приоритет ближайшим водоёмам</p>}
{caseData.has_transport && caseData.has_transport !== 'none' && (
<p><strong>Транспорт:</strong> {caseData.has_transport}</p>
{unmodeled.length > 0 && (
<div className="unmodeled-note">
{unmodeled.map((u, i) => (
<p key={i}>• {u.profile}: {u.note}</p>
))}
</div>
)}
{(analysisData?.unmodeled_profiles || []).length > 0 && (
<div style={{ marginTop: '10px', padding: '10px', background: '#161b22', borderRadius: '6px' }}>
<p style={{ fontSize: '13px', color: '#8b949e', margin: '0 0 4px 0' }}>
<strong>Вне поведенческой модели</strong> (на скоринг зон не влияют):
</p>
{analysisData.unmodeled_profiles.map((u, i) => (
<p key={i} style={{ fontSize: '13px', color: '#8b949e', margin: '2px 0' }}>
• {u.profile}: {u.note}
</p>
</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>
)}
{/* Блок 10: Условия среды */}
<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>}
{/* ===== Ряд 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>
@@ -348,4 +368,19 @@ const AnalysisResult = () => {
);
};
export default AnalysisResult;
// РУ-названия психотипов (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;