// ── 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 = '

Failed to load sessions.

'; } } 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 => `
${s.name}
${s.date || ''}
`).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 = '

Failed to load analysis.

'; } } 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 `
${g.label || 'Group ' + (i+1)} ${g.distance_m ? g.distance_m + ' m' : ''}
${gSpeeds.length} shots
${renderStatsTable(gStats)}
${chartB64 ? `Group chart` : '

No chart available.

'}
Target photo
Loading…
`; }).join(''); document.getElementById('groupSections').innerHTML = groupHtml || '

No shot groups in this session.

'; // 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 '

No data.

'; const sdText = stats.sd != null ? `${stats.sd.toFixed(1)} fps` : '—'; return `
Shots${stats.n}
Average${stats.avg.toFixed(1)} fps
Min${stats.min.toFixed(1)} fps
Max${stats.max.toFixed(1)} fps
Extreme spread (ES)${stats.es.toFixed(1)} fps
Std deviation (SD)${sdText}
`; } // ── 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 ? 'Replace group photo' : '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 = ` `; 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 `
ES: ${esMm}${esMoa}
`; })() : ''; const hasPois = (gp.points_of_impact || []).length >= 2; container.innerHTML = `
${gp.caption ? `${gp.caption}` : ''} ${analysisHtml} Measure group ${hasPois ? `` : ''}
`; } catch(e) { if (container) container.innerHTML = 'Failed to load photos.'; } } 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();