// ── Group Size Calculator ───────────────────────────────────────────────────── // Canvas-based annotation tool. All geometry is computed client-side. // Optional API integration when a GroupPhoto ID is provided via ?gp=. let canvas, ctx; const imgEl = new Image(); // Annotation state let mode = 'idle'; // 'idle' | 'ref1' | 'ref2' | 'poa' | 'poi' let refP1 = null, refP2 = null; let poa = null; let pois = []; let hoverPt = null; // API context (set when ?gp= param is present) let gpId = null; // GroupPhoto id let gpPhotoId = null; // Photo id (for image URL) // ── Boot ────────────────────────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', async () => { canvas = document.getElementById('annotCanvas'); ctx = canvas.getContext('2d'); canvas.addEventListener('click', onCanvasClick); canvas.addEventListener('mousemove', onCanvasMove); canvas.addEventListener('mouseleave', () => { hoverPt = null; if (mode !== 'idle') redraw(); }); // File upload wiring const dropArea = document.getElementById('dropArea'); const fileInput = document.getElementById('fileInput'); dropArea.addEventListener('click', () => fileInput.click()); fileInput.addEventListener('change', e => { if (e.target.files[0]) loadFile(e.target.files[0]); }); 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'); if (e.dataTransfer.files[0]) loadFile(e.dataTransfer.files[0]); }); // Recompute when setup inputs change document.getElementById('refLength').addEventListener('input', () => { redraw(); updateResults(); }); document.getElementById('distanceM').addEventListener('input', updateResults); document.getElementById('bulletDia').addEventListener('input', () => { redraw(); updateResults(); }); // Unit switch const gsUnitBtns = document.getElementById('gsDistUnitBtns'); applyDistUnitButtons(gsUnitBtns); gsUnitBtns.addEventListener('click', e => { const btn = e.target.closest('[data-dist-unit]'); if (!btn) return; setDistUnit(btn.dataset.distUnit); applyDistUnitButtons(gsUnitBtns); updateResults(); }); // Check for ?gp= query param const params = new URLSearchParams(window.location.search); const gpParam = params.get('gp'); if (gpParam) { await loadFromApi(parseInt(gpParam)); } // Show back button if we came from somewhere (history) or have a ?gp= param const backWrap = document.getElementById('backBtnWrap'); if (backWrap) { if (document.referrer) { const ref = new URL(document.referrer); document.getElementById('backBtn').href = ref.pathname + ref.search; backWrap.classList.remove('d-none'); } else if (gpParam) { // No referrer but linked from chrono — default back to chrono backWrap.classList.remove('d-none'); } } }); // ── Image loading ───────────────────────────────────────────────────────────── function loadFile(file) { if (!file.type.startsWith('image/')) return; const reader = new FileReader(); reader.onload = e => { imgEl.onload = onImageReady; imgEl.src = e.target.result; }; reader.readAsDataURL(file); } async function loadFromApi(id) { try { const gp = await apiGet(`/photos/group-photos/${id}/`); gpId = gp.id; gpPhotoId = gp.photo.id; // Prefill distance from linked shot group if (gp.shot_group_detail?.distance_m) { document.getElementById('distanceM').value = gp.shot_group_detail.distance_m; } // Show "linked" badge const badge = document.getElementById('linkedBadge'); if (badge) { badge.textContent = gp.shot_group_detail ? `Linked to: ${gp.shot_group_detail.label}${gp.shot_group_detail.distance_m ? ' @ ' + gp.shot_group_detail.distance_m + ' m' : ''}` : 'Linked group photo'; badge.classList.remove('d-none'); } // If existing POIs, prefill them (using pixel coords) if (gp.points_of_impact && gp.points_of_impact.length) { // POIs will be mapped after image load (need canvas size) imgEl._existingPois = gp.points_of_impact; } imgEl.onload = onImageReady; imgEl.src = `/api/photos/${gpPhotoId}/data/`; } catch(e) { showUploadError('Failed to load group photo from server.'); } } function onImageReady() { document.getElementById('uploadSection').classList.add('d-none'); document.getElementById('annotSection').classList.remove('d-none'); // Reset annotations (keep any prefilled distance) refP1 = refP2 = poa = null; pois = []; resizeCanvas(); window.addEventListener('resize', resizeCanvas); // Restore existing POIs from API (scaled to canvas) if (imgEl._existingPois && imgEl._existingPois.length) { const sx = canvas.width / imgEl.naturalWidth; const sy = canvas.height / imgEl.naturalHeight; pois = imgEl._existingPois.map(p => ({ x: p.x_px * sx, y: p.y_px * sy })); imgEl._existingPois = null; } setMode('ref1'); } function resizeCanvas() { const w = document.getElementById('canvasWrap').clientWidth; canvas.width = w; canvas.height = Math.round(w * imgEl.naturalHeight / imgEl.naturalWidth); redraw(); } function showUploadError(msg) { const el = document.getElementById('uploadError'); el.textContent = msg; el.classList.remove('d-none'); } // ── Mode management ─────────────────────────────────────────────────────────── const STATUS = { idle: '', ref1: '① Click the first point of your reference measurement.', ref2: '② Click the second point of the reference measurement.', poa: '③ Click the Point of Aim — the intended centre of the target.', poi: '④ Click each bullet hole to add a Point of Impact. Click Compute when done.', }; function setMode(m) { mode = m; canvas.style.cursor = m === 'idle' ? 'default' : 'crosshair'; document.getElementById('statusMsg').textContent = STATUS[m] || ''; ['btnRef', 'btnPoa', 'btnPoi'].forEach(id => { const active = (id === 'btnRef' && (m === 'ref1' || m === 'ref2')) || (id === 'btnPoa' && m === 'poa') || (id === 'btnPoi' && m === 'poi'); const el = document.getElementById(id); el.className = `btn btn-sm w-100 ${active ? 'btn-primary' : 'btn-outline-secondary'}`; }); } // ── Canvas events ───────────────────────────────────────────────────────────── function canvasPt(e) { const r = canvas.getBoundingClientRect(); return { x: (e.clientX - r.left) * canvas.width / r.width, y: (e.clientY - r.top) * canvas.height / r.height, }; } function onCanvasClick(e) { const pt = canvasPt(e); switch (mode) { case 'ref1': refP1 = pt; refP2 = null; setMode('ref2'); break; case 'ref2': refP2 = pt; setMode('idle'); document.getElementById('refLength').focus(); break; case 'poa': poa = pt; setMode('poi'); break; case 'poi': pois.push(pt); break; default: return; } redraw(); updateResults(); } function onCanvasMove(e) { hoverPt = canvasPt(e); if (mode !== 'idle') redraw(); } // ── Drawing ─────────────────────────────────────────────────────────────────── function redraw() { if (!canvas || !imgEl.naturalWidth) return; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(imgEl, 0, 0, canvas.width, canvas.height); const scale = getScale(); // px/mm const bulletDia = parseFloat(document.getElementById('bulletDia').value) || 0; const poiR = scale && bulletDia ? (bulletDia / 2) * scale : 10; // ── Reference line ────────────────────────────────────────────────────────── if (refP1) { const end = refP2 || (mode === 'ref2' && hoverPt) || refP1; ctx.save(); ctx.strokeStyle = '#3b82f6'; ctx.lineWidth = 2; ctx.setLineDash([7, 4]); ctx.beginPath(); ctx.moveTo(refP1.x, refP1.y); ctx.lineTo(end.x, end.y); ctx.stroke(); drawDot(refP1.x, refP1.y, '#3b82f6', 5); if (refP2) { drawDot(refP2.x, refP2.y, '#3b82f6', 5); // Length label midpoint const mx = (refP1.x + refP2.x) / 2, my = (refP1.y + refP2.y) / 2; const mm = document.getElementById('refLength').value; if (mm) { ctx.fillStyle = '#fff'; ctx.font = 'bold 11px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const label = mm + ' mm'; const tw = ctx.measureText(label).width + 6; ctx.fillRect(mx - tw/2, my - 8, tw, 16); ctx.fillStyle = '#3b82f6'; ctx.fillText(label, mx, my); } } ctx.restore(); } // ── POA crosshair ─────────────────────────────────────────────────────────── if (poa) drawCross(poa.x, poa.y, '#22c55e', 14); // ── POIs ──────────────────────────────────────────────────────────────────── pois.forEach((p, i) => { ctx.save(); ctx.strokeStyle = '#ef4444'; ctx.lineWidth = 2; ctx.fillStyle = 'rgba(239,68,68,0.18)'; const r = Math.max(poiR, 8); ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, 2 * Math.PI); ctx.fill(); ctx.stroke(); ctx.fillStyle = '#ef4444'; ctx.font = 'bold 11px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(i + 1, p.x, p.y); ctx.restore(); }); // Hover ghost in POI mode if (mode === 'poi' && hoverPt) { ctx.save(); ctx.strokeStyle = 'rgba(239,68,68,0.4)'; ctx.lineWidth = 1.5; ctx.setLineDash([4, 3]); ctx.beginPath(); ctx.arc(hoverPt.x, hoverPt.y, Math.max(poiR, 8), 0, 2 * Math.PI); ctx.stroke(); ctx.restore(); } // ── Centroid + offset line ────────────────────────────────────────────────── const res = compute(); if (res && pois.length >= 1) { const cx = poa.x + res.offsetX_mm * scale; const cy = poa.y - res.offsetY_mm * scale; // Y flipped (up = positive elevation) ctx.save(); ctx.strokeStyle = '#f97316'; ctx.lineWidth = 2; ctx.setLineDash([5, 3]); ctx.beginPath(); ctx.moveTo(poa.x, poa.y); ctx.lineTo(cx, cy); ctx.stroke(); ctx.restore(); drawDot(cx, cy, '#f97316', 7); } } function drawDot(x, y, color, r) { ctx.save(); ctx.fillStyle = color; ctx.beginPath(); ctx.arc(x, y, r, 0, 2 * Math.PI); ctx.fill(); ctx.restore(); } function drawCross(x, y, color, size) { ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.moveTo(x - size, y); ctx.lineTo(x + size, y); ctx.moveTo(x, y - size); ctx.lineTo(x, y + size); ctx.stroke(); ctx.beginPath(); ctx.arc(x, y, 4, 0, 2 * Math.PI); ctx.stroke(); ctx.restore(); } // ── Computation ─────────────────────────────────────────────────────────────── // Returns px/mm, or null if reference is not set. function getScale() { const mm = parseFloat(document.getElementById('refLength').value); if (!refP1 || !refP2 || !mm || mm <= 0) return null; const d = Math.hypot(refP2.x - refP1.x, refP2.y - refP1.y); return d > 2 ? d / mm : null; } function compute() { const scale = getScale(); if (!scale || !poa || !pois.length) return null; // Convert POIs to mm relative to POA. // X: positive = right. Y: positive = up (canvas Y is flipped). const pts = pois.map(p => ({ x: (p.x - poa.x) / scale, y: -(p.y - poa.y) / scale, })); const cx = pts.reduce((s, p) => s + p.x, 0) / pts.length; const cy = pts.reduce((s, p) => s + p.y, 0) / pts.length; // Extreme spread (max pairwise distance) let groupSizeMm = 0; for (let i = 0; i < pts.length; i++) for (let j = i + 1; j < pts.length; j++) groupSizeMm = Math.max(groupSizeMm, Math.hypot(pts[i].x - pts[j].x, pts[i].y - pts[j].y)); // Mean radius from centroid const meanRadius = pts.reduce((s, p) => s + Math.hypot(p.x - cx, p.y - cy), 0) / pts.length; const dist_m = parseFloat(document.getElementById('distanceM').value) || null; const toMoa = dist_m ? mm => mm / (dist_m * 0.29089) : null; return { groupSizeMm, meanRadius, offsetX_mm: cx, offsetY_mm: cy, toMoa, scale, dist_m, pts }; } // ── Results rendering ───────────────────────────────────────────────────────── // fval: format a mm value using the user's chosen distance unit. // fn = toMoa (from compute()), distM = shooting distance in metres. function fval(mm, toMoa, distM) { return fDist(mm, toMoa ? toMoa(mm) : null, distM); } function updateResults() { const res = document.getElementById('resultsSection'); const hint = document.getElementById('resultsHint'); const saveBtn = document.getElementById('saveBtn'); const r = compute(); if (!r) { res.classList.add('d-none'); if (saveBtn) saveBtn.classList.add('d-none'); const msg = !refP1 || !refP2 ? 'Draw a reference line on the image.' : !parseFloat(document.getElementById('refLength').value) ? 'Enter the reference length in mm.' : !poa ? 'Set the Point of Aim (POA).' : 'Add at least one Point of Impact (POI).'; hint.textContent = msg; hint.classList.remove('d-none'); return; } hint.classList.add('d-none'); res.classList.remove('d-none'); const { groupSizeMm, meanRadius, offsetX_mm, offsetY_mm, toMoa, dist_m } = r; const absW = Math.abs(offsetX_mm); const absE = Math.abs(offsetY_mm); document.getElementById('resPOICount').textContent = pois.length; document.getElementById('resGroupSize').innerHTML = pois.length >= 2 ? fval(groupSizeMm, toMoa, dist_m) : '— (need ≥ 2 POIs)'; document.getElementById('resMeanRadius').innerHTML = fval(meanRadius, toMoa, dist_m); if (absW < 0.5 && absE < 0.5) { document.getElementById('resOffset').innerHTML = 'On POA ✓'; document.getElementById('resCorrection').innerHTML = 'None needed ✓'; } else { const wDir = offsetX_mm > 0.5 ? 'right' : offsetX_mm < -0.5 ? 'left' : null; const eDir = offsetY_mm > 0.5 ? 'high' : offsetY_mm < -0.5 ? 'low' : null; const cwDir = offsetX_mm > 0.5 ? 'left' : offsetX_mm < -0.5 ? 'right' : null; const ceDir = offsetY_mm > 0.5 ? 'down' : offsetY_mm < -0.5 ? 'up' : null; document.getElementById('resOffset').innerHTML = (wDir ? `${fval(absW, toMoa, dist_m)} ${wDir}` : '') + (eDir ? `${fval(absE, toMoa, dist_m)} ${eDir}` : ''); document.getElementById('resCorrection').innerHTML = (cwDir ? `${fval(absW, toMoa, dist_m)} ${cwDir}` : '') + (ceDir ? `${fval(absE, toMoa, dist_m)} ${ceDir}` : ''); } // Hide the MOA note when the unit isn't mm (user explicitly chose a different unit) const showNote = getDistUnit() === 'mm' && !dist_m; document.getElementById('resMoaNote').classList.toggle('d-none', !showNote); // Show save button only when linked to a GroupPhoto if (saveBtn && gpId) saveBtn.classList.remove('d-none'); } // ── Save back to API ────────────────────────────────────────────────────────── async function saveToApi() { const r = compute(); if (!r || !gpId) return; const btn = document.getElementById('saveBtn'); btn.disabled = true; try { // 1. Delete existing POIs const existing = await apiGet(`/photos/group-photos/${gpId}/`); for (const poi of (existing.points_of_impact || [])) { await apiFetch(`/photos/group-photos/${gpId}/points/${poi.id}/`, { method: 'DELETE' }); } // 2. Post new POIs with pixel + mm coords const sx = imgEl.naturalWidth / canvas.width; const sy = imgEl.naturalHeight / canvas.height; for (const [i, pt] of pois.entries()) { const mm = r.pts[i]; await apiPost(`/photos/group-photos/${gpId}/points/`, { order: i + 1, x_px: Math.round(pt.x * sx), y_px: Math.round(pt.y * sy), x_mm: parseFloat(mm.x.toFixed(2)), y_mm: parseFloat(mm.y.toFixed(2)), }); } // 3. Trigger server-side group size computation await apiPost(`/photos/group-photos/${gpId}/compute-group-size/`, {}); showToast('Saved & computed — results stored in the session.'); } catch(e) { showToast('Save failed: ' + (e.message || 'unknown error'), 'danger'); } finally { btn.disabled = false; } } // ── Control buttons (called from HTML) ──────────────────────────────────────── function btnRef() { setMode('ref1'); } function btnPoa() { setMode('poa'); } function btnPoi() { setMode('poi'); } function btnUndo() { if (pois.length) { pois.pop(); redraw(); updateResults(); } } function btnReset() { refP1 = refP2 = poa = null; pois = []; document.getElementById('refLength').value = ''; redraw(); updateResults(); setMode('ref1'); } function loadNewPhoto() { document.getElementById('uploadSection').classList.remove('d-none'); document.getElementById('annotSection').classList.add('d-none'); window.removeEventListener('resize', resizeCanvas); gpId = gpPhotoId = null; refP1 = refP2 = poa = null; pois = []; } // showToast → utils.js