Files
ShooterHub/templates/sessions/annotate_photo.html
2026-03-19 16:42:37 +01:00

648 lines
26 KiB
HTML

{% extends "base.html" %}
{% block title %}Annotate photo — {{ session.label }}{% endblock %}
{% block content %}
<div style="margin-bottom:1rem;">
<div style="font-size:0.82rem;color:#888;margin-bottom:.3rem;">
<a href="{{ url_for('sessions.index') }}">Sessions</a> &rsaquo;
<a href="{{ url_for('sessions.detail', session_id=session.id) }}">{{ session.label }}</a> &rsaquo;
Annotate
</div>
<div style="display:flex;align-items:center;justify-content:space-between;gap:1rem;">
<h1 style="margin:0;">{{ photo.caption or 'Photo annotation' }}</h1>
<a href="{{ url_for('sessions.detail', session_id=session.id) }}"
style="background:#f0f4ff;color:#1a1a2e;border:1px solid #c8d4f0;border-radius:4px;
padding:.45rem 1rem;font-size:0.88rem;text-decoration:none;white-space:nowrap;">
✕ Close
</a>
</div>
</div>
<div style="display:flex;gap:1.5rem;align-items:flex-start;">
{# ── Canvas ── #}
<div style="flex:1;min-width:0;">
<canvas id="ann-canvas"
style="width:100%;border-radius:6px;cursor:crosshair;display:block;
box-shadow:0 2px 8px rgba(0,0,0,.18);background:#111;"></canvas>
</div>
{# ── Control panel ── #}
<div style="width:260px;flex-shrink:0;">
{# Step indicator #}
<div style="margin-bottom:1.25rem;">
<div id="si-0" class="si"
style="display:flex;align-items:center;gap:.5rem;padding:.45rem .7rem;border-radius:4px;margin-bottom:.3rem;font-size:0.88rem;">
<span class="si-num" style="width:1.4rem;height:1.4rem;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.78rem;font-weight:700;flex-shrink:0;"></span>
Reference line
</div>
<div id="si-1" class="si"
style="display:flex;align-items:center;gap:.5rem;padding:.45rem .7rem;border-radius:4px;margin-bottom:.3rem;font-size:0.88rem;">
<span class="si-num" style="width:1.4rem;height:1.4rem;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.78rem;font-weight:700;flex-shrink:0;"></span>
Point of Aim
</div>
<div id="si-2" class="si"
style="display:flex;align-items:center;gap:.5rem;padding:.45rem .7rem;border-radius:4px;margin-bottom:.3rem;font-size:0.88rem;">
<span class="si-num" style="width:1.4rem;height:1.4rem;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.78rem;font-weight:700;flex-shrink:0;"></span>
Points of Impact
</div>
<div id="si-3" class="si"
style="display:flex;align-items:center;gap:.5rem;padding:.45rem .7rem;border-radius:4px;font-size:0.88rem;">
<span class="si-num" style="width:1.4rem;height:1.4rem;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.78rem;font-weight:700;flex-shrink:0;"></span>
Results
</div>
</div>
<hr style="border:none;border-top:1px solid #e8e8e8;margin-bottom:1rem;">
{# Shooting distance (always visible, pre-filled from session) #}
<div style="margin-bottom:.75rem;">
<label style="display:block;font-size:.82rem;font-weight:600;color:#444;margin-bottom:.3rem;">Shooting distance</label>
<div style="display:flex;gap:.4rem;">
<input type="number" id="shoot-dist" min="1" step="1" placeholder="100"
style="flex:1;padding:.4rem .5rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;">
<select id="shoot-unit"
style="padding:.4rem .5rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;">
<option value="m">m</option>
<option value="yd">yd</option>
</select>
</div>
</div>
{# Clean barrel checkbox #}
<div style="margin-bottom:1rem;">
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:0.88rem;color:#444;">
<input type="checkbox" id="clean-barrel" style="width:1rem;height:1rem;">
Clean barrel (first shot)
</label>
</div>
<hr style="border:none;border-top:1px solid #e8e8e8;margin-bottom:1rem;">
{# Step 0: Reference line #}
<div id="panel-0" class="step-panel">
<p style="font-size:0.84rem;color:#555;margin-bottom:.75rem;line-height:1.5;">
Click <strong>two points</strong> on the image to draw a reference line — e.g. a known grid square or target diameter.
</p>
<div id="ref-dist-row" style="display:none;margin-bottom:.75rem;">
<label style="display:block;font-size:.82rem;font-weight:600;color:#444;margin-bottom:.3rem;">Real distance</label>
<div style="display:flex;gap:.4rem;">
<input type="number" id="ref-dist" min="0.1" step="0.1" placeholder="50"
style="flex:1;padding:.4rem .5rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;">
<select id="ref-unit"
style="padding:.4rem .5rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;">
<option value="mm">mm</option>
<option value="cm">cm</option>
<option value="in">in</option>
</select>
</div>
</div>
<div style="display:flex;gap:.5rem;flex-wrap:wrap;">
<button class="btn-primary" id="btn-next-0" disabled onclick="goStep(1)">Next →</button>
<button class="btn-ghost" onclick="resetRef()">Reset</button>
</div>
</div>
{# Step 1: POA #}
<div id="panel-1" class="step-panel" style="display:none;">
<p style="font-size:0.84rem;color:#555;margin-bottom:.75rem;line-height:1.5;">
Click your <strong>Point of Aim</strong> — the center of the target or wherever you were aiming.
</p>
<div style="display:flex;gap:.5rem;flex-wrap:wrap;">
<button class="btn-ghost" onclick="goStep(0)">← Back</button>
<button class="btn-ghost" onclick="poa=null;redraw();">Reset POA</button>
</div>
</div>
{# Step 2: POIs #}
<div id="panel-2" class="step-panel" style="display:none;">
<p style="font-size:0.84rem;color:#555;margin-bottom:.75rem;line-height:1.5;">
Click each <strong>bullet hole</strong>. Click an existing point to remove it.
</p>
<p id="poi-count" style="font-size:0.88rem;font-weight:600;color:#1a1a2e;margin-bottom:.75rem;">0 impacts</p>
<div style="display:flex;gap:.5rem;flex-wrap:wrap;">
<button class="btn-primary" id="btn-compute" disabled onclick="compute()">Compute →</button>
<button class="btn-ghost" onclick="goStep(1)">← Back</button>
<button class="btn-ghost" onclick="undoPoi()">Undo last</button>
</div>
</div>
{# Step 3: Results #}
<div id="panel-3" class="step-panel" style="display:none;">
<div id="results-box" style="margin-bottom:1rem;"></div>
<div style="display:flex;gap:.5rem;flex-wrap:wrap;">
<button class="btn-primary" id="btn-save" onclick="saveAnnotations()">Save &amp; close</button>
<button class="btn-ghost" onclick="goStep(2)">← Edit</button>
</div>
<div id="save-status" style="font-size:0.82rem;margin-top:.5rem;"></div>
</div>
</div>{# end control panel #}
</div>
<style>
.btn-primary {
background: #1a1a2e; color: #fff; border: none; border-radius: 4px;
padding: .5rem 1rem; font-size: 0.88rem; cursor: pointer; font-family: inherit;
}
.btn-primary:disabled { background: #aaa; cursor: not-allowed; }
.btn-ghost {
background: #f0f4ff; color: #1a1a2e; border: 1px solid #c8d4f0; border-radius: 4px;
padding: .5rem 1rem; font-size: 0.88rem; cursor: pointer; font-family: inherit;
}
.stat-row { display: flex; justify-content: space-between; font-size: 0.85rem;
padding: .3rem 0; border-bottom: 1px solid #f0f0f0; }
.stat-label { color: #666; }
.stat-val { font-weight: 600; color: #1a1a2e; }
.stat-section { font-size: 0.78rem; text-transform: uppercase; letter-spacing: .05em;
color: #888; margin: .75rem 0 .35rem; }
</style>
<script>
const PHOTO_URL = {{ photo.photo_url | tojson }};
const SAVE_URL = {{ url_for('sessions.annotate_photo', session_id=session.id, photo_id=photo.id) | tojson }};
const SESSION_URL = {{ url_for('sessions.detail', session_id=session.id) | tojson }};
const SESSION_DIST_M = {{ (session.distance_m or 'null') }};
const EXISTING = {{ (photo.annotations or {}) | tojson }};
// ── State ──────────────────────────────────────────────────────────────────
let step = 0;
// Reference: coords in natural image pixels (fractions stored on save)
let refP1 = null, refP2 = null, refMm = null;
let refClickStage = 0; // 0=waiting p1, 1=waiting p2, 2=done
let poa = null; // natural px
let pois = []; // natural px array
let stats = null;
let mousePos = null; // canvas px, for rubber-band
// ── Canvas / image ─────────────────────────────────────────────────────────
const canvas = document.getElementById('ann-canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = PHOTO_URL;
img.onload = () => { resizeCanvas(); loadExisting(); redraw(); };
function resizeCanvas() {
// Canvas internal size = natural image size (so all coords stay in nat px)
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
}
// Convert canvas mouse event → natural image pixels
function evToNat(e) {
const r = canvas.getBoundingClientRect();
const sx = img.naturalWidth / r.width;
const sy = img.naturalHeight / r.height;
return { x: (e.clientX - r.left) * sx, y: (e.clientY - r.top) * sy };
}
// ── Mouse events ───────────────────────────────────────────────────────────
canvas.addEventListener('mousemove', e => {
if (step === 0 && refClickStage === 1) {
const r = canvas.getBoundingClientRect();
const sx = img.naturalWidth / r.width;
const sy = img.naturalHeight / r.height;
mousePos = { x: (e.clientX - r.left) * sx, y: (e.clientY - r.top) * sy };
redraw();
}
});
canvas.addEventListener('click', e => {
const p = evToNat(e);
if (step === 0) handleRefClick(p);
else if (step === 1) handlePoaClick(p);
else if (step === 2) handlePoiClick(p);
});
canvas.addEventListener('mouseleave', () => { mousePos = null; redraw(); });
// ── Step 0: Reference line ─────────────────────────────────────────────────
function handleRefClick(p) {
if (refClickStage === 0) {
refP1 = p; refP2 = null; refClickStage = 1;
canvas.style.cursor = 'crosshair';
redraw();
} else if (refClickStage === 1) {
refP2 = p; refClickStage = 2; mousePos = null;
document.getElementById('ref-dist-row').style.display = '';
redraw();
updateNextBtn0();
}
}
function resetRef() {
refP1 = refP2 = null; refClickStage = 0; refMm = null; mousePos = null;
document.getElementById('ref-dist-row').style.display = 'none';
document.getElementById('ref-dist').value = '';
updateNextBtn0(); redraw();
}
document.getElementById('ref-dist').addEventListener('input', updateNextBtn0);
function updateNextBtn0() {
const v = parseFloat(document.getElementById('ref-dist').value);
document.getElementById('btn-next-0').disabled = !(refP1 && refP2 && v > 0);
}
// ── Step 1: POA ────────────────────────────────────────────────────────────
function handlePoaClick(p) {
poa = p; redraw();
// Auto-advance to step 2
goStep(2);
}
// ── Step 2: POIs ───────────────────────────────────────────────────────────
const HIT_RADIUS = 14; // canvas display px
function handlePoiClick(p) {
// Check if clicking near an existing POI to remove it
const r = canvas.getBoundingClientRect();
const dispScale = r.width / img.naturalWidth; // nat px → display px
for (let i = pois.length - 1; i >= 0; i--) {
const dx = (pois[i].x - p.x) * dispScale;
const dy = (pois[i].y - p.y) * dispScale;
if (Math.sqrt(dx*dx + dy*dy) < HIT_RADIUS) {
pois.splice(i, 1);
updatePoiUI(); redraw(); return;
}
}
pois.push(p);
updatePoiUI(); redraw();
}
function undoPoi() { if (pois.length) { pois.pop(); updatePoiUI(); redraw(); } }
function updatePoiUI() {
document.getElementById('poi-count').textContent = pois.length + ' impact' + (pois.length !== 1 ? 's' : '');
document.getElementById('btn-compute').disabled = pois.length < 1;
}
// ── Step navigation ────────────────────────────────────────────────────────
function goStep(n) {
// Validate before advancing
if (n === 1) {
const distVal = parseFloat(document.getElementById('ref-dist').value);
const unitSel = document.getElementById('ref-unit').value;
if (!(refP1 && refP2 && distVal > 0)) { alert('Please draw the reference line and enter its distance.'); return; }
refMm = toMm(distVal, unitSel);
}
step = n;
updateStepUI(); redraw();
}
function updateStepUI() {
// Panels
for (let i = 0; i <= 3; i++) {
document.getElementById('panel-' + i).style.display = (i === step) ? '' : 'none';
}
// Step indicators
const labels = ['Reference line', 'Point of Aim', 'Points of Impact', 'Results'];
for (let i = 0; i <= 3; i++) {
const el = document.getElementById('si-' + i);
const num = el.querySelector('.si-num');
if (i < step) {
el.style.background = '#e8f5e9'; el.style.color = '#27ae60';
num.style.background = '#27ae60'; num.style.color = '#fff';
num.textContent = '✓';
} else if (i === step) {
el.style.background = '#f0f4ff'; el.style.color = '#1a1a2e';
num.style.background = '#1a1a2e'; num.style.color = '#fff';
num.textContent = i + 1;
} else {
el.style.background = ''; el.style.color = '#aaa';
num.style.background = '#e0e0e0'; num.style.color = '#888';
num.textContent = i + 1;
}
}
// Cursor
canvas.style.cursor = (step <= 2) ? 'crosshair' : 'default';
}
// ── Computation ────────────────────────────────────────────────────────────
function dist2(a, b) { return Math.sqrt((a.x-b.x)**2 + (a.y-b.y)**2); }
function toMm(val, unit) {
if (unit === 'cm') return val * 10;
if (unit === 'in') return val * 25.4;
return val;
}
function toMoa(sizeMm, distM) {
// true angular MOA
return Math.atan(sizeMm / (distM * 1000)) * (180 / Math.PI * 60);
}
function compute() {
const shootDistEl = document.getElementById('shoot-dist');
const shootUnitEl = document.getElementById('shoot-unit');
let distM = parseFloat(shootDistEl.value);
if (isNaN(distM) || distM <= 0) { alert('Enter a valid shooting distance first.'); shootDistEl.focus(); return; }
if (shootUnitEl.value === 'yd') distM *= 0.9144; // yards → metres
// Scale factor: pixels per mm
const refPxDist = dist2(refP1, refP2);
const pxPerMm = refPxDist / refMm;
// Convert POIs to mm relative to POA
const poisMm = pois.map(p => ({
x: (p.x - poa.x) / pxPerMm,
y: (p.y - poa.y) / pxPerMm,
}));
// Group centre
const cx = poisMm.reduce((s, p) => s + p.x, 0) / poisMm.length;
const cy = poisMm.reduce((s, p) => s + p.y, 0) / poisMm.length;
// Extreme Spread: max pairwise distance
let es = 0, esI = 0, esJ = 0;
for (let i = 0; i < poisMm.length; i++) {
for (let j = i + 1; j < poisMm.length; j++) {
const d = dist2(poisMm[i], poisMm[j]);
if (d > es) { es = d; esI = i; esJ = j; }
}
}
// Mean Radius: average distance from group centre
const mr = poisMm.reduce((s, p) => s + dist2(p, {x:cx,y:cy}), 0) / poisMm.length;
// POA → centre
const poaToCenter = dist2({x:0,y:0}, {x:cx,y:cy});
stats = {
shot_count: pois.length,
group_size_mm: es,
group_size_moa: distM > 0 ? toMoa(es, distM) : null,
mean_radius_mm: mr,
mean_radius_moa: distM > 0 ? toMoa(mr, distM) : null,
center_x_mm: cx, // + = right, - = left
center_y_mm: cy, // + = down, - = up
center_dist_mm: poaToCenter,
center_dist_moa: distM > 0 ? toMoa(poaToCenter, distM) : null,
shooting_distance_m: distM,
es_poi_indices: [esI, esJ],
};
renderResults();
goStep(3);
redraw();
}
function renderResults() {
if (!stats) return;
const f1 = v => (v != null ? v.toFixed(1) : '—');
const f2 = v => (v != null ? v.toFixed(2) : '—');
const sign = v => v >= 0 ? '+' : '';
const dir = (mm, axis) => {
if (axis === 'x') return mm > 0 ? 'right' : mm < 0 ? 'left' : 'center';
return mm > 0 ? 'low' : mm < 0 ? 'high' : 'center';
};
document.getElementById('results-box').innerHTML = `
<div class="stat-section">Group size</div>
<div class="stat-row"><span class="stat-label">Extreme Spread</span>
<span class="stat-val">${f2(stats.group_size_moa)} MOA</span></div>
<div class="stat-row"><span class="stat-label"></span>
<span class="stat-val" style="color:#666;font-weight:400;">${f1(stats.group_size_mm)} mm</span></div>
<div class="stat-section">Precision</div>
<div class="stat-row"><span class="stat-label">Mean Radius</span>
<span class="stat-val">${f2(stats.mean_radius_moa)} MOA</span></div>
<div class="stat-row"><span class="stat-label"></span>
<span class="stat-val" style="color:#666;font-weight:400;">${f1(stats.mean_radius_mm)} mm</span></div>
<div class="stat-section">Center vs POA</div>
<div class="stat-row"><span class="stat-label">Distance</span>
<span class="stat-val">${f2(stats.center_dist_moa)} MOA</span></div>
<div class="stat-row"><span class="stat-label">Horiz.</span>
<span class="stat-val">${f1(Math.abs(stats.center_x_mm))} mm ${dir(stats.center_x_mm,'x')}</span></div>
<div class="stat-row"><span class="stat-label">Vert.</span>
<span class="stat-val">${f1(Math.abs(stats.center_y_mm))} mm ${dir(stats.center_y_mm,'y')}</span></div>
<div class="stat-section">Info</div>
<div class="stat-row"><span class="stat-label">Shots</span>
<span class="stat-val">${stats.shot_count}</span></div>
<div class="stat-row"><span class="stat-label">@ distance</span>
<span class="stat-val">${stats.shooting_distance_m.toFixed(0)} m</span></div>
<div class="stat-row"><span class="stat-label">Clean barrel</span>
<span class="stat-val">${document.getElementById('clean-barrel').checked ? 'Yes' : 'No'}</span></div>
`;
}
// ── Drawing ────────────────────────────────────────────────────────────────
const COLORS = {
ref: '#2196f3',
poa: '#e53935',
poi: '#1565c0',
center: '#ff9800',
es: '#9c27b0',
mr: '#00897b',
};
function lineW(px) {
// px in display pixels → natural pixels
const r = canvas.getBoundingClientRect();
return px * (img.naturalWidth / r.width);
}
function redraw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
const lw = lineW(2);
const dotR = lineW(7);
// Reference line
if (refP1) {
const p2 = refClickStage === 1 && mousePos ? mousePos : refP2;
if (p2) {
ctx.save();
ctx.setLineDash([lineW(8), lineW(5)]);
ctx.strokeStyle = COLORS.ref; ctx.lineWidth = lw;
ctx.beginPath(); ctx.moveTo(refP1.x, refP1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke();
ctx.setLineDash([]);
// Endpoints
drawDot(refP1, dotR * 0.7, COLORS.ref);
if (refP2) {
drawDot(refP2, dotR * 0.7, COLORS.ref);
// Label
const mid = { x: (refP1.x + refP2.x) / 2, y: (refP1.y + refP2.y) / 2 };
drawLabel(mid, refMm ? refMm.toFixed(0) + ' mm' : '?', COLORS.ref, lineW(12));
}
ctx.restore();
} else {
drawDot(refP1, dotR * 0.7, COLORS.ref);
}
}
// POA
if (poa) {
const r = dotR * 1.3;
ctx.save();
ctx.strokeStyle = COLORS.poa; ctx.lineWidth = lw * 1.5;
// Circle
ctx.beginPath(); ctx.arc(poa.x, poa.y, r, 0, Math.PI * 2); ctx.stroke();
// Crosshair
ctx.beginPath();
ctx.moveTo(poa.x - r * 1.6, poa.y); ctx.lineTo(poa.x - r * 0.4, poa.y);
ctx.moveTo(poa.x + r * 0.4, poa.y); ctx.lineTo(poa.x + r * 1.6, poa.y);
ctx.moveTo(poa.x, poa.y - r * 1.6); ctx.lineTo(poa.x, poa.y - r * 0.4);
ctx.moveTo(poa.x, poa.y + r * 0.4); ctx.lineTo(poa.x, poa.y + r * 1.6);
ctx.stroke();
ctx.restore();
}
// Group overlay (if computed)
if (stats && poa) {
const pxPerMm = dist2(refP1, refP2) / refMm;
const cx = poa.x + stats.center_x_mm * pxPerMm;
const cy = poa.y + stats.center_y_mm * pxPerMm;
// Mean radius circle
const mrPx = stats.mean_radius_mm * pxPerMm;
ctx.save();
ctx.setLineDash([lineW(6), lineW(4)]);
ctx.strokeStyle = COLORS.mr; ctx.lineWidth = lw;
ctx.beginPath(); ctx.arc(cx, cy, mrPx, 0, Math.PI*2); ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
// ES line between furthest pair
if (stats.shot_count >= 2) {
const [ei, ej] = stats.es_poi_indices;
ctx.save();
ctx.strokeStyle = COLORS.es; ctx.lineWidth = lw;
ctx.beginPath();
ctx.moveTo(pois[ei].x, pois[ei].y);
ctx.lineTo(pois[ej].x, pois[ej].y);
ctx.stroke();
ctx.restore();
}
// Group centre
drawDot({x:cx,y:cy}, dotR * 0.8, COLORS.center);
// Line POA → centre
if (dist2(poa, {x:cx,y:cy}) > dotR) {
ctx.save();
ctx.strokeStyle = COLORS.center; ctx.lineWidth = lw * 0.7;
ctx.setLineDash([lineW(4), lineW(3)]);
ctx.beginPath(); ctx.moveTo(poa.x, poa.y); ctx.lineTo(cx, cy); ctx.stroke();
ctx.restore();
}
}
// POIs
pois.forEach((p, i) => {
drawDot(p, dotR, COLORS.poi);
drawLabel(p, String(i + 1), '#fff', dotR * 0.85);
});
}
function drawDot(p, r, color) {
ctx.save();
ctx.fillStyle = color;
ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
function drawLabel(p, text, color, size) {
ctx.save();
ctx.fillStyle = color;
ctx.font = `bold ${size}px system-ui,sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(text, p.x, p.y);
ctx.restore();
}
// ── Save ───────────────────────────────────────────────────────────────────
async function saveAnnotations() {
const btn = document.getElementById('btn-save');
const status = document.getElementById('save-status');
btn.disabled = true;
status.textContent = 'Saving…';
const refDistVal = parseFloat(document.getElementById('ref-dist').value);
const refUnitVal = document.getElementById('ref-unit').value;
// Store coords as fractions of natural image size for portability
function toFrac(p) { return { x: p.x / img.naturalWidth, y: p.y / img.naturalHeight }; }
const payload = {
ref: { p1: toFrac(refP1), p2: toFrac(refP2), dist_value: refDistVal, dist_unit: refUnitVal, dist_mm: refMm },
poa: toFrac(poa),
pois: pois.map(toFrac),
shooting_distance_m: stats.shooting_distance_m,
clean_barrel: document.getElementById('clean-barrel').checked,
stats: stats,
};
try {
const resp = await fetch(SAVE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (resp.ok) {
window.location.href = SESSION_URL;
} else {
throw new Error('Server error');
}
} catch {
status.style.color = '#e53935';
status.textContent = 'Save failed.';
btn.disabled = false;
}
}
// ── Load existing annotations ──────────────────────────────────────────────
function loadExisting() {
// Always pre-fill shooting distance from session if available
if (SESSION_DIST_M) {
document.getElementById('shoot-dist').value = SESSION_DIST_M;
document.getElementById('shoot-unit').value = 'm';
}
if (!EXISTING || !EXISTING.ref) return;
const W = img.naturalWidth, H = img.naturalHeight;
function fromFrac(f) { return { x: f.x * W, y: f.y * H }; }
refP1 = fromFrac(EXISTING.ref.p1);
refP2 = fromFrac(EXISTING.ref.p2);
refMm = EXISTING.ref.dist_mm;
refClickStage = 2;
document.getElementById('ref-dist').value = EXISTING.ref.dist_value || '';
document.getElementById('ref-unit').value = EXISTING.ref.dist_unit || 'mm';
document.getElementById('ref-dist-row').style.display = '';
updateNextBtn0();
if (EXISTING.poa) poa = fromFrac(EXISTING.poa);
if (EXISTING.pois) pois = EXISTING.pois.map(fromFrac);
if (EXISTING.shooting_distance_m) {
document.getElementById('shoot-dist').value = EXISTING.shooting_distance_m.toFixed(0);
document.getElementById('shoot-unit').value = 'm';
}
if (EXISTING.clean_barrel) {
document.getElementById('clean-barrel').checked = true;
}
if (EXISTING.stats) {
stats = EXISTING.stats;
renderResults();
goStep(3);
} else if (pois.length > 0) {
goStep(2); updatePoiUI();
} else if (poa) {
goStep(2);
} else {
goStep(0);
}
redraw();
}
// ── Init ───────────────────────────────────────────────────────────────────
updateStepUI();
updatePoiUI();
</script>
{% endblock %}