bone_2026/src/api/static/js/dxa-app.js

357 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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.

// DXA Quality Assessment Web Interface
const API_BASE = '';
let results = [];
let sortColumn = null;
let sortDirection = 'asc';
// DOM Elements
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const progressSection = document.getElementById('progressSection');
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
const progressPercent = document.getElementById('progressPercent');
const resultsSection = document.getElementById('resultsSection');
const resultsTable = document.getElementById('resultsTable');
const emptyResults = document.getElementById('emptyResults');
const errorSection = document.getElementById('errorSection');
const errorMessage = document.getElementById('errorMessage');
const statusBanner = document.getElementById('statusBanner');
const statusIcon = document.getElementById('statusIcon');
const statusText = document.getElementById('statusText');
// Theme toggle
const themeToggle = document.getElementById('themeToggle');
if (themeToggle) {
themeToggle.addEventListener('click', () => {
document.documentElement.classList.toggle('dark');
localStorage.setItem('theme', document.documentElement.classList.contains('dark') ? 'dark' : 'light');
});
// Load saved theme
if (localStorage.getItem('theme') === 'dark' || (!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
}
// Check API health
async function checkHealth() {
try {
const response = await fetch(`${API_BASE}/api/v1/health`);
const data = await response.json();
if (data.status === 'ok') {
statusIcon.className = 'w-3 h-3 rounded-full bg-green-500';
statusText.textContent = `Сервис готов • Модель: ${data.model_loaded ? 'загружена' : 'не загружена'} • Устройство: ${data.device}`;
} else {
statusIcon.className = 'w-3 h-3 rounded-full bg-yellow-500';
statusText.textContent = 'Проблемы с сервисом';
}
} catch (e) {
statusIcon.className = 'w-3 h-3 rounded-full bg-red-500';
statusText.textContent = 'Сервис недоступен';
}
}
// Drag and drop
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('drag-over');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
handleFiles(e.dataTransfer.files);
});
fileInput.addEventListener('change', (e) => {
handleFiles(e.target.files);
});
// Filter inputs
const searchInput = document.getElementById('searchInput');
const filterRegion = document.getElementById('filterRegion');
const filterQuality = document.getElementById('filterQuality');
searchInput.addEventListener('input', renderTable);
filterRegion.addEventListener('change', renderTable);
filterQuality.addEventListener('change', renderTable);
// Sort headers
document.querySelectorAll('th[data-sort]').forEach(th => {
th.addEventListener('click', () => {
const column = th.dataset.sort;
if (sortColumn === column) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortColumn = column;
sortDirection = 'asc';
}
renderTable();
updateSortIcons();
});
});
function updateSortIcons() {
document.querySelectorAll('th[data-sort]').forEach(th => {
const icon = th.querySelector('i');
if (th.dataset.sort === sortColumn) {
icon.className = `fas fa-sort-${sortDirection === 'asc' ? 'up' : 'down'} ml-1`;
} else {
icon.className = 'fas fa-sort ml-1';
}
});
}
// Handle files
async function handleFiles(files) {
if (files.length === 0) return;
results = [];
errorSection.classList.add('hidden');
resultsSection.classList.remove('hidden');
progressSection.classList.remove('hidden');
const dcmFiles = Array.from(files).filter(f =>
f.name.toLowerCase().endsWith('.dcm') ||
f.type === 'application/dicom' ||
f.name.toLowerCase().includes('dcm')
);
if (dcmFiles.length === 0) {
showError('DICOM файлы не найдены. Пожалуйста, выберите файлы с расширением .dcm');
progressSection.classList.add('hidden');
return;
}
progressText.textContent = `Анализ ${dcmFiles.length} файлов...`;
progressBar.style.width = '0%';
progressPercent.textContent = '0%';
let processed = 0;
for (const file of dcmFiles) {
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${API_BASE}/api/v1/analyze`, {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
results.push({
filename: file.name,
...data
});
} catch (e) {
results.push({
filename: file.name,
quality_class: -1,
quality_label: 'Error',
processing_status: `Failure: ${e.message}`,
confidence: 0
});
}
processed++;
const percent = Math.round((processed / dcmFiles.length) * 100);
progressBar.style.width = `${percent}%`;
progressPercent.textContent = `${percent}%`;
}
progressSection.classList.add('hidden');
updateStats();
renderTable();
}
// Update stats
function updateStats() {
const total = results.length;
const ok = results.filter(r => r.quality_class === 0).length;
const violation = results.filter(r => r.quality_class === 1).length;
const avgConfidence = results.length > 0
? (results.reduce((sum, r) => sum + (r.confidence || 0), 0) / results.length * 100).toFixed(1)
: 0;
document.getElementById('totalCount').textContent = total;
document.getElementById('okCount').textContent = ok;
document.getElementById('violationCount').textContent = violation;
document.getElementById('accuracy').textContent = `${avgConfidence}%`;
}
// Filter and sort results
function getFilteredResults() {
let filtered = [...results];
// Search filter
const search = searchInput.value.toLowerCase();
if (search) {
filtered = filtered.filter(r =>
(r.filename || '').toLowerCase().includes(search) ||
(r.study_uid || '').toLowerCase().includes(search) ||
(r.image_uid || '').toLowerCase().includes(search)
);
}
// Region filter
const region = filterRegion.value;
if (region) {
filtered = filtered.filter(r => r.anatomical_region === region);
}
// Quality filter
const quality = filterQuality.value;
if (quality !== '') {
filtered = filtered.filter(r => String(r.quality_class) === quality);
}
// Sort
if (sortColumn) {
filtered.sort((a, b) => {
let aVal = a[sortColumn] || '';
let bVal = b[sortColumn] || '';
if (typeof aVal === 'number' && typeof bVal === 'number') {
return sortDirection === 'asc' ? aVal - bVal : bVal - aVal;
}
aVal = String(aVal).toLowerCase();
bVal = String(bVal).toLowerCase();
if (sortDirection === 'asc') {
return aVal.localeCompare(bVal);
}
return bVal.localeCompare(aVal);
});
}
return filtered;
}
// Render table
function renderTable() {
const filtered = getFilteredResults();
if (filtered.length === 0) {
resultsTable.innerHTML = '';
emptyResults.classList.remove('hidden');
return;
}
emptyResults.classList.add('hidden');
resultsTable.innerHTML = filtered.map(r => {
const qualityClass = r.quality_class === 0 ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' :
r.quality_class === 1 ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' :
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
const qualityLabel = r.quality_class === 0 ? 'OK' :
r.quality_class === 1 ? 'Нарушение' :
'Ошибка';
const regionLabel = r.anatomical_region === 'spine' ? 'Позвоночник' :
r.anatomical_region === 'hip' ? 'Бедро' :
r.anatomical_region || '—';
const confidence = r.confidence ? (r.confidence * 100).toFixed(1) + '%' : '—';
return `
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 fade-in">
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white">
<div class="flex items-center gap-2">
<i class="fas fa-file-medical text-gray-400"></i>
<span class="font-medium">${r.filename || '—'}</span>
</div>
${r.study_uid ? `<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">UID: ${r.study_uid.substring(0, 20)}...</div>` : ''}
</td>
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
<span class="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-xs font-medium">
${regionLabel}
</span>
</td>
<td class="px-4 py-3">
<span class="px-2 py-1 rounded text-xs font-medium ${qualityClass}">
${qualityLabel}
</span>
</td>
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
<div class="flex items-center gap-2">
<div class="w-16 bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
<div class="bg-primary-600 h-1.5 rounded-full" style="width: ${r.confidence ? r.confidence * 100 : 0}%"></div>
</div>
<span class="text-xs">${confidence}</span>
</div>
</td>
</tr>
`;
}).join('');
}
// Export XLSX
document.getElementById('exportXlsx')?.addEventListener('click', async () => {
if (results.length === 0) return;
// Show loading state
const btn = document.getElementById('exportXlsx');
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Экспорт...';
btn.disabled = true;
try {
// For proper XLSX export, we'd need to re-upload files
// For now, export as CSV which Excel can open
const headers = ['filename', 'study_uid', 'image_uid', 'anatomical_region', 'quality_class', 'quality_label', 'confidence', 'processing_status'];
const csvContent = [
headers.join(','),
...results.map(r => headers.map(h => {
let val = r[h] || '';
if (typeof val === 'string' && val.includes(',')) {
val = `"${val}"`;
}
return val;
}).join(','))
].join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `dxa_results_${new Date().toISOString().slice(0, 10)}.csv`;
link.click();
} catch (e) {
showError('Ошибка экспорта: ' + e.message);
} finally {
btn.innerHTML = originalText;
btn.disabled = false;
}
});
// Clear results
document.getElementById('clearResults')?.addEventListener('click', () => {
results = [];
resultsSection.classList.add('hidden');
fileInput.value = '';
});
// Show error
function showError(message) {
errorMessage.textContent = message;
errorSection.classList.remove('hidden');
}
// Initialize
checkHealth();