449 lines
18 KiB
JavaScript
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();
|