CookbookLabs and workshopsNo. 29
A lifting technique manualcues, faults and spotting for the big lifts
See it in know.sh
The bench press
An animated, anatomically sensible side view of the bench press. Space plays or pauses, arrow keys step frames, and 1, 2, or 3 set playback speed. Toggle teaching overlays and common faults to compare bar paths and joint stacking.
<div class="bench-plate" tabindex="0" aria-label="Bench press mechanics. Space plays or pauses, left and right arrow keys step frames, 1 2 and 3 change speed.">
<canvas aria-hidden="true"></canvas>
<div class="readouts" aria-label="Lift metrics">
<div><span class="lbl">Phase</span><b data-k="phase">Set</b></div>
<div><span class="lbl">Load</span><b data-k="load">100 kg</b></div>
<div><span class="lbl">Bar speed</span><b data-k="speed">0.00 m/s</b></div>
<div><span class="lbl">TUT</span><b data-k="tut">0.0 s</b></div>
<div><span class="lbl">Elbow</span><b data-k="elbow">170°</b></div>
<div><span class="lbl">Arm flare</span><b data-k="flare">60°</b></div>
</div>
<div class="caption-row">
<span class="muscles" data-k="muscles">Pectorals, anterior deltoids, triceps</span>
</div>
<div class="controls" aria-label="Playback controls">
<div class="btn-group">
<button type="button" data-act="play" aria-label="Play or pause repetition">Pause</button>
<button type="button" data-act="step-back" aria-label="Step backward one frame">←</button>
<button type="button" data-act="step-fwd" aria-label="Step forward one frame">→</button>
</div>
<label class="scrub-wrap" for="scrubber">
<span class="sr-only">Repetition progress scrubber</span>
<input type="range" id="scrubber" data-act="scrub" min="0" max="1000" step="1" value="0">
</label>
<div class="btn-group speeds" role="group" aria-label="Playback speed">
<button type="button" data-speed="0.25">0.25×</button>
<button type="button" data-speed="0.5">0.5×</button>
<button type="button" data-speed="1" aria-pressed="true">1×</button>
</div>
</div>
<div class="options-bar" aria-label="Overlays and load">
<div class="toggles" role="group" aria-label="Teaching overlays">
<label><input type="checkbox" data-toggle="trace" checked> Path trace</label>
<label><input type="checkbox" data-toggle="angles" checked> Angles</label>
<label><input type="checkbox" data-toggle="plumb" checked> Forearm vertical</label>
<label><input type="checkbox" data-toggle="force" checked> Force arrow</label>
</div>
<div class="load-select">
<label for="load-input">Load</label>
<select id="load-input" data-act="load">
<option value="60">60 kg (warmup)</option>
<option value="100" selected>100 kg (standard)</option>
<option value="140">140 kg (heavy)</option>
<option value="180">180 kg (elite)</option>
</select>
</div>
</div>
<div class="fault-row">
<label for="fault-select">Technique fault</label>
<select id="fault-select" data-act="fault">
<option value="none" selected>Standard technique (clean J-curve)</option>
<option value="flare-90">Elbows flared to 90° (high shoulder strain)</option>
<option value="bounce">Bouncing off chest (loss of tension)</option>
<option value="hips">Hips lifting off bench (leverage cheat)</option>
<option value="drift-face">Path drifting over face (shoulder lever arm)</option>
<option value="drift-belly">Path drifting down to belly (loss of stack)</option>
<option value="wrists">Wrists bent back (carpal shear)</option>
</select>
<span class="correction" data-k="correction">Drive the bar off the lower sternum in a smooth J-curve back over the shoulder joint.</span>
</div>
</div>.bench-plate { position: relative; outline: none; font-family: var(--sans); color: var(--ink); background: var(--paper); }
.bench-plate:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); border: 0; }
canvas { display: block; width: 100%; aspect-ratio: 6 / 5; border-bottom: 1px solid var(--rule); background: var(--paper); }
.readouts { display: grid; grid-template-columns: repeat(auto-fit, minmax(85px, 1fr)); gap: 6px 14px; margin: 10px 0 6px; font-variant-numeric: tabular-nums; }
.readouts div { display: flex; flex-direction: column; }
.readouts .lbl { font: 400 11px/1.2 var(--sans); color: var(--ink-2); }
.readouts b { font: 600 15px/1.2 var(--sans); color: var(--ink); }
.caption-row { min-height: 1.5em; margin: 4px 0 10px; font: italic 400 14px/1.4 var(--serif); color: var(--ink-2); }
.controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-bottom: 10px; }
.btn-group { display: inline-flex; }
.btn-group button { min-height: 44px; min-width: 44px; padding: 0 14px; font: 500 14px/1 var(--sans); color: var(--ink); background: var(--paper); border: 1px solid var(--ink); border-radius: 0; cursor: pointer; user-select: none; }
.btn-group button + button { border-left: none; }
.btn-group button[aria-pressed='true'] { background: var(--ink); color: var(--paper); }
.scrub-wrap { flex: 1; min-width: 140px; display: flex; align-items: center; }
.scrub-wrap input[type=range] { width: 100%; height: 28px; accent-color: var(--ink); cursor: pointer; }
.options-bar { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 10px 18px; padding: 8px 0; border-top: 1px solid var(--rule); border-bottom: 1px solid var(--rule); font-size: 13px; color: var(--ink-2); }
.toggles { display: flex; flex-wrap: wrap; gap: 8px 14px; }
.toggles label { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; color: var(--ink); user-select: none; }
.load-select { display: inline-flex; align-items: center; gap: 8px; }
select { min-height: 36px; padding: 4px 8px; font: 400 13px var(--sans); color: var(--ink); background: var(--paper); border: 1px solid var(--rule); border-radius: 0; cursor: pointer; }
select:focus-visible { outline: 2px solid var(--ink); }
.fault-row { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 12px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--rule); }
.fault-row label { font: 500 13px var(--sans); color: var(--ink); white-space: nowrap; }
.correction { font: italic 400 13px/1.4 var(--serif); color: var(--ink-2); flex: 1; min-width: 220px; }
@media (max-width: 520px) {
.readouts { grid-template-columns: repeat(3, 1fr); }
.controls { flex-direction: column; align-items: stretch; }
.btn-group { width: 100%; }
.btn-group button { flex: 1; }
.options-bar { flex-direction: column; align-items: flex-start; }
}(function () {
var DEFAULT_BENCH_CONFIG = {
shoulder: { x: 216, y: 226 },
upperArmLength: 72,
forearmLength: 72,
rackPos: { x: 175, y: 120 },
lockoutPos: { x: 216, y: 82.5 },
touchPos: { x: 260, y: 188 }
};
var DEFAULT_TEMPO = {
unrack: 1.2,
set: 0.8,
descent: 3.0,
pause: 1.0,
press: 1.2,
lockout: 0.8,
rerack: 1.2
};
var CANVAS_VIEW = {
x: 120,
y: 0,
w: 390,
h: 325
};
function getTotalRepDuration(tempo) {
tempo = tempo || DEFAULT_TEMPO;
return tempo.unrack + tempo.set + tempo.descent + tempo.pause + tempo.press + tempo.lockout + tempo.rerack;
}
function getPhaseAtTime(time, tempo) {
tempo = tempo || DEFAULT_TEMPO;
var totalDuration = getTotalRepDuration(tempo);
var clampedTime = Math.max(0, Math.min(time, totalDuration));
var phases = [
{ phase: 'unrack', duration: tempo.unrack },
{ phase: 'set', duration: tempo.set },
{ phase: 'descent', duration: tempo.descent },
{ phase: 'pause', duration: tempo.pause },
{ phase: 'press', duration: tempo.press },
{ phase: 'lockout', duration: tempo.lockout },
{ phase: 'rerack', duration: tempo.rerack }
];
var accumulated = 0;
for (var i = 0; i < phases.length; i++) {
var item = phases[i];
if (clampedTime <= accumulated + item.duration || item.phase === 'rerack') {
var phaseElapsed = clampedTime - accumulated;
var phaseProgress = item.duration > 0 ? Math.min(1, Math.max(0, phaseElapsed / item.duration)) : 1;
return {
phase: item.phase,
phaseProgress: phaseProgress,
phaseElapsed: phaseElapsed,
phaseDuration: item.duration,
totalProgress: totalDuration > 0 ? clampedTime / totalDuration : 1,
totalDuration: totalDuration
};
}
accumulated += item.duration;
}
return {
phase: 'rerack',
phaseProgress: 1,
phaseElapsed: tempo.rerack,
phaseDuration: tempo.rerack,
totalProgress: 1,
totalDuration: totalDuration
};
}
function computeBarPosition(phase, progress, config, fault) {
config = config || DEFAULT_BENCH_CONFIG;
fault = fault || 'none';
var rackPos = config.rackPos;
var lockoutPos = config.lockoutPos;
var touchPos = config.touchPos;
if (fault === 'flare-90') {
touchPos = { x: 224, y: 182 };
} else if (fault === 'drift-face') {
touchPos = { x: 198, y: 178 };
} else if (fault === 'drift-belly') {
touchPos = { x: 300, y: 204 };
}
var p = Math.min(1, Math.max(0, progress));
if (phase === 'unrack') {
var hookClear = Math.sin(p * Math.PI) * 6;
return {
x: rackPos.x + (lockoutPos.x - rackPos.x) * p,
y: rackPos.y + (lockoutPos.y - rackPos.y) * p - hookClear
};
}
if (phase === 'set' || phase === 'lockout') {
return { x: lockoutPos.x, y: lockoutPos.y };
}
if (phase === 'descent') {
return {
x: lockoutPos.x + (touchPos.x - lockoutPos.x) * p,
y: lockoutPos.y + (touchPos.y - lockoutPos.y) * p
};
}
if (phase === 'pause') {
if (fault === 'bounce') {
var bounceDip = Math.sin(p * Math.PI) * 10;
return { x: touchPos.x, y: touchPos.y + bounceDip };
}
return { x: touchPos.x, y: touchPos.y };
}
if (phase === 'press') {
var y = touchPos.y + (lockoutPos.y - touchPos.y) * p;
var jCurveFactor = 2 * p - p * p;
var x = touchPos.x - (touchPos.x - lockoutPos.x) * jCurveFactor;
return { x: x, y: y };
}
if (phase === 'rerack') {
var rhookClear = Math.sin(p * Math.PI) * 6;
return {
x: lockoutPos.x + (rackPos.x - lockoutPos.x) * p,
y: lockoutPos.y + (rackPos.y - lockoutPos.y) * p - rhookClear
};
}
return { x: lockoutPos.x, y: lockoutPos.y };
}
function solveIK(shoulder, target, l1, l2, fault) {
fault = fault || 'none';
var pExt = Math.min(1, Math.max(0, (200 - target.y) / (200 - 82.5)));
var ex = target.x;
var ey = target.y + 64 + 8 * pExt;
if (fault === 'flare-90') {
ex = shoulder.x - 16 * (1 - pExt);
ey = target.y + 62 + 10 * pExt;
} else if (fault === 'drift-belly') {
ex = Math.min(target.x - 42 * (1 - pExt), 258);
ey = target.y + 64 + 8 * pExt;
} else if (fault === 'drift-face') {
ex = shoulder.x + 10 * (1 - pExt);
ey = target.y + 64 + 8 * pExt;
}
return {
elbow: { x: ex, y: ey },
wrist: { x: target.x, y: target.y },
reachable: true
};
}
function calcElbowAngle(shoulder, elbow, wrist) {
var v1x = shoulder.x - elbow.x;
var v1y = shoulder.y - elbow.y;
var v2x = wrist.x - elbow.x;
var v2y = wrist.y - elbow.y;
var dot = v1x * v2x + v1y * v2y;
var m1 = Math.hypot(v1x, v1y);
var m2 = Math.hypot(v2x, v2y);
if (m1 < 1e-6 || m2 < 1e-6) return 180;
var cosAngle = Math.min(Math.max(dot / (m1 * m2), -1), 1);
return Math.round((Math.acos(cosAngle) * 180) / Math.PI);
}
function getForearmDeviation(wrist, elbow) {
var fdx = wrist.x - elbow.x;
var fdy = wrist.y - elbow.y;
var radFromVert = Math.atan2(fdx, -fdy);
return Math.abs((radFromVert * 180) / Math.PI);
}
function getBarPathPoints(config, steps) {
config = config || DEFAULT_BENCH_CONFIG;
steps = steps || 30;
var jcurve = [];
var vertical = [];
for (var i = 0; i <= steps; i++) {
var p = i / steps;
jcurve.push(computeBarPosition('press', p, config, 'none'));
vertical.push({
x: config.touchPos.x,
y: config.touchPos.y + (config.lockoutPos.y - config.touchPos.y) * p
});
}
return { jcurve: jcurve, vertical: vertical };
}
function computeLabelBoxes(state, config, view) {
config = config || DEFAULT_BENCH_CONFIG;
view = view || CANVAS_VIEW;
var barPos = computeBarPosition(state.phase, state.progress, config, state.fault);
var wristTarget = barPos;
if (state.fault === 'wrists') {
wristTarget = { x: barPos.x + 8, y: barPos.y + 6 };
}
var ik = solveIK(config.shoulder, wristTarget, config.upperArmLength, config.forearmLength, state.fault);
var elbowDeg = calcElbowAngle(config.shoulder, ik.elbow, ik.wrist);
var toggles = {
trace: true,
angles: true,
plumb: true,
force: true
};
if (state.toggles) {
if (typeof state.toggles.trace !== 'undefined') toggles.trace = !!state.toggles.trace;
if (typeof state.toggles.angles !== 'undefined') toggles.angles = !!state.toggles.angles;
if (typeof state.toggles.plumb !== 'undefined') toggles.plumb = !!state.toggles.plumb;
if (typeof state.toggles.force !== 'undefined') toggles.force = !!state.toggles.force;
}
var boxes = [];
// 1. Top view caption (inside canvas with >= 6 units margin)
boxes.push({
id: 'top-view-caption',
text: 'Top view, flare',
x: 430 - 40,
y: 8,
width: 80,
height: 12
});
// 2. Flare readout
var flareDeg = state.fault === 'flare-90' ? 90 : 60;
boxes.push({
id: 'flare-angle',
text: flareDeg + '° flare',
x: 430 - 27,
y: 88,
width: 54,
height: 12
});
// 3. J-curve path & vertical path labels
if (toggles.trace) {
boxes.push({
id: 'jcurve',
text: 'J-curve path',
x: 130,
y: 24,
width: 66,
height: 12
});
boxes.push({
id: 'vertical',
text: 'Vertical path',
x: 266,
y: 16,
width: 68,
height: 12
});
}
// 4. Forearm plumb
if (toggles.plumb) {
var dev = Math.round(getForearmDeviation(ik.wrist, ik.elbow));
var plumbText = dev <= 1 ? 'Forearm vertical' : 'Forearm ' + dev + '° off vertical';
var plumbWidth = plumbText.length * 5.5 + 8;
var minPlumbX = view.x + 6;
var maxPlumbX = view.x + view.w - 6 - plumbWidth;
var plumbX = Math.max(minPlumbX, Math.min(maxPlumbX, ik.wrist.x - plumbWidth / 2));
boxes.push({
id: 'plumb',
text: plumbText,
x: plumbX,
y: 296,
width: plumbWidth,
height: 12
});
}
// 5. Elbow angle
if (toggles.angles) {
var rawElbowX = ik.elbow.x - 12 - 24;
var minElbowX = view.x + 6;
var maxElbowX = view.x + view.w - 6 - 24;
var elbowX = Math.max(minElbowX, Math.min(maxElbowX, rawElbowX));
boxes.push({
id: 'elbow',
text: elbowDeg + '°',
x: elbowX,
y: ik.elbow.y + 2,
width: 24,
height: 12
});
}
// 6. Force arrow label
if (toggles.force) {
var forceLen = 0;
if (state.phase === 'press') forceLen = 28;
else if (state.phase === 'set' || state.phase === 'lockout') forceLen = 18;
else if (state.phase === 'descent') forceLen = 12;
else if (state.phase === 'pause') forceLen = 10;
if (forceLen > 0) {
var arrowTipY = barPos.y - 27 - forceLen;
boxes.push({
id: 'force',
text: 'Force',
x: barPos.x + 6,
y: arrowTipY - 2,
width: 28,
height: 12
});
}
}
return boxes;
}
var root = document.querySelector('.bench-plate');
var canvas = root.querySelector('canvas');
var ctx = canvas.getContext('2d');
var hud = {
phase: root.querySelector('[data-k="phase"]'),
load: root.querySelector('[data-k="load"]'),
speed: root.querySelector('[data-k="speed"]'),
tut: root.querySelector('[data-k="tut"]'),
elbow: root.querySelector('[data-k="elbow"]'),
flare: root.querySelector('[data-k="flare"]'),
muscles: root.querySelector('[data-k="muscles"]'),
correction: root.querySelector('[data-k="correction"]')
};
var scrubber = root.querySelector('[data-act="scrub"]');
var playBtn = root.querySelector('[data-act="play"]');
var faultSelect = root.querySelector('[data-act="fault"]');
var loadSelect = root.querySelector('[data-act="load"]');
var toggles = {
trace: root.querySelector('[data-toggle="trace"]'),
angles: root.querySelector('[data-toggle="angles"]'),
plumb: root.querySelector('[data-toggle="plumb"]'),
force: root.querySelector('[data-toggle="force"]')
};
var speedBtns = root.querySelectorAll('[data-speed]');
var totalRepTime = getTotalRepDuration(DEFAULT_TEMPO);
var state = {
playing: true,
time: 0,
speed: 1,
load: 100,
fault: 'none',
lastStamp: 0,
tutTotal: 0
};
// Expose model and state for tests / host inspection
window.__bench = {
config: DEFAULT_BENCH_CONFIG,
DEFAULT_BENCH_CONFIG: DEFAULT_BENCH_CONFIG,
tempo: DEFAULT_TEMPO,
DEFAULT_TEMPO: DEFAULT_TEMPO,
view: CANVAS_VIEW,
CANVAS_VIEW: CANVAS_VIEW,
getTotalRepDuration: getTotalRepDuration,
getPhaseAtTime: getPhaseAtTime,
computeBarPosition: computeBarPosition,
solveIK: solveIK,
solve2LinkIK: solveIK,
calcElbowAngle: calcElbowAngle,
computeElbowInteriorAngle: calcElbowAngle,
getForearmDeviation: getForearmDeviation,
getBarPathPoints: getBarPathPoints,
computeLabelBoxes: computeLabelBoxes,
labelBoxes: computeLabelBoxes,
getState: function () { return state; }
};
// Respect prefers-reduced-motion
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
state.playing = false;
if (playBtn) playBtn.textContent = 'Play';
}
function ink(token, fallback) {
return getComputedStyle(document.documentElement).getPropertyValue(token).trim() || fallback;
}
var CORRECTIONS = {
'none': 'Drive the bar off the lower sternum in a smooth J-curve back over the shoulder joint.',
'flare-90': 'Tuck elbows to 45–70° from torso to protect the rotator cuff and recruit lats.',
'bounce': 'Pause motion at contact with soft touch; keep tension without sinking into ribs.',
'hips': 'Drive through feet while keeping glutes pinned firmly to the bench pad.',
'drift-face': 'Keep bar path directed to the lower sternum; avoid drifting back over throat early.',
'drift-belly': 'Do not dump the bar on the abdomen; touch at the nipple line so the forearms stay stacked.',
'wrists': 'Stack the bar directly over the heel of the palm and vertical forearm bones.'
};
var MUSCLE_MAP = {
'unrack': 'Latissimus dorsi, triceps, anterior deltoids',
'set': 'Pectorals, anterior deltoids, triceps isometric hold',
'descent': 'Pectorals (eccentric lengthening), anterior deltoids, lats stabilizing',
'pause': 'Pectorals, anterior deltoids isometric tension at chest touch',
'press': 'Pectorals driving off chest, anterior deltoids, triceps extending to lockout',
'lockout': 'Triceps, serratus anterior, anterior deltoids stabilizing',
'rerack': 'Latissimus dorsi, deltoids guiding bar onto J-hooks'
};
function updateReadouts(phase, barPos, elbowDeg, dt) {
hud.phase.textContent = phase.phase.charAt(0).toUpperCase() + phase.phase.slice(1);
hud.load.textContent = state.load + ' kg';
hud.elbow.textContent = elbowDeg + '°';
var flareVal = state.fault === 'flare-90' ? '90°' : '60°';
hud.flare.textContent = flareVal;
var speedVal = '0.00 m/s';
if (phase.phase === 'press') {
var romMetres = 0.42;
var dyFrac = 2 * (1 - phase.phaseProgress); // derivative of 2p - p^2
var instSpeed = (romMetres / DEFAULT_TEMPO.press) * Math.max(0.2, dyFrac);
speedVal = instSpeed.toFixed(2) + ' m/s';
} else if (phase.phase === 'descent') {
speedVal = (0.42 / DEFAULT_TEMPO.descent).toFixed(2) + ' m/s';
}
hud.speed.textContent = speedVal;
if (state.playing && phase.phase !== 'unrack' && phase.phase !== 'rerack') {
state.tutTotal += dt;
}
hud.tut.textContent = state.tutTotal.toFixed(1) + ' s';
hud.muscles.textContent = MUSCLE_MAP[phase.phase] || '';
hud.correction.textContent = CORRECTIONS[state.fault] || '';
if (scrubber && !scrubber.matches(':active')) {
scrubber.value = Math.round((state.time / totalRepTime) * 1000);
}
}
function draw(barPos, ik, phase, elbowDeg) {
var dpr = window.devicePixelRatio || 1;
var cw = canvas.clientWidth, ch = canvas.clientHeight;
if (canvas.width !== Math.round(cw * dpr) || canvas.height !== Math.round(ch * dpr)) {
canvas.width = Math.round(cw * dpr);
canvas.height = Math.round(ch * dpr);
}
var s = canvas.width / CANVAS_VIEW.w;
var paper = ink('--paper', '#fbfbf9');
var black = ink('--ink', '#000000');
var ink2 = ink('--ink-2', '#45453f');
var ink3 = ink('--ink-3', '#66665f');
var rule = ink('--rule', '#d8d8d2');
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = paper;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.setTransform(s, 0, 0, s, -CANVAS_VIEW.x * s, -CANVAS_VIEW.y * s);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
function drawLabel(text, lx, ly, opts) {
opts = opts || {};
ctx.save();
ctx.font = opts.font || '400 10px sans-serif';
ctx.textAlign = opts.align || 'left';
ctx.textBaseline = opts.baseline || 'top';
ctx.strokeStyle = paper;
ctx.lineWidth = opts.halo || 4;
ctx.lineJoin = 'round';
ctx.strokeText(text, lx, ly);
ctx.fillStyle = opts.color || black;
ctx.fillText(text, lx, ly);
ctx.restore();
}
// 1. Floor line
ctx.strokeStyle = rule;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(120, 320);
ctx.lineTo(510, 320);
ctx.stroke();
// 2. Bench & Rack Uprights
var benchY = 240;
var padThickness = 12;
ctx.fillStyle = paper;
ctx.strokeStyle = black;
ctx.lineWidth = 1.5;
ctx.fillRect(140, benchY, 320, padThickness);
ctx.strokeRect(140, benchY, 320, padThickness);
ctx.beginPath();
ctx.moveTo(170, benchY + padThickness);
ctx.lineTo(170, 320);
ctx.moveTo(430, benchY + padThickness);
ctx.lineTo(430, 320);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(150, 320); ctx.lineTo(190, 320);
ctx.moveTo(410, 320); ctx.lineTo(450, 320);
ctx.stroke();
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(155, 100);
ctx.lineTo(155, 320);
ctx.moveTo(140, 320);
ctx.lineTo(170, 320);
ctx.stroke();
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(155, 128);
ctx.lineTo(175, 128);
ctx.lineTo(175, 120);
ctx.stroke();
// 3. Lifter Skeleton
var shoulder = { x: DEFAULT_BENCH_CONFIG.shoulder.x, y: DEFAULT_BENCH_CONFIG.shoulder.y };
var hipY = state.fault === 'hips' ? 220 : 236;
var hip = { x: 345, y: hipY };
var knee = { x: 420, y: 254 };
var ankle = { x: 435, y: 312 };
var footToe = { x: 465, y: 320 };
var footHeel = { x: 425, y: 320 };
ctx.lineWidth = 1.5;
ctx.strokeStyle = black;
ctx.beginPath();
ctx.arc(172, 230, 16, 0, Math.PI * 2);
ctx.fillStyle = paper;
ctx.fill();
ctx.stroke();
ctx.beginPath();
ctx.moveTo(186, 233);
ctx.lineTo(shoulder.x, shoulder.y);
var archPeakY = state.fault === 'hips' ? 208 : 220;
ctx.quadraticCurveTo(280, archPeakY, hip.x, hip.y);
ctx.lineWidth = 3;
ctx.stroke();
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(shoulder.x, shoulder.y);
ctx.quadraticCurveTo(240, 192, 280, 206);
ctx.lineTo(hip.x, hip.y);
ctx.stroke();
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(hip.x, hip.y);
ctx.lineTo(knee.x, knee.y);
ctx.lineTo(ankle.x, ankle.y);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(footHeel.x, footHeel.y);
ctx.lineTo(footToe.x, footToe.y);
ctx.lineTo(ankle.x, ankle.y);
ctx.stroke();
ctx.fillStyle = black;
ctx.beginPath(); ctx.arc(shoulder.x, shoulder.y, 3.5, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(hip.x, hip.y, 3, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(knee.x, knee.y, 3, 0, Math.PI * 2); ctx.fill();
// 4. Arms
var elbow = ik.elbow;
var wrist = ik.wrist;
var handGrip = { x: barPos.x, y: barPos.y };
ctx.lineWidth = 3;
ctx.strokeStyle = black;
ctx.beginPath();
ctx.moveTo(shoulder.x, shoulder.y);
ctx.lineTo(elbow.x, elbow.y);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(elbow.x, elbow.y);
ctx.lineTo(wrist.x, wrist.y);
ctx.stroke();
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(handGrip.x, handGrip.y, 4, 0, Math.PI * 2);
ctx.fillStyle = paper;
ctx.fill();
ctx.stroke();
ctx.fillStyle = black;
ctx.beginPath(); ctx.arc(elbow.x, elbow.y, 3.5, 0, Math.PI * 2); ctx.fill();
// 5. Barbell & Olympic Plates
ctx.fillStyle = black;
ctx.beginPath();
ctx.arc(barPos.x, barPos.y, 5, 0, Math.PI * 2);
ctx.fill();
var plateRadius = 26;
ctx.strokeStyle = black;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(barPos.x, barPos.y, plateRadius, 0, Math.PI * 2);
ctx.stroke();
ctx.lineWidth = 1;
ctx.strokeStyle = ink2;
ctx.beginPath();
ctx.arc(barPos.x, barPos.y, 10, 0, Math.PI * 2);
ctx.stroke();
// 6. Teaching Overlays
// (a) Bar path trace
if (toggles.trace.checked) {
ctx.lineWidth = 1.5;
ctx.strokeStyle = rule;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(DEFAULT_BENCH_CONFIG.touchPos.x, 28);
ctx.lineTo(DEFAULT_BENCH_CONFIG.touchPos.x, 198);
ctx.stroke();
var pathPts = getBarPathPoints(DEFAULT_BENCH_CONFIG, 30);
ctx.strokeStyle = ink3;
ctx.setLineDash([3, 3]);
ctx.beginPath();
for (var sIdx = 0; sIdx < pathPts.jcurve.length; sIdx++) {
var ppos = pathPts.jcurve[sIdx];
if (sIdx === 0) ctx.moveTo(ppos.x, ppos.y);
else ctx.lineTo(ppos.x, ppos.y);
}
ctx.stroke();
ctx.setLineDash([]);
ctx.strokeStyle = rule;
ctx.setLineDash([2, 2]);
ctx.beginPath();
ctx.moveTo(198, 30);
ctx.lineTo(210, 68);
ctx.stroke();
ctx.setLineDash([]);
}
// (b) Plumb line
if (toggles.plumb.checked) {
ctx.strokeStyle = ink3;
ctx.lineWidth = 1;
ctx.setLineDash([2, 3]);
ctx.beginPath();
ctx.moveTo(wrist.x, barPos.y - 10);
ctx.lineTo(wrist.x, 315);
ctx.stroke();
ctx.setLineDash([]);
}
// (c) Elbow arc
if (toggles.angles.checked) {
ctx.strokeStyle = ink2;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(elbow.x, elbow.y, 14, 0, Math.PI * 0.6);
ctx.stroke();
}
// (d) Force arrow
if (toggles.force.checked) {
var forceLen = 0;
if (phase.phase === 'press') forceLen = 28;
else if (phase.phase === 'set' || phase.phase === 'lockout') forceLen = 18;
else if (phase.phase === 'descent') forceLen = 12;
else if (phase.phase === 'pause') forceLen = 10;
if (forceLen > 0) {
var arrowBaseY = barPos.y - 27;
var arrowTipY = arrowBaseY - forceLen;
ctx.strokeStyle = black;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(barPos.x, arrowBaseY);
ctx.lineTo(barPos.x, arrowTipY + 4);
ctx.stroke();
ctx.fillStyle = black;
ctx.beginPath();
ctx.moveTo(barPos.x, arrowTipY);
ctx.lineTo(barPos.x - 4, arrowTipY + 6);
ctx.lineTo(barPos.x + 4, arrowTipY + 6);
ctx.closePath();
ctx.fill();
}
}
// 7. Top-down Inset Diagram
var tCenterX = 430;
var tCenterY = 56;
ctx.strokeStyle = black;
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.ellipse(tCenterX, tCenterY, 15, 24, Math.PI / 2, 0, Math.PI * 2);
ctx.stroke();
ctx.strokeStyle = black;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(tCenterX - 36, tCenterY);
ctx.lineTo(tCenterX + 36, tCenterY);
ctx.stroke();
ctx.fillStyle = black;
ctx.fillRect(tCenterX - 35, tCenterY - 8, 4, 16);
ctx.fillRect(tCenterX + 31, tCenterY - 8, 4, 16);
var flareDeg = state.fault === 'flare-90' ? 90 : 60;
var flareAngleRad = (flareDeg * Math.PI) / 180;
var armLen = 22;
var eL = { x: tCenterX - 15 - Math.sin(flareAngleRad) * armLen, y: tCenterY + Math.cos(flareAngleRad) * armLen };
var eR = { x: tCenterX + 15 + Math.sin(flareAngleRad) * armLen, y: tCenterY + Math.cos(flareAngleRad) * armLen };
ctx.lineWidth = 2;
ctx.strokeStyle = black;
ctx.beginPath();
ctx.moveTo(tCenterX - 14, tCenterY);
ctx.lineTo(eL.x, eL.y);
ctx.lineTo(tCenterX - 24, tCenterY);
ctx.moveTo(tCenterX + 14, tCenterY);
ctx.lineTo(eR.x, eR.y);
ctx.lineTo(tCenterX + 24, tCenterY);
ctx.stroke();
// 8. Render Labels via unified computeLabelBoxes
var boxes = computeLabelBoxes({
phase: phase.phase,
progress: phase.phaseProgress,
fault: state.fault,
toggles: {
trace: toggles.trace.checked,
angles: toggles.angles.checked,
plumb: toggles.plumb.checked,
force: toggles.force.checked
}
}, DEFAULT_BENCH_CONFIG, CANVAS_VIEW);
for (var bIdx = 0; bIdx < boxes.length; bIdx++) {
var box = boxes[bIdx];
if (box.id === 'top-view-caption') {
drawLabel(box.text, box.x + box.width / 2, box.y, { align: 'center', baseline: 'top', font: '500 10px sans-serif', color: ink2, halo: 4 });
} else if (box.id === 'flare-angle') {
drawLabel(box.text, box.x + box.width / 2, box.y, { align: 'center', baseline: 'top', font: '600 10px sans-serif', color: state.fault === 'flare-90' ? black : ink2, halo: 4 });
} else if (box.id === 'jcurve') {
drawLabel(box.text, box.x, box.y, { align: 'left', baseline: 'top', font: '400 10px sans-serif', color: ink3, halo: 5 });
} else if (box.id === 'vertical') {
drawLabel(box.text, box.x, box.y, { align: 'left', baseline: 'top', font: '400 10px sans-serif', color: ink3, halo: 4 });
} else if (box.id === 'plumb') {
drawLabel(box.text, box.x + box.width / 2, box.y, { align: 'center', baseline: 'top', font: '400 10px sans-serif', color: ink2, halo: 5 });
} else if (box.id === 'elbow') {
drawLabel(box.text, box.x + box.width, box.y, { align: 'right', baseline: 'top', font: '500 11px sans-serif', color: black, halo: 4 });
} else if (box.id === 'force') {
drawLabel(box.text, box.x, box.y, { align: 'left', baseline: 'top', font: '500 10px sans-serif', color: black, halo: 4 });
}
}
}
function frame(timestamp) {
if (!state.lastStamp) state.lastStamp = timestamp;
var dt = Math.min(0.1, (timestamp - state.lastStamp) / 1000 || 0);
state.lastStamp = timestamp;
if (state.playing && !document.hidden) {
state.time = (state.time + dt * state.speed) % totalRepTime;
}
var phase = getPhaseAtTime(state.time, DEFAULT_TEMPO);
var barPos = computeBarPosition(phase.phase, phase.phaseProgress, DEFAULT_BENCH_CONFIG, state.fault);
var wristTarget = barPos;
if (state.fault === 'wrists') {
wristTarget = { x: barPos.x + 8, y: barPos.y + 6 };
}
var ik = solveIK(DEFAULT_BENCH_CONFIG.shoulder, wristTarget, DEFAULT_BENCH_CONFIG.upperArmLength, DEFAULT_BENCH_CONFIG.forearmLength, state.fault);
var elbowDeg = calcElbowAngle(DEFAULT_BENCH_CONFIG.shoulder, ik.elbow, ik.wrist);
updateReadouts(phase, barPos, elbowDeg, dt * state.speed);
draw(barPos, ik, phase, elbowDeg);
requestAnimationFrame(frame);
}
// Event handlers
if (playBtn) {
playBtn.addEventListener('click', function () {
state.playing = !state.playing;
playBtn.textContent = state.playing ? 'Pause' : 'Play';
root.focus();
});
}
var stepBackBtn = root.querySelector('[data-act="step-back"]');
if (stepBackBtn) {
stepBackBtn.addEventListener('click', function () {
state.playing = false;
if (playBtn) playBtn.textContent = 'Play';
state.time = (state.time - 0.05 + totalRepTime) % totalRepTime;
root.focus();
});
}
var stepFwdBtn = root.querySelector('[data-act="step-fwd"]');
if (stepFwdBtn) {
stepFwdBtn.addEventListener('click', function () {
state.playing = false;
if (playBtn) playBtn.textContent = 'Play';
state.time = (state.time + 0.05) % totalRepTime;
root.focus();
});
}
if (scrubber) {
scrubber.addEventListener('input', function () {
state.playing = false;
if (playBtn) playBtn.textContent = 'Play';
state.time = (parseInt(scrubber.value, 10) / 1000) * totalRepTime;
});
}
speedBtns.forEach(function (btn) {
btn.addEventListener('click', function () {
speedBtns.forEach(function (b) { b.removeAttribute('aria-pressed'); });
btn.setAttribute('aria-pressed', 'true');
state.speed = parseFloat(btn.dataset.speed);
root.focus();
});
});
if (loadSelect) {
loadSelect.addEventListener('change', function () {
state.load = parseInt(loadSelect.value, 10);
root.focus();
});
}
if (faultSelect) {
faultSelect.addEventListener('change', function () {
state.fault = faultSelect.value;
root.focus();
});
}
// Keyboard navigation
root.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.code === 'Space') {
e.preventDefault();
state.playing = !state.playing;
if (playBtn) playBtn.textContent = state.playing ? 'Pause' : 'Play';
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
state.playing = false;
if (playBtn) playBtn.textContent = 'Play';
state.time = (state.time - 0.05 + totalRepTime) % totalRepTime;
} else if (e.key === 'ArrowRight') {
e.preventDefault();
state.playing = false;
if (playBtn) playBtn.textContent = 'Play';
state.time = (state.time + 0.05) % totalRepTime;
} else if (e.key === '1') {
state.speed = 0.25;
speedBtns.forEach(function (b) { b.setAttribute('aria-pressed', b.dataset.speed === '0.25'); });
} else if (e.key === '2') {
state.speed = 0.5;
speedBtns.forEach(function (b) { b.setAttribute('aria-pressed', b.dataset.speed === '0.5'); });
} else if (e.key === '3') {
state.speed = 1.0;
speedBtns.forEach(function (b) { b.setAttribute('aria-pressed', b.dataset.speed === '1'); });
}
});
canvas.addEventListener('click', function () {
root.focus();
});
requestAnimationFrame(frame);
})();```element widget
{
"title": "The bench press",
"caption": "An animated, anatomically sensible side view of the bench press. Space plays or pauses, arrow keys step frames, and 1, 2, or 3 set playback speed. Toggle teaching overlays and common faults to compare bar paths and joint stacking.",
"height": 640,
"html": "<div class=\"bench-plate\" tabindex=\"0\" aria-label=\"Bench press mechanics. Space plays or pauses, left and right arrow keys step frames, 1 2 and 3 change speed.\">\n <canvas aria-hidden=\"true\"></canvas>\n\n <div class=\"readouts\" aria-label=\"Lift metrics\">\n <div><span class=\"lbl\">Phase</span><b data-k=\"phase\">Set</b></div>\n <div><span class=\"lbl\">Load</span><b data-k=\"load\">100 kg</b></div>\n <div><span class=\"lbl\">Bar speed</span><b data-k=\"speed\">0.00 m/s</b></div>\n <div><span class=\"lbl\">TUT</span><b data-k=\"tut\">0.0 s</b></div>\n <div><span class=\"lbl\">Elbow</span><b data-k=\"elbow\">170°</b></div>\n <div><span class=\"lbl\">Arm flare</span><b data-k=\"flare\">60°</b></div>\n </div>\n\n <div class=\"caption-row\">\n <span class=\"muscles\" data-k=\"muscles\">Pectorals, anterior deltoids, triceps</span>\n </div>\n\n <div class=\"controls\" aria-label=\"Playback controls\">\n <div class=\"btn-group\">\n <button type=\"button\" data-act=\"play\" aria-label=\"Play or pause repetition\">Pause</button>\n <button type=\"button\" data-act=\"step-back\" aria-label=\"Step backward one frame\">←</button>\n <button type=\"button\" data-act=\"step-fwd\" aria-label=\"Step forward one frame\">→</button>\n </div>\n\n <label class=\"scrub-wrap\" for=\"scrubber\">\n <span class=\"sr-only\">Repetition progress scrubber</span>\n <input type=\"range\" id=\"scrubber\" data-act=\"scrub\" min=\"0\" max=\"1000\" step=\"1\" value=\"0\">\n </label>\n\n <div class=\"btn-group speeds\" role=\"group\" aria-label=\"Playback speed\">\n <button type=\"button\" data-speed=\"0.25\">0.25×</button>\n <button type=\"button\" data-speed=\"0.5\">0.5×</button>\n <button type=\"button\" data-speed=\"1\" aria-pressed=\"true\">1×</button>\n </div>\n </div>\n\n <div class=\"options-bar\" aria-label=\"Overlays and load\">\n <div class=\"toggles\" role=\"group\" aria-label=\"Teaching overlays\">\n <label><input type=\"checkbox\" data-toggle=\"trace\" checked> Path trace</label>\n <label><input type=\"checkbox\" data-toggle=\"angles\" checked> Angles</label>\n <label><input type=\"checkbox\" data-toggle=\"plumb\" checked> Forearm vertical</label>\n <label><input type=\"checkbox\" data-toggle=\"force\" checked> Force arrow</label>\n </div>\n\n <div class=\"load-select\">\n <label for=\"load-input\">Load</label>\n <select id=\"load-input\" data-act=\"load\">\n <option value=\"60\">60 kg (warmup)</option>\n <option value=\"100\" selected>100 kg (standard)</option>\n <option value=\"140\">140 kg (heavy)</option>\n <option value=\"180\">180 kg (elite)</option>\n </select>\n </div>\n </div>\n\n <div class=\"fault-row\">\n <label for=\"fault-select\">Technique fault</label>\n <select id=\"fault-select\" data-act=\"fault\">\n <option value=\"none\" selected>Standard technique (clean J-curve)</option>\n <option value=\"flare-90\">Elbows flared to 90° (high shoulder strain)</option>\n <option value=\"bounce\">Bouncing off chest (loss of tension)</option>\n <option value=\"hips\">Hips lifting off bench (leverage cheat)</option>\n <option value=\"drift-face\">Path drifting over face (shoulder lever arm)</option>\n <option value=\"drift-belly\">Path drifting down to belly (loss of stack)</option>\n <option value=\"wrists\">Wrists bent back (carpal shear)</option>\n </select>\n <span class=\"correction\" data-k=\"correction\">Drive the bar off the lower sternum in a smooth J-curve back over the shoulder joint.</span>\n </div>\n</div>",
"css": ".bench-plate { position: relative; outline: none; font-family: var(--sans); color: var(--ink); background: var(--paper); }\n.bench-plate:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }\n.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); border: 0; }\ncanvas { display: block; width: 100%; aspect-ratio: 6 / 5; border-bottom: 1px solid var(--rule); background: var(--paper); }\n.readouts { display: grid; grid-template-columns: repeat(auto-fit, minmax(85px, 1fr)); gap: 6px 14px; margin: 10px 0 6px; font-variant-numeric: tabular-nums; }\n.readouts div { display: flex; flex-direction: column; }\n.readouts .lbl { font: 400 11px/1.2 var(--sans); color: var(--ink-2); }\n.readouts b { font: 600 15px/1.2 var(--sans); color: var(--ink); }\n.caption-row { min-height: 1.5em; margin: 4px 0 10px; font: italic 400 14px/1.4 var(--serif); color: var(--ink-2); }\n.controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-bottom: 10px; }\n.btn-group { display: inline-flex; }\n.btn-group button { min-height: 44px; min-width: 44px; padding: 0 14px; font: 500 14px/1 var(--sans); color: var(--ink); background: var(--paper); border: 1px solid var(--ink); border-radius: 0; cursor: pointer; user-select: none; }\n.btn-group button + button { border-left: none; }\n.btn-group button[aria-pressed='true'] { background: var(--ink); color: var(--paper); }\n.scrub-wrap { flex: 1; min-width: 140px; display: flex; align-items: center; }\n.scrub-wrap input[type=range] { width: 100%; height: 28px; accent-color: var(--ink); cursor: pointer; }\n.options-bar { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 10px 18px; padding: 8px 0; border-top: 1px solid var(--rule); border-bottom: 1px solid var(--rule); font-size: 13px; color: var(--ink-2); }\n.toggles { display: flex; flex-wrap: wrap; gap: 8px 14px; }\n.toggles label { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; color: var(--ink); user-select: none; }\n.load-select { display: inline-flex; align-items: center; gap: 8px; }\nselect { min-height: 36px; padding: 4px 8px; font: 400 13px var(--sans); color: var(--ink); background: var(--paper); border: 1px solid var(--rule); border-radius: 0; cursor: pointer; }\nselect:focus-visible { outline: 2px solid var(--ink); }\n.fault-row { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 12px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--rule); }\n.fault-row label { font: 500 13px var(--sans); color: var(--ink); white-space: nowrap; }\n.correction { font: italic 400 13px/1.4 var(--serif); color: var(--ink-2); flex: 1; min-width: 220px; }\n@media (max-width: 520px) {\n .readouts { grid-template-columns: repeat(3, 1fr); }\n .controls { flex-direction: column; align-items: stretch; }\n .btn-group { width: 100%; }\n .btn-group button { flex: 1; }\n .options-bar { flex-direction: column; align-items: flex-start; }\n}",
"js": "(function () {\n var DEFAULT_BENCH_CONFIG = {\n shoulder: { x: 216, y: 226 },\n upperArmLength: 72,\n forearmLength: 72,\n rackPos: { x: 175, y: 120 },\n lockoutPos: { x: 216, y: 82.5 },\n touchPos: { x: 260, y: 188 }\n};\n\nvar DEFAULT_TEMPO = {\n unrack: 1.2,\n set: 0.8,\n descent: 3.0,\n pause: 1.0,\n press: 1.2,\n lockout: 0.8,\n rerack: 1.2\n};\n\nvar CANVAS_VIEW = {\n x: 120,\n y: 0,\n w: 390,\n h: 325\n};\n\nfunction getTotalRepDuration(tempo) {\n tempo = tempo || DEFAULT_TEMPO;\n return tempo.unrack + tempo.set + tempo.descent + tempo.pause + tempo.press + tempo.lockout + tempo.rerack;\n}\n\nfunction getPhaseAtTime(time, tempo) {\n tempo = tempo || DEFAULT_TEMPO;\n var totalDuration = getTotalRepDuration(tempo);\n var clampedTime = Math.max(0, Math.min(time, totalDuration));\n var phases = [\n { phase: 'unrack', duration: tempo.unrack },\n { phase: 'set', duration: tempo.set },\n { phase: 'descent', duration: tempo.descent },\n { phase: 'pause', duration: tempo.pause },\n { phase: 'press', duration: tempo.press },\n { phase: 'lockout', duration: tempo.lockout },\n { phase: 'rerack', duration: tempo.rerack }\n ];\n var accumulated = 0;\n for (var i = 0; i < phases.length; i++) {\n var item = phases[i];\n if (clampedTime <= accumulated + item.duration || item.phase === 'rerack') {\n var phaseElapsed = clampedTime - accumulated;\n var phaseProgress = item.duration > 0 ? Math.min(1, Math.max(0, phaseElapsed / item.duration)) : 1;\n return {\n phase: item.phase,\n phaseProgress: phaseProgress,\n phaseElapsed: phaseElapsed,\n phaseDuration: item.duration,\n totalProgress: totalDuration > 0 ? clampedTime / totalDuration : 1,\n totalDuration: totalDuration\n };\n }\n accumulated += item.duration;\n }\n return {\n phase: 'rerack',\n phaseProgress: 1,\n phaseElapsed: tempo.rerack,\n phaseDuration: tempo.rerack,\n totalProgress: 1,\n totalDuration: totalDuration\n };\n}\n\nfunction computeBarPosition(phase, progress, config, fault) {\n config = config || DEFAULT_BENCH_CONFIG;\n fault = fault || 'none';\n var rackPos = config.rackPos;\n var lockoutPos = config.lockoutPos;\n var touchPos = config.touchPos;\n\n if (fault === 'flare-90') {\n touchPos = { x: 224, y: 182 };\n } else if (fault === 'drift-face') {\n touchPos = { x: 198, y: 178 };\n } else if (fault === 'drift-belly') {\n touchPos = { x: 300, y: 204 };\n }\n\n var p = Math.min(1, Math.max(0, progress));\n\n if (phase === 'unrack') {\n var hookClear = Math.sin(p * Math.PI) * 6;\n return {\n x: rackPos.x + (lockoutPos.x - rackPos.x) * p,\n y: rackPos.y + (lockoutPos.y - rackPos.y) * p - hookClear\n };\n }\n if (phase === 'set' || phase === 'lockout') {\n return { x: lockoutPos.x, y: lockoutPos.y };\n }\n if (phase === 'descent') {\n return {\n x: lockoutPos.x + (touchPos.x - lockoutPos.x) * p,\n y: lockoutPos.y + (touchPos.y - lockoutPos.y) * p\n };\n }\n if (phase === 'pause') {\n if (fault === 'bounce') {\n var bounceDip = Math.sin(p * Math.PI) * 10;\n return { x: touchPos.x, y: touchPos.y + bounceDip };\n }\n return { x: touchPos.x, y: touchPos.y };\n }\n if (phase === 'press') {\n var y = touchPos.y + (lockoutPos.y - touchPos.y) * p;\n var jCurveFactor = 2 * p - p * p;\n var x = touchPos.x - (touchPos.x - lockoutPos.x) * jCurveFactor;\n return { x: x, y: y };\n }\n if (phase === 'rerack') {\n var rhookClear = Math.sin(p * Math.PI) * 6;\n return {\n x: lockoutPos.x + (rackPos.x - lockoutPos.x) * p,\n y: lockoutPos.y + (rackPos.y - lockoutPos.y) * p - rhookClear\n };\n }\n return { x: lockoutPos.x, y: lockoutPos.y };\n}\n\nfunction solveIK(shoulder, target, l1, l2, fault) {\n fault = fault || 'none';\n var pExt = Math.min(1, Math.max(0, (200 - target.y) / (200 - 82.5)));\n var ex = target.x;\n var ey = target.y + 64 + 8 * pExt;\n\n if (fault === 'flare-90') {\n ex = shoulder.x - 16 * (1 - pExt);\n ey = target.y + 62 + 10 * pExt;\n } else if (fault === 'drift-belly') {\n ex = Math.min(target.x - 42 * (1 - pExt), 258);\n ey = target.y + 64 + 8 * pExt;\n } else if (fault === 'drift-face') {\n ex = shoulder.x + 10 * (1 - pExt);\n ey = target.y + 64 + 8 * pExt;\n }\n\n return {\n elbow: { x: ex, y: ey },\n wrist: { x: target.x, y: target.y },\n reachable: true\n };\n}\n\nfunction calcElbowAngle(shoulder, elbow, wrist) {\n var v1x = shoulder.x - elbow.x;\n var v1y = shoulder.y - elbow.y;\n var v2x = wrist.x - elbow.x;\n var v2y = wrist.y - elbow.y;\n var dot = v1x * v2x + v1y * v2y;\n var m1 = Math.hypot(v1x, v1y);\n var m2 = Math.hypot(v2x, v2y);\n if (m1 < 1e-6 || m2 < 1e-6) return 180;\n var cosAngle = Math.min(Math.max(dot / (m1 * m2), -1), 1);\n return Math.round((Math.acos(cosAngle) * 180) / Math.PI);\n}\n\nfunction getForearmDeviation(wrist, elbow) {\n var fdx = wrist.x - elbow.x;\n var fdy = wrist.y - elbow.y;\n var radFromVert = Math.atan2(fdx, -fdy);\n return Math.abs((radFromVert * 180) / Math.PI);\n}\n\nfunction getBarPathPoints(config, steps) {\n config = config || DEFAULT_BENCH_CONFIG;\n steps = steps || 30;\n var jcurve = [];\n var vertical = [];\n for (var i = 0; i <= steps; i++) {\n var p = i / steps;\n jcurve.push(computeBarPosition('press', p, config, 'none'));\n vertical.push({\n x: config.touchPos.x,\n y: config.touchPos.y + (config.lockoutPos.y - config.touchPos.y) * p\n });\n }\n return { jcurve: jcurve, vertical: vertical };\n}\n\nfunction computeLabelBoxes(state, config, view) {\n config = config || DEFAULT_BENCH_CONFIG;\n view = view || CANVAS_VIEW;\n var barPos = computeBarPosition(state.phase, state.progress, config, state.fault);\n var wristTarget = barPos;\n if (state.fault === 'wrists') {\n wristTarget = { x: barPos.x + 8, y: barPos.y + 6 };\n }\n var ik = solveIK(config.shoulder, wristTarget, config.upperArmLength, config.forearmLength, state.fault);\n var elbowDeg = calcElbowAngle(config.shoulder, ik.elbow, ik.wrist);\n\n var toggles = {\n trace: true,\n angles: true,\n plumb: true,\n force: true\n };\n if (state.toggles) {\n if (typeof state.toggles.trace !== 'undefined') toggles.trace = !!state.toggles.trace;\n if (typeof state.toggles.angles !== 'undefined') toggles.angles = !!state.toggles.angles;\n if (typeof state.toggles.plumb !== 'undefined') toggles.plumb = !!state.toggles.plumb;\n if (typeof state.toggles.force !== 'undefined') toggles.force = !!state.toggles.force;\n }\n\n var boxes = [];\n\n // 1. Top view caption (inside canvas with >= 6 units margin)\n boxes.push({\n id: 'top-view-caption',\n text: 'Top view, flare',\n x: 430 - 40,\n y: 8,\n width: 80,\n height: 12\n });\n\n // 2. Flare readout\n var flareDeg = state.fault === 'flare-90' ? 90 : 60;\n boxes.push({\n id: 'flare-angle',\n text: flareDeg + '° flare',\n x: 430 - 27,\n y: 88,\n width: 54,\n height: 12\n });\n\n // 3. J-curve path & vertical path labels\n if (toggles.trace) {\n boxes.push({\n id: 'jcurve',\n text: 'J-curve path',\n x: 130,\n y: 24,\n width: 66,\n height: 12\n });\n\n boxes.push({\n id: 'vertical',\n text: 'Vertical path',\n x: 266,\n y: 16,\n width: 68,\n height: 12\n });\n }\n\n // 4. Forearm plumb\n if (toggles.plumb) {\n var dev = Math.round(getForearmDeviation(ik.wrist, ik.elbow));\n var plumbText = dev <= 1 ? 'Forearm vertical' : 'Forearm ' + dev + '° off vertical';\n var plumbWidth = plumbText.length * 5.5 + 8;\n var minPlumbX = view.x + 6;\n var maxPlumbX = view.x + view.w - 6 - plumbWidth;\n var plumbX = Math.max(minPlumbX, Math.min(maxPlumbX, ik.wrist.x - plumbWidth / 2));\n boxes.push({\n id: 'plumb',\n text: plumbText,\n x: plumbX,\n y: 296,\n width: plumbWidth,\n height: 12\n });\n }\n\n // 5. Elbow angle\n if (toggles.angles) {\n var rawElbowX = ik.elbow.x - 12 - 24;\n var minElbowX = view.x + 6;\n var maxElbowX = view.x + view.w - 6 - 24;\n var elbowX = Math.max(minElbowX, Math.min(maxElbowX, rawElbowX));\n boxes.push({\n id: 'elbow',\n text: elbowDeg + '°',\n x: elbowX,\n y: ik.elbow.y + 2,\n width: 24,\n height: 12\n });\n }\n\n // 6. Force arrow label\n if (toggles.force) {\n var forceLen = 0;\n if (state.phase === 'press') forceLen = 28;\n else if (state.phase === 'set' || state.phase === 'lockout') forceLen = 18;\n else if (state.phase === 'descent') forceLen = 12;\n else if (state.phase === 'pause') forceLen = 10;\n\n if (forceLen > 0) {\n var arrowTipY = barPos.y - 27 - forceLen;\n boxes.push({\n id: 'force',\n text: 'Force',\n x: barPos.x + 6,\n y: arrowTipY - 2,\n width: 28,\n height: 12\n });\n }\n }\n\n return boxes;\n}\n\n var root = document.querySelector('.bench-plate');\n var canvas = root.querySelector('canvas');\n var ctx = canvas.getContext('2d');\n\n var hud = {\n phase: root.querySelector('[data-k=\"phase\"]'),\n load: root.querySelector('[data-k=\"load\"]'),\n speed: root.querySelector('[data-k=\"speed\"]'),\n tut: root.querySelector('[data-k=\"tut\"]'),\n elbow: root.querySelector('[data-k=\"elbow\"]'),\n flare: root.querySelector('[data-k=\"flare\"]'),\n muscles: root.querySelector('[data-k=\"muscles\"]'),\n correction: root.querySelector('[data-k=\"correction\"]')\n };\n\n var scrubber = root.querySelector('[data-act=\"scrub\"]');\n var playBtn = root.querySelector('[data-act=\"play\"]');\n var faultSelect = root.querySelector('[data-act=\"fault\"]');\n var loadSelect = root.querySelector('[data-act=\"load\"]');\n\n var toggles = {\n trace: root.querySelector('[data-toggle=\"trace\"]'),\n angles: root.querySelector('[data-toggle=\"angles\"]'),\n plumb: root.querySelector('[data-toggle=\"plumb\"]'),\n force: root.querySelector('[data-toggle=\"force\"]')\n };\n\n var speedBtns = root.querySelectorAll('[data-speed]');\n\n var totalRepTime = getTotalRepDuration(DEFAULT_TEMPO);\n\n var state = {\n playing: true,\n time: 0,\n speed: 1,\n load: 100,\n fault: 'none',\n lastStamp: 0,\n tutTotal: 0\n };\n\n // Expose model and state for tests / host inspection\n window.__bench = {\n config: DEFAULT_BENCH_CONFIG,\n DEFAULT_BENCH_CONFIG: DEFAULT_BENCH_CONFIG,\n tempo: DEFAULT_TEMPO,\n DEFAULT_TEMPO: DEFAULT_TEMPO,\n view: CANVAS_VIEW,\n CANVAS_VIEW: CANVAS_VIEW,\n getTotalRepDuration: getTotalRepDuration,\n getPhaseAtTime: getPhaseAtTime,\n computeBarPosition: computeBarPosition,\n solveIK: solveIK,\n solve2LinkIK: solveIK,\n calcElbowAngle: calcElbowAngle,\n computeElbowInteriorAngle: calcElbowAngle,\n getForearmDeviation: getForearmDeviation,\n getBarPathPoints: getBarPathPoints,\n computeLabelBoxes: computeLabelBoxes,\n labelBoxes: computeLabelBoxes,\n getState: function () { return state; }\n };\n\n // Respect prefers-reduced-motion\n if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {\n state.playing = false;\n if (playBtn) playBtn.textContent = 'Play';\n }\n\n function ink(token, fallback) {\n return getComputedStyle(document.documentElement).getPropertyValue(token).trim() || fallback;\n }\n\n var CORRECTIONS = {\n 'none': 'Drive the bar off the lower sternum in a smooth J-curve back over the shoulder joint.',\n 'flare-90': 'Tuck elbows to 45–70° from torso to protect the rotator cuff and recruit lats.',\n 'bounce': 'Pause motion at contact with soft touch; keep tension without sinking into ribs.',\n 'hips': 'Drive through feet while keeping glutes pinned firmly to the bench pad.',\n 'drift-face': 'Keep bar path directed to the lower sternum; avoid drifting back over throat early.',\n 'drift-belly': 'Do not dump the bar on the abdomen; touch at the nipple line so the forearms stay stacked.',\n 'wrists': 'Stack the bar directly over the heel of the palm and vertical forearm bones.'\n };\n\n var MUSCLE_MAP = {\n 'unrack': 'Latissimus dorsi, triceps, anterior deltoids',\n 'set': 'Pectorals, anterior deltoids, triceps isometric hold',\n 'descent': 'Pectorals (eccentric lengthening), anterior deltoids, lats stabilizing',\n 'pause': 'Pectorals, anterior deltoids isometric tension at chest touch',\n 'press': 'Pectorals driving off chest, anterior deltoids, triceps extending to lockout',\n 'lockout': 'Triceps, serratus anterior, anterior deltoids stabilizing',\n 'rerack': 'Latissimus dorsi, deltoids guiding bar onto J-hooks'\n };\n\n function updateReadouts(phase, barPos, elbowDeg, dt) {\n hud.phase.textContent = phase.phase.charAt(0).toUpperCase() + phase.phase.slice(1);\n hud.load.textContent = state.load + ' kg';\n hud.elbow.textContent = elbowDeg + '°';\n\n var flareVal = state.fault === 'flare-90' ? '90°' : '60°';\n hud.flare.textContent = flareVal;\n\n var speedVal = '0.00 m/s';\n if (phase.phase === 'press') {\n var romMetres = 0.42;\n var dyFrac = 2 * (1 - phase.phaseProgress); // derivative of 2p - p^2\n var instSpeed = (romMetres / DEFAULT_TEMPO.press) * Math.max(0.2, dyFrac);\n speedVal = instSpeed.toFixed(2) + ' m/s';\n } else if (phase.phase === 'descent') {\n speedVal = (0.42 / DEFAULT_TEMPO.descent).toFixed(2) + ' m/s';\n }\n hud.speed.textContent = speedVal;\n\n if (state.playing && phase.phase !== 'unrack' && phase.phase !== 'rerack') {\n state.tutTotal += dt;\n }\n hud.tut.textContent = state.tutTotal.toFixed(1) + ' s';\n hud.muscles.textContent = MUSCLE_MAP[phase.phase] || '';\n hud.correction.textContent = CORRECTIONS[state.fault] || '';\n\n if (scrubber && !scrubber.matches(':active')) {\n scrubber.value = Math.round((state.time / totalRepTime) * 1000);\n }\n }\n\n function draw(barPos, ik, phase, elbowDeg) {\n var dpr = window.devicePixelRatio || 1;\n var cw = canvas.clientWidth, ch = canvas.clientHeight;\n if (canvas.width !== Math.round(cw * dpr) || canvas.height !== Math.round(ch * dpr)) {\n canvas.width = Math.round(cw * dpr);\n canvas.height = Math.round(ch * dpr);\n }\n var s = canvas.width / CANVAS_VIEW.w;\n\n var paper = ink('--paper', '#fbfbf9');\n var black = ink('--ink', '#000000');\n var ink2 = ink('--ink-2', '#45453f');\n var ink3 = ink('--ink-3', '#66665f');\n var rule = ink('--rule', '#d8d8d2');\n\n ctx.setTransform(1, 0, 0, 1, 0, 0);\n ctx.fillStyle = paper;\n ctx.fillRect(0, 0, canvas.width, canvas.height);\n\n ctx.setTransform(s, 0, 0, s, -CANVAS_VIEW.x * s, -CANVAS_VIEW.y * s);\n ctx.lineCap = 'round';\n ctx.lineJoin = 'round';\n\n function drawLabel(text, lx, ly, opts) {\n opts = opts || {};\n ctx.save();\n ctx.font = opts.font || '400 10px sans-serif';\n ctx.textAlign = opts.align || 'left';\n ctx.textBaseline = opts.baseline || 'top';\n ctx.strokeStyle = paper;\n ctx.lineWidth = opts.halo || 4;\n ctx.lineJoin = 'round';\n ctx.strokeText(text, lx, ly);\n ctx.fillStyle = opts.color || black;\n ctx.fillText(text, lx, ly);\n ctx.restore();\n }\n\n // 1. Floor line\n ctx.strokeStyle = rule;\n ctx.lineWidth = 1;\n ctx.beginPath();\n ctx.moveTo(120, 320);\n ctx.lineTo(510, 320);\n ctx.stroke();\n\n // 2. Bench & Rack Uprights\n var benchY = 240;\n var padThickness = 12;\n ctx.fillStyle = paper;\n ctx.strokeStyle = black;\n ctx.lineWidth = 1.5;\n ctx.fillRect(140, benchY, 320, padThickness);\n ctx.strokeRect(140, benchY, 320, padThickness);\n\n ctx.beginPath();\n ctx.moveTo(170, benchY + padThickness);\n ctx.lineTo(170, 320);\n ctx.moveTo(430, benchY + padThickness);\n ctx.lineTo(430, 320);\n ctx.stroke();\n\n ctx.beginPath();\n ctx.moveTo(150, 320); ctx.lineTo(190, 320);\n ctx.moveTo(410, 320); ctx.lineTo(450, 320);\n ctx.stroke();\n\n ctx.lineWidth = 2;\n ctx.beginPath();\n ctx.moveTo(155, 100);\n ctx.lineTo(155, 320);\n ctx.moveTo(140, 320);\n ctx.lineTo(170, 320);\n ctx.stroke();\n\n ctx.lineWidth = 2.5;\n ctx.beginPath();\n ctx.moveTo(155, 128);\n ctx.lineTo(175, 128);\n ctx.lineTo(175, 120);\n ctx.stroke();\n\n // 3. Lifter Skeleton\n var shoulder = { x: DEFAULT_BENCH_CONFIG.shoulder.x, y: DEFAULT_BENCH_CONFIG.shoulder.y };\n var hipY = state.fault === 'hips' ? 220 : 236;\n var hip = { x: 345, y: hipY };\n var knee = { x: 420, y: 254 };\n var ankle = { x: 435, y: 312 };\n var footToe = { x: 465, y: 320 };\n var footHeel = { x: 425, y: 320 };\n\n ctx.lineWidth = 1.5;\n ctx.strokeStyle = black;\n ctx.beginPath();\n ctx.arc(172, 230, 16, 0, Math.PI * 2);\n ctx.fillStyle = paper;\n ctx.fill();\n ctx.stroke();\n\n ctx.beginPath();\n ctx.moveTo(186, 233);\n ctx.lineTo(shoulder.x, shoulder.y);\n var archPeakY = state.fault === 'hips' ? 208 : 220;\n ctx.quadraticCurveTo(280, archPeakY, hip.x, hip.y);\n ctx.lineWidth = 3;\n ctx.stroke();\n\n ctx.lineWidth = 1.5;\n ctx.beginPath();\n ctx.moveTo(shoulder.x, shoulder.y);\n ctx.quadraticCurveTo(240, 192, 280, 206);\n ctx.lineTo(hip.x, hip.y);\n ctx.stroke();\n\n ctx.lineWidth = 2.5;\n ctx.beginPath();\n ctx.moveTo(hip.x, hip.y);\n ctx.lineTo(knee.x, knee.y);\n ctx.lineTo(ankle.x, ankle.y);\n ctx.stroke();\n\n ctx.beginPath();\n ctx.moveTo(footHeel.x, footHeel.y);\n ctx.lineTo(footToe.x, footToe.y);\n ctx.lineTo(ankle.x, ankle.y);\n ctx.stroke();\n\n ctx.fillStyle = black;\n ctx.beginPath(); ctx.arc(shoulder.x, shoulder.y, 3.5, 0, Math.PI * 2); ctx.fill();\n ctx.beginPath(); ctx.arc(hip.x, hip.y, 3, 0, Math.PI * 2); ctx.fill();\n ctx.beginPath(); ctx.arc(knee.x, knee.y, 3, 0, Math.PI * 2); ctx.fill();\n\n // 4. Arms\n var elbow = ik.elbow;\n var wrist = ik.wrist;\n var handGrip = { x: barPos.x, y: barPos.y };\n\n ctx.lineWidth = 3;\n ctx.strokeStyle = black;\n ctx.beginPath();\n ctx.moveTo(shoulder.x, shoulder.y);\n ctx.lineTo(elbow.x, elbow.y);\n ctx.stroke();\n\n ctx.beginPath();\n ctx.moveTo(elbow.x, elbow.y);\n ctx.lineTo(wrist.x, wrist.y);\n ctx.stroke();\n\n ctx.lineWidth = 2;\n ctx.beginPath();\n ctx.arc(handGrip.x, handGrip.y, 4, 0, Math.PI * 2);\n ctx.fillStyle = paper;\n ctx.fill();\n ctx.stroke();\n\n ctx.fillStyle = black;\n ctx.beginPath(); ctx.arc(elbow.x, elbow.y, 3.5, 0, Math.PI * 2); ctx.fill();\n\n // 5. Barbell & Olympic Plates\n ctx.fillStyle = black;\n ctx.beginPath();\n ctx.arc(barPos.x, barPos.y, 5, 0, Math.PI * 2);\n ctx.fill();\n\n var plateRadius = 26;\n ctx.strokeStyle = black;\n ctx.lineWidth = 2;\n ctx.beginPath();\n ctx.arc(barPos.x, barPos.y, plateRadius, 0, Math.PI * 2);\n ctx.stroke();\n\n ctx.lineWidth = 1;\n ctx.strokeStyle = ink2;\n ctx.beginPath();\n ctx.arc(barPos.x, barPos.y, 10, 0, Math.PI * 2);\n ctx.stroke();\n\n // 6. Teaching Overlays\n // (a) Bar path trace\n if (toggles.trace.checked) {\n ctx.lineWidth = 1.5;\n ctx.strokeStyle = rule;\n ctx.setLineDash([4, 4]);\n ctx.beginPath();\n ctx.moveTo(DEFAULT_BENCH_CONFIG.touchPos.x, 28);\n ctx.lineTo(DEFAULT_BENCH_CONFIG.touchPos.x, 198);\n ctx.stroke();\n\n var pathPts = getBarPathPoints(DEFAULT_BENCH_CONFIG, 30);\n ctx.strokeStyle = ink3;\n ctx.setLineDash([3, 3]);\n ctx.beginPath();\n for (var sIdx = 0; sIdx < pathPts.jcurve.length; sIdx++) {\n var ppos = pathPts.jcurve[sIdx];\n if (sIdx === 0) ctx.moveTo(ppos.x, ppos.y);\n else ctx.lineTo(ppos.x, ppos.y);\n }\n ctx.stroke();\n ctx.setLineDash([]);\n\n ctx.strokeStyle = rule;\n ctx.setLineDash([2, 2]);\n ctx.beginPath();\n ctx.moveTo(198, 30);\n ctx.lineTo(210, 68);\n ctx.stroke();\n ctx.setLineDash([]);\n }\n\n // (b) Plumb line\n if (toggles.plumb.checked) {\n ctx.strokeStyle = ink3;\n ctx.lineWidth = 1;\n ctx.setLineDash([2, 3]);\n ctx.beginPath();\n ctx.moveTo(wrist.x, barPos.y - 10);\n ctx.lineTo(wrist.x, 315);\n ctx.stroke();\n ctx.setLineDash([]);\n }\n\n // (c) Elbow arc\n if (toggles.angles.checked) {\n ctx.strokeStyle = ink2;\n ctx.lineWidth = 1;\n ctx.beginPath();\n ctx.arc(elbow.x, elbow.y, 14, 0, Math.PI * 0.6);\n ctx.stroke();\n }\n\n // (d) Force arrow\n if (toggles.force.checked) {\n var forceLen = 0;\n if (phase.phase === 'press') forceLen = 28;\n else if (phase.phase === 'set' || phase.phase === 'lockout') forceLen = 18;\n else if (phase.phase === 'descent') forceLen = 12;\n else if (phase.phase === 'pause') forceLen = 10;\n\n if (forceLen > 0) {\n var arrowBaseY = barPos.y - 27;\n var arrowTipY = arrowBaseY - forceLen;\n\n ctx.strokeStyle = black;\n ctx.lineWidth = 2;\n ctx.beginPath();\n ctx.moveTo(barPos.x, arrowBaseY);\n ctx.lineTo(barPos.x, arrowTipY + 4);\n ctx.stroke();\n\n ctx.fillStyle = black;\n ctx.beginPath();\n ctx.moveTo(barPos.x, arrowTipY);\n ctx.lineTo(barPos.x - 4, arrowTipY + 6);\n ctx.lineTo(barPos.x + 4, arrowTipY + 6);\n ctx.closePath();\n ctx.fill();\n }\n }\n\n // 7. Top-down Inset Diagram\n var tCenterX = 430;\n var tCenterY = 56;\n\n ctx.strokeStyle = black;\n ctx.lineWidth = 1.2;\n ctx.beginPath();\n ctx.ellipse(tCenterX, tCenterY, 15, 24, Math.PI / 2, 0, Math.PI * 2);\n ctx.stroke();\n\n ctx.strokeStyle = black;\n ctx.lineWidth = 2;\n ctx.beginPath();\n ctx.moveTo(tCenterX - 36, tCenterY);\n ctx.lineTo(tCenterX + 36, tCenterY);\n ctx.stroke();\n\n ctx.fillStyle = black;\n ctx.fillRect(tCenterX - 35, tCenterY - 8, 4, 16);\n ctx.fillRect(tCenterX + 31, tCenterY - 8, 4, 16);\n\n var flareDeg = state.fault === 'flare-90' ? 90 : 60;\n var flareAngleRad = (flareDeg * Math.PI) / 180;\n var armLen = 22;\n var eL = { x: tCenterX - 15 - Math.sin(flareAngleRad) * armLen, y: tCenterY + Math.cos(flareAngleRad) * armLen };\n var eR = { x: tCenterX + 15 + Math.sin(flareAngleRad) * armLen, y: tCenterY + Math.cos(flareAngleRad) * armLen };\n\n ctx.lineWidth = 2;\n ctx.strokeStyle = black;\n ctx.beginPath();\n ctx.moveTo(tCenterX - 14, tCenterY);\n ctx.lineTo(eL.x, eL.y);\n ctx.lineTo(tCenterX - 24, tCenterY);\n ctx.moveTo(tCenterX + 14, tCenterY);\n ctx.lineTo(eR.x, eR.y);\n ctx.lineTo(tCenterX + 24, tCenterY);\n ctx.stroke();\n\n // 8. Render Labels via unified computeLabelBoxes\n var boxes = computeLabelBoxes({\n phase: phase.phase,\n progress: phase.phaseProgress,\n fault: state.fault,\n toggles: {\n trace: toggles.trace.checked,\n angles: toggles.angles.checked,\n plumb: toggles.plumb.checked,\n force: toggles.force.checked\n }\n }, DEFAULT_BENCH_CONFIG, CANVAS_VIEW);\n\n for (var bIdx = 0; bIdx < boxes.length; bIdx++) {\n var box = boxes[bIdx];\n if (box.id === 'top-view-caption') {\n drawLabel(box.text, box.x + box.width / 2, box.y, { align: 'center', baseline: 'top', font: '500 10px sans-serif', color: ink2, halo: 4 });\n } else if (box.id === 'flare-angle') {\n drawLabel(box.text, box.x + box.width / 2, box.y, { align: 'center', baseline: 'top', font: '600 10px sans-serif', color: state.fault === 'flare-90' ? black : ink2, halo: 4 });\n } else if (box.id === 'jcurve') {\n drawLabel(box.text, box.x, box.y, { align: 'left', baseline: 'top', font: '400 10px sans-serif', color: ink3, halo: 5 });\n } else if (box.id === 'vertical') {\n drawLabel(box.text, box.x, box.y, { align: 'left', baseline: 'top', font: '400 10px sans-serif', color: ink3, halo: 4 });\n } else if (box.id === 'plumb') {\n drawLabel(box.text, box.x + box.width / 2, box.y, { align: 'center', baseline: 'top', font: '400 10px sans-serif', color: ink2, halo: 5 });\n } else if (box.id === 'elbow') {\n drawLabel(box.text, box.x + box.width, box.y, { align: 'right', baseline: 'top', font: '500 11px sans-serif', color: black, halo: 4 });\n } else if (box.id === 'force') {\n drawLabel(box.text, box.x, box.y, { align: 'left', baseline: 'top', font: '500 10px sans-serif', color: black, halo: 4 });\n }\n }\n }\n\n function frame(timestamp) {\n if (!state.lastStamp) state.lastStamp = timestamp;\n var dt = Math.min(0.1, (timestamp - state.lastStamp) / 1000 || 0);\n state.lastStamp = timestamp;\n\n if (state.playing && !document.hidden) {\n state.time = (state.time + dt * state.speed) % totalRepTime;\n }\n\n var phase = getPhaseAtTime(state.time, DEFAULT_TEMPO);\n var barPos = computeBarPosition(phase.phase, phase.phaseProgress, DEFAULT_BENCH_CONFIG, state.fault);\n var wristTarget = barPos;\n if (state.fault === 'wrists') {\n wristTarget = { x: barPos.x + 8, y: barPos.y + 6 };\n }\n var ik = solveIK(DEFAULT_BENCH_CONFIG.shoulder, wristTarget, DEFAULT_BENCH_CONFIG.upperArmLength, DEFAULT_BENCH_CONFIG.forearmLength, state.fault);\n var elbowDeg = calcElbowAngle(DEFAULT_BENCH_CONFIG.shoulder, ik.elbow, ik.wrist);\n\n updateReadouts(phase, barPos, elbowDeg, dt * state.speed);\n draw(barPos, ik, phase, elbowDeg);\n\n requestAnimationFrame(frame);\n }\n\n // Event handlers\n if (playBtn) {\n playBtn.addEventListener('click', function () {\n state.playing = !state.playing;\n playBtn.textContent = state.playing ? 'Pause' : 'Play';\n root.focus();\n });\n }\n\n var stepBackBtn = root.querySelector('[data-act=\"step-back\"]');\n if (stepBackBtn) {\n stepBackBtn.addEventListener('click', function () {\n state.playing = false;\n if (playBtn) playBtn.textContent = 'Play';\n state.time = (state.time - 0.05 + totalRepTime) % totalRepTime;\n root.focus();\n });\n }\n\n var stepFwdBtn = root.querySelector('[data-act=\"step-fwd\"]');\n if (stepFwdBtn) {\n stepFwdBtn.addEventListener('click', function () {\n state.playing = false;\n if (playBtn) playBtn.textContent = 'Play';\n state.time = (state.time + 0.05) % totalRepTime;\n root.focus();\n });\n }\n\n if (scrubber) {\n scrubber.addEventListener('input', function () {\n state.playing = false;\n if (playBtn) playBtn.textContent = 'Play';\n state.time = (parseInt(scrubber.value, 10) / 1000) * totalRepTime;\n });\n }\n\n speedBtns.forEach(function (btn) {\n btn.addEventListener('click', function () {\n speedBtns.forEach(function (b) { b.removeAttribute('aria-pressed'); });\n btn.setAttribute('aria-pressed', 'true');\n state.speed = parseFloat(btn.dataset.speed);\n root.focus();\n });\n });\n\n if (loadSelect) {\n loadSelect.addEventListener('change', function () {\n state.load = parseInt(loadSelect.value, 10);\n root.focus();\n });\n }\n\n if (faultSelect) {\n faultSelect.addEventListener('change', function () {\n state.fault = faultSelect.value;\n root.focus();\n });\n }\n\n // Keyboard navigation\n root.addEventListener('keydown', function (e) {\n if (e.key === ' ' || e.code === 'Space') {\n e.preventDefault();\n state.playing = !state.playing;\n if (playBtn) playBtn.textContent = state.playing ? 'Pause' : 'Play';\n } else if (e.key === 'ArrowLeft') {\n e.preventDefault();\n state.playing = false;\n if (playBtn) playBtn.textContent = 'Play';\n state.time = (state.time - 0.05 + totalRepTime) % totalRepTime;\n } else if (e.key === 'ArrowRight') {\n e.preventDefault();\n state.playing = false;\n if (playBtn) playBtn.textContent = 'Play';\n state.time = (state.time + 0.05) % totalRepTime;\n } else if (e.key === '1') {\n state.speed = 0.25;\n speedBtns.forEach(function (b) { b.setAttribute('aria-pressed', b.dataset.speed === '0.25'); });\n } else if (e.key === '2') {\n state.speed = 0.5;\n speedBtns.forEach(function (b) { b.setAttribute('aria-pressed', b.dataset.speed === '0.5'); });\n } else if (e.key === '3') {\n state.speed = 1.0;\n speedBtns.forEach(function (b) { b.setAttribute('aria-pressed', b.dataset.speed === '1'); });\n }\n });\n\n canvas.addEventListener('click', function () {\n root.focus();\n });\n\n requestAnimationFrame(frame);\n})();"
}
```Weight room — the big liftsBench press
5of 8
Common faults, and the cue for each
Problem, key section, 1 highlight, 410 words
Watch from the side for the bar path and the forearms, and from the head of the bench for the elbows. Fix one fault at a time, the first one you see.
Bench press faults: what you see, the cue, and the drill
| F | W |
|---|---|
| Elbows flared to 90° | Upper arms straight out from the body; bar touches high. Cue: “Bend the bar, elbows in.” Drill: paused bench, lighter load. |
| Bouncing off the chest | Bar sinks and rebounds; speed from the ribs. Cue: “Touch, then press.” Drill: one-second pause on the chest. |
| Hips off the bench | Glutes lift as the bar leaves the chest. Cue: “Glutes down, push the floor away.” Drill: feet set before the lift-off. |
| Path over the face | Bar comes down toward the throat. Cue: “Lower to the bottom of the breastbone.” Drill: three-second lowering. |
| Bar lands on the belly | Touch below the chest; forearms slant. Cue: “Forearms straight up at the touch.” Drill: film from the side, empty bar. |
| Wrists bent back | Bar near the fingers; knuckles tipped back. Cue: “Bar in the heel of the hand.” Drill: grip check before every set. |
The six faults the bench press model can show. Loads for each drill are set by the coach, in person.
Bench press faults: what you see, the cue, and the drill
| F | W |
|---|---|
| Elbows flared to 90° | Upper arms straight out from the body; bar touches high. Cue: “Bend the bar, elbows in.” Drill: paused bench, lighter load. |
| Bouncing off the chest | Bar sinks and rebounds; speed from the ribs. Cue: “Touch, then press.” Drill: one-second pause on the chest. |
| Hips off the bench | Glutes lift as the bar leaves the chest. Cue: “Glutes down, push the floor away.” Drill: feet set before the lift-off. |
| Path over the face | Bar comes down toward the throat. Cue: “Lower to the bottom of the breastbone.” Drill: three-second lowering. |
| Bar lands on the belly | Touch below the chest; forearms slant. Cue: “Forearms straight up at the touch.” Drill: film from the side, empty bar. |
| Wrists bent back | Bar near the fingers; knuckles tipped back. Cue: “Bar in the heel of the hand.” Drill: grip check before every set. |
The six faults the bench press model can show. Loads for each drill are set by the coach, in person.
```element table
{
"caption": "Bench press faults: what you see, the cue, and the drill",
"columns": [
{
"label": "F",
"align": "left"
},
{
"label": "W",
"align": "left"
}
],
"rows": [
[
"Elbows flared to 90°",
"Upper arms straight out from the body; bar touches high. *Cue:* “Bend the bar, elbows in.” *Drill:* paused bench, lighter load."
],
[
"Bouncing off the chest",
"Bar sinks and rebounds; speed from the ribs. *Cue:* “Touch, then press.” *Drill:* one-second pause on the chest."
],
[
"Hips off the bench",
"Glutes lift as the bar leaves the chest. *Cue:* “Glutes down, push the floor away.” *Drill:* feet set before the lift-off."
],
[
"Path over the face",
"Bar comes down toward the throat. *Cue:* “Lower to the bottom of the breastbone.” *Drill:* three-second lowering."
],
[
"Bar lands on the belly",
"Touch below the chest; forearms slant. *Cue:* “Forearms straight up at the touch.” *Drill:* film from the side, empty bar."
],
[
"Wrists bent back",
"Bar near the fingers; knuckles tipped back. *Cue:* “Bar in the heel of the hand.” *Drill:* grip check before every set."
]
],
"notes": "The six faults the bench press model can show. Loads for each drill are set by the coach, in person."
}
```Buttocks stay on the bench for every rep; it is a rule in powerlifting competition as well as a coaching point.
Weight room — the big liftsNo. 1
Spotting and safetyevery lift, before anyone lifts
6 sections, 1,720 words, about 7 minutes, 4 highlights, revised 3 September
Read this before your first session and before you spot anyone. Every athlete shows the coach the bench press spot with an empty bar before spotting a loaded one.
Spotting the bench press
Set the safeties and the collars
In a rack, safety arms just below the bar’s lowest point, so a missed rep rests on them. Collars on both ends.
Agree the signals
How many reps, the count for the lift-off, and one word, take, that means the spotter takes the bar.
Hand off on the count
Stand at the head of the bench, alternate grip inside the lifter’s hands. On the count, help the bar out over the shoulders; let go when the lifter says so.
Follow the bar
Hands close under the bar, not touching it, down and up with every rep. Eyes on the bar, not the room.
Take it
If the bar stops, drops or the lifter says take, grip it and lift it straight up and back to the hooks, with the lifter.
Rack it together
Bar back until it touches the uprights, then down into the hooks. Nobody lets go until it is in both.
Spotting the bench press
- Set the safeties and the collars
In a rack, safety arms just below the bar’s lowest point, so a missed rep rests on them. Collars on both ends.
- Agree the signals
How many reps, the count for the lift-off, and one word, take, that means the spotter takes the bar.
- Hand off on the count
Stand at the head of the bench, alternate grip inside the lifter’s hands. On the count, help the bar out over the shoulders; let go when the lifter says so.
- Follow the bar
Hands close under the bar, not touching it, down and up with every rep. Eyes on the bar, not the room.
- Take it
If the bar stops, drops or the lifter says take, grip it and lift it straight up and back to the hooks, with the lifter.
- Rack it together
Bar back until it touches the uprights, then down into the hooks. Nobody lets go until it is in both.
```element steps
{
"label": "Spotting the bench press",
"steps": [
{
"title": "Set the safeties and the collars",
"body": "In a rack, safety arms just below the bar’s lowest point, so a missed rep rests on them. Collars on both ends."
},
{
"title": "Agree the signals",
"body": "How many reps, the count for the lift-off, and one word, *take*, that means the spotter takes the bar."
},
{
"title": "Hand off on the count",
"body": "Stand at the head of the bench, alternate grip inside the lifter’s hands. On the count, help the bar out over the shoulders; let go when the lifter says so."
},
{
"title": "Follow the bar",
"body": "Hands close under the bar, not touching it, down and up with every rep. Eyes on the bar, not the room."
},
{
"title": "Take it",
"body": "If the bar stops, drops or the lifter says *take*, grip it and lift it straight up and back to the hooks, with the lifter."
},
{
"title": "Rack it together",
"body": "Bar back until it touches the uprights, then down into the hooks. Nobody lets go until it is in both."
}
]
}
```Deadlifts are not spotted: clear the space around the bar instead. The squat spot is section 3.
Sections
- 1Spotting the bench pressRisk, keySafeties, signals, the hand-off, following the bar and the take.
- 2Collars, safeties and a clear floorRisk, key
- 3Spotting the squatRisk, keySafety arms set first; one spotter behind, or two at the ends of the bar.
- 4Stop on pain, and tell the coachRisk, keyPain ends the set. The coach and a medical professional decide what happens next.
and 2 more sections
How it works
Write down the cues you say on the floor, “bend the bar”, “touch, then press”, where an athlete can read them the night before. Each lift gets its setup, cues, faults and spotting, with the faults set out as what you see, the cue and the drill. The bench press carries a model to step through frame by frame.