Files
ShooterHub/frontend/js/chrono.js
2026-04-02 11:24:30 +02:00

449 lines
18 KiB
JavaScript

// ── Chronograph Analyser page logic ───────────────────────────────────────────
let sessions = [];
let currentSessionId = null;
let _photoTargetGroupId = null; // group id for current photo upload modal
// showToast, renderPublicToggle → utils.js
// ── Sessions list ─────────────────────────────────────────────────────────────
async function loadSessions() {
try {
sessions = await apiGetAll('/tools/chronograph/');
renderSessionsList();
} catch(e) {
document.getElementById('sessionsSpinner').innerHTML =
'<p class="text-danger small">Failed to load sessions.</p>';
}
}
function renderSessionsList() {
document.getElementById('sessionsSpinner').classList.add('d-none');
const list = document.getElementById('sessionsList');
const empty = document.getElementById('sessionsEmpty');
if (!sessions.length) {
empty.classList.remove('d-none');
list.innerHTML = '';
return;
}
empty.classList.add('d-none');
list.innerHTML = sessions.map(s => `
<div class="session-item mb-1 ${s.id === currentSessionId ? 'active' : ''}"
onclick="selectSession(${s.id})">
<div class="fw-semibold small">${s.name}</div>
<div class="text-muted small">${s.date || ''}</div>
</div>
`).join('');
}
// ── Select & display a session ────────────────────────────────────────────────
async function selectSession(id) {
currentSessionId = id;
renderSessionsList();
document.getElementById('noSessionMsg').classList.add('d-none');
document.getElementById('analysisContent').classList.remove('d-none');
document.getElementById('chartsSpinner').classList.remove('d-none');
document.getElementById('chartsContent').classList.add('d-none');
const session = sessions.find(s => s.id === id);
document.getElementById('sessionTitle').textContent = session?.name || 'Session';
document.getElementById('sessionDate').textContent = session?.date || '';
renderPublicToggle(session?.is_public || false);
try {
// Fetch detail (includes groups + per-group stats via serializer)
const detail = await apiGet(`/tools/chronograph/${id}/`);
// Fetch chart images
const charts = await apiGet(`/tools/chronograph/${id}/charts/`);
renderAnalysis(detail, charts);
} catch(e) {
document.getElementById('chartsSpinner').innerHTML =
'<p class="text-danger text-center py-4">Failed to load analysis.</p>';
}
}
function fmtFps(v) {
return v != null ? `${Number(v).toFixed(1)} fps` : '—';
}
function renderAnalysis(detail, charts) {
document.getElementById('chartsSpinner').classList.add('d-none');
document.getElementById('chartsContent').classList.remove('d-none');
// Overall stats from all shots
const allShots = (detail.shot_groups || []).flatMap(g => g.shots || []);
const speeds = allShots.map(s => parseFloat(s.velocity_fps)).filter(v => !isNaN(v));
const overallStats = computeStats(speeds);
document.getElementById('overallStatsWrap').innerHTML = renderStatsTable(overallStats);
// Overview chart
const overviewImg = document.getElementById('overviewChart');
if (charts.overview) {
overviewImg.src = `data:image/png;base64,${charts.overview}`;
overviewImg.style.display = '';
} else {
overviewImg.style.display = 'none';
}
// Per-group sections
const groups = detail.shot_groups || [];
const groupCharts = charts.groups || [];
const groupHtml = groups.map((g, i) => {
const gSpeeds = (g.shots || []).map(s => parseFloat(s.velocity_fps)).filter(v => !isNaN(v));
const gStats = computeStats(gSpeeds);
const chartB64 = groupCharts[i];
return `
<div class="card border-0 shadow-sm mb-3" id="group-card-${g.id}">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<h6 class="fw-semibold mb-0">
<i class="bi bi-collection me-1"></i>
${g.label || 'Group ' + (i+1)}
<span class="text-muted fw-normal ms-2 small">${g.distance_m ? g.distance_m + ' m' : ''}</span>
</h6>
<span class="badge bg-secondary">${gSpeeds.length} shots</span>
</div>
<div class="row g-3">
<div class="col-md-5">
${renderStatsTable(gStats)}
</div>
<div class="col-md-7">
${chartB64
? `<img src="data:image/png;base64,${chartB64}" class="group-chart w-100" alt="Group chart">`
: '<p class="text-muted small">No chart available.</p>'}
</div>
</div>
<!-- Photos section -->
<div class="mt-3 pt-2 border-top">
<div class="d-flex align-items-center mb-2">
<span class="small fw-semibold text-muted"><i class="bi bi-images me-1"></i>Target photo</span>
</div>
<div id="photos-${g.id}">
<span class="text-muted small">Loading…</span>
</div>
</div>
</div>
</div>`;
}).join('');
document.getElementById('groupSections').innerHTML = groupHtml ||
'<p class="text-muted">No shot groups in this session.</p>';
// Load photos for each group asynchronously
groups.forEach(g => loadGroupPhotos(g.id));
}
function computeStats(speeds) {
if (!speeds.length) return null;
const n = speeds.length;
const avg = speeds.reduce((a,b) => a+b, 0) / n;
const min = Math.min(...speeds);
const max = Math.max(...speeds);
const es = max - min;
const sd = n > 1
? Math.sqrt(speeds.reduce((s,v) => s + (v-avg)**2, 0) / (n-1))
: null;
return { n, avg, min, max, es, sd };
}
function renderStatsTable(stats) {
if (!stats) return '<p class="text-muted small">No data.</p>';
const sdText = stats.sd != null ? `${stats.sd.toFixed(1)} fps` : '—';
return `
<table class="table table-sm stat-table mb-0">
<tbody>
<tr><td>Shots</td><td>${stats.n}</td></tr>
<tr><td>Average</td><td>${stats.avg.toFixed(1)} fps</td></tr>
<tr><td>Min</td><td>${stats.min.toFixed(1)} fps</td></tr>
<tr><td>Max</td><td>${stats.max.toFixed(1)} fps</td></tr>
<tr><td>Extreme spread (ES)</td><td>${stats.es.toFixed(1)} fps</td></tr>
<tr><td>Std deviation (SD)</td><td>${sdText}</td></tr>
</tbody>
</table>`;
}
// ── Public toggle (renderPublicToggle → utils.js) ────────────────────────────
document.getElementById('togglePublicBtn').addEventListener('click', async () => {
if (!currentSessionId) return;
const session = sessions.find(s => s.id === currentSessionId);
if (!session) return;
const newVal = !session.is_public;
try {
await apiPatch(`/tools/chronograph/${currentSessionId}/`, { is_public: newVal });
session.is_public = newVal;
renderPublicToggle(newVal);
showToast(newVal ? 'Analysis is now public.' : 'Analysis is now private.');
} catch(e) {
showToast('Failed to update visibility.', 'danger');
}
});
// ── Delete session ────────────────────────────────────────────────────────────
document.getElementById('deleteSessionBtn').addEventListener('click', async () => {
if (!currentSessionId) return;
if (!confirm('Delete this session and all its data?')) return;
try {
await apiDelete(`/tools/chronograph/${currentSessionId}/`);
sessions = sessions.filter(s => s.id !== currentSessionId);
currentSessionId = null;
document.getElementById('noSessionMsg').classList.remove('d-none');
document.getElementById('analysisContent').classList.add('d-none');
renderSessionsList();
showToast('Session deleted.');
} catch(e) {
showToast('Failed to delete session.', 'danger');
}
});
// ── Download PDF ──────────────────────────────────────────────────────────────
document.getElementById('downloadPdfBtn').addEventListener('click', () => {
if (!currentSessionId) return;
window.open(`/api/tools/chronograph/${currentSessionId}/report.pdf`, '_blank');
});
// ── Upload CSV ────────────────────────────────────────────────────────────────
const uploadModal = new bootstrap.Modal('#uploadModal');
document.getElementById('uploadBtn').addEventListener('click', () => {
document.getElementById('uploadName').value = '';
document.getElementById('uploadDate').value = new Date().toISOString().slice(0,10);
document.getElementById('csvFileInput').value = '';
document.getElementById('selectedFileName').classList.add('d-none');
document.getElementById('uploadAlert').classList.add('d-none');
document.getElementById('uploadSubmitBtn').disabled = true;
uploadModal.show();
});
// Drag & drop / click on drop area
const dropArea = document.getElementById('dropArea');
dropArea.addEventListener('click', () => document.getElementById('csvFileInput').click());
dropArea.addEventListener('dragover', e => { e.preventDefault(); dropArea.classList.add('drag-over'); });
dropArea.addEventListener('dragleave', () => dropArea.classList.remove('drag-over'));
dropArea.addEventListener('drop', e => {
e.preventDefault();
dropArea.classList.remove('drag-over');
const file = e.dataTransfer.files[0];
if (file) setSelectedFile(file);
});
document.getElementById('csvFileInput').addEventListener('change', e => {
const file = e.target.files[0];
if (file) setSelectedFile(file);
});
function setSelectedFile(file) {
const nameEl = document.getElementById('selectedFileName');
nameEl.textContent = `Selected: ${file.name}`;
nameEl.classList.remove('d-none');
document.getElementById('uploadSubmitBtn').disabled = false;
// Pre-fill session name from filename if empty
const nameInput = document.getElementById('uploadName');
if (!nameInput.value) {
nameInput.value = file.name.replace(/\.[^.]+$/, '').replace(/[_-]/g, ' ');
}
}
document.getElementById('uploadSubmitBtn').addEventListener('click', async () => {
const alertEl = document.getElementById('uploadAlert');
const spinner = document.getElementById('uploadSpinnerInline');
const submitBtn = document.getElementById('uploadSubmitBtn');
alertEl.classList.add('d-none');
const file = document.getElementById('csvFileInput').files[0];
if (!file) {
alertEl.textContent = 'Please select a CSV file.';
alertEl.classList.remove('d-none');
return;
}
const formData = new FormData();
formData.append('file', file);
const name = document.getElementById('uploadName').value.trim();
const date = document.getElementById('uploadDate').value;
const velUnit = document.querySelector('input[name="uploadVelUnit"]:checked')?.value || 'fps';
const chronoType = document.getElementById('uploadChronoType').value;
if (name) formData.append('name', name);
if (date) formData.append('date', date);
formData.append('velocity_unit', velUnit);
formData.append('chrono_type', chronoType);
submitBtn.disabled = true;
spinner.classList.remove('d-none');
try {
const session = await apiFetch('/tools/chronograph/upload/', {
method: 'POST',
body: formData,
}).then(async r => {
if (!r.ok) {
const err = await r.json().catch(() => ({}));
throw Object.assign(new Error('Upload failed'), { data: err });
}
return r.json();
});
sessions.unshift(session);
renderSessionsList();
uploadModal.hide();
selectSession(session.id);
showToast('Session uploaded and analysed!');
} catch(e) {
alertEl.textContent = (e.data && (e.data.detail || JSON.stringify(e.data))) || 'Upload failed.';
alertEl.classList.remove('d-none');
submitBtn.disabled = false;
} finally {
spinner.classList.add('d-none');
}
});
// ── Photos per group (one per group) ──────────────────────────────────────────
const photoModal = new bootstrap.Modal('#photoModal');
// Track existing GroupPhoto id for the current modal (null = no existing photo)
let _existingGroupPhotoId = null;
function openPhotoModal(groupId, existingGroupPhotoId = null) {
_photoTargetGroupId = groupId;
_existingGroupPhotoId = existingGroupPhotoId;
document.getElementById('photoFileInput').value = '';
document.getElementById('photoCaption').value = '';
document.getElementById('photoAlert').classList.add('d-none');
const title = document.querySelector('#photoModal .modal-title');
if (title) title.innerHTML = existingGroupPhotoId
? '<i class="bi bi-image me-2"></i>Replace group photo'
: '<i class="bi bi-image me-2"></i>Add photo to group';
photoModal.show();
}
async function loadGroupPhotos(groupId) {
const container = document.getElementById(`photos-${groupId}`);
if (!container) return;
try {
const _gpData = await apiGet(`/photos/group-photos/?shot_group=${groupId}`);
const items = asList(_gpData);
if (!items.length) {
container.innerHTML = `
<button class="btn btn-sm btn-outline-secondary py-0 px-2"
onclick="openPhotoModal(${groupId})">
<i class="bi bi-plus-lg me-1"></i>Add photo
</button>`;
return;
}
// One photo per group — use first result
const gp = items[0];
const analysisHtml = gp.analysis ? (() => {
const esMm = gp.analysis.group_size_mm != null ? parseFloat(gp.analysis.group_size_mm).toFixed(1) + ' mm' : '—';
const esMoa = gp.analysis.group_size_moa != null ? ' / ' + parseFloat(gp.analysis.group_size_moa).toFixed(2) + ' MOA' : '';
return `<div class="small text-muted mt-1">ES: ${esMm}${esMoa}</div>`;
})() : '';
const hasPois = (gp.points_of_impact || []).length >= 2;
container.innerHTML = `
<div class="d-flex align-items-start gap-3">
<img src="/api/photos/${gp.photo.id}/data/"
style="width:120px;height:90px;object-fit:cover;border-radius:6px;cursor:pointer"
title="${gp.caption || ''}"
onclick="window.open('/api/photos/${gp.photo.id}/data/','_blank')">
<div class="d-flex flex-column gap-1">
${gp.caption ? `<span class="small text-muted">${gp.caption}</span>` : ''}
${analysisHtml}
<a href="/group-size.html?gp=${gp.id}" class="btn btn-xs btn-outline-primary py-0 px-2 small">
<i class="bi bi-crosshair2 me-1"></i>Measure group
</a>
${hasPois ? `<button class="btn btn-xs btn-outline-secondary py-0 px-1 small"
onclick="computeGroupSize(${gp.id}, ${groupId})">
Recompute
</button>` : ''}
<button class="btn btn-xs btn-outline-secondary py-0 px-2 mt-1"
onclick="openPhotoModal(${groupId}, ${gp.id})">
<i class="bi bi-arrow-repeat me-1"></i>Replace
</button>
</div>
</div>`;
} catch(e) {
if (container) container.innerHTML = '<span class="text-danger small">Failed to load photos.</span>';
}
}
document.getElementById('photoSubmitBtn').addEventListener('click', async () => {
const alertEl = document.getElementById('photoAlert');
const spinner = document.getElementById('photoSpinner');
const submitBtn = document.getElementById('photoSubmitBtn');
alertEl.classList.add('d-none');
const file = document.getElementById('photoFileInput').files[0];
if (!file) {
alertEl.textContent = 'Please select an image file.';
alertEl.classList.remove('d-none');
return;
}
submitBtn.disabled = true;
spinner.classList.remove('d-none');
try {
// Step 1: if replacing, delete the old GroupPhoto first
if (_existingGroupPhotoId) {
await apiDelete(`/photos/group-photos/${_existingGroupPhotoId}/`);
}
// Step 2: upload raw photo
const formData = new FormData();
formData.append('file', file);
const photo = await apiFetch('/photos/upload/', { method: 'POST', body: formData })
.then(async r => {
if (!r.ok) { const e = await r.json().catch(() => ({})); throw Object.assign(new Error('Upload failed'), { data: e }); }
return r.json();
});
// Step 3: link to group
const caption = document.getElementById('photoCaption').value.trim();
await apiPost('/photos/group-photos/', {
photo_id: photo.id,
shot_group: _photoTargetGroupId,
...(caption ? { caption } : {}),
});
photoModal.hide();
loadGroupPhotos(_photoTargetGroupId);
showToast(_existingGroupPhotoId ? 'Photo replaced.' : 'Photo added.');
} catch(e) {
alertEl.textContent = (e.data && (e.data.detail || JSON.stringify(e.data))) || 'Upload failed.';
alertEl.classList.remove('d-none');
} finally {
submitBtn.disabled = false;
spinner.classList.add('d-none');
}
});
async function computeGroupSize(groupPhotoId, groupId) {
try {
await apiPost(`/photos/group-photos/${groupPhotoId}/compute-group-size/`, {});
loadGroupPhotos(groupId);
showToast('Group size computed.');
} catch(e) {
showToast('Failed to compute group size.', 'danger');
}
}
// ── Init ──────────────────────────────────────────────────────────────────────
async function init() {
await loadSessions();
const params = new URLSearchParams(window.location.search);
const id = parseInt(params.get('id'), 10);
if (id && sessions.find(s => s.id === id)) selectSession(id);
}
init();