357 lines
13 KiB
JavaScript
357 lines
13 KiB
JavaScript
// 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();
|