456 lines
18 KiB
JavaScript
456 lines
18 KiB
JavaScript
// ── Group Size Calculator ─────────────────────────────────────────────────────
|
|
// Canvas-based annotation tool. All geometry is computed client-side.
|
|
// Optional API integration when a GroupPhoto ID is provided via ?gp=<id>.
|
|
|
|
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)
|
|
: '<span class="text-muted">— (need ≥ 2 POIs)</span>';
|
|
document.getElementById('resMeanRadius').innerHTML = fval(meanRadius, toMoa, dist_m);
|
|
|
|
if (absW < 0.5 && absE < 0.5) {
|
|
document.getElementById('resOffset').innerHTML = '<span class="text-success">On POA ✓</span>';
|
|
document.getElementById('resCorrection').innerHTML = '<span class="text-success">None needed ✓</span>';
|
|
} 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 ? `<span class="me-3">${fval(absW, toMoa, dist_m)} <strong>${wDir}</strong></span>` : '') +
|
|
(eDir ? `<span>${fval(absE, toMoa, dist_m)} <strong>${eDir}</strong></span>` : '');
|
|
|
|
document.getElementById('resCorrection').innerHTML =
|
|
(cwDir ? `<span class="me-3">${fval(absW, toMoa, dist_m)} <strong>${cwDir}</strong></span>` : '') +
|
|
(ceDir ? `<span>${fval(absE, toMoa, dist_m)} <strong>${ceDir}</strong></span>` : '');
|
|
}
|
|
|
|
// 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
|