(Please note: So far, I’ve done one test of the web app below and it didn’t do very well. I hope to do another test soon to try to understand the variables better. Proceed with caution!)
I have, over the past several years, been really interested in creating photographic compositions comprised of multiple shots. The photo atop this post is stitched from about a dozen photographs. It’s probably impossible to discern from the jpeg, but you could print this practically life-sized. And to me that’s exciting. I’d like to be able to reproduce things at life size.
I can’t tell you how many times I’ve searched online for a web app like you’ll find below. I did find a handful, but they never made intuitive sense to me. So, having heard from programmer friends about how remarkable Claude AI is at this kind of thing, I made a widget of my own. The AI made this possible—I’m terrible at coding and I’ve forgotten most of what I once knew about math—so I’m going to have to pay some kind of AI penance.
Give it a try and let me know what you think!
Cake Slicer 1.1
Units
Movement mode
Subject
Camera & Lens
Frame Overlap
Grid size
Results
The code (in case you’d like to embed it)
<style> *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } #cgc { --cgc-font-head: 'Century Gothic', 'Avenir Next', 'Futura', 'Poppins', sans-serif; --cgc-font-body: Georgia, 'Times New Roman', Times, serif; font-family: var(--cgc-font-body); font-variant-numeric: oldstyle-nums; font-size: 15px; line-height: 1.5; color: #1a1a1a; background: #fff; padding: 1.5rem; border: 1px solid #e0e0e0; border-radius: 10px; max-width: 780px; margin: 0 auto; } #cgc h1 { font-family: var(--cgc-font-head); font-size: 22px; font-weight: 700; color: #06b6d4; margin-bottom: 1.25rem; } #cgc .section-label { font-family: var(--cgc-font-head); font-size: 10px; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; color: #06b6d4; margin-bottom: 10px; } #cgc .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } #cgc .grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px; } @media (max-width: 520px) { #cgc .grid-2, #cgc .grid-3 { grid-template-columns: 1fr; } } #cgc .field { display: flex; flex-direction: column; gap: 4px; } #cgc .field label { font-family: var(--cgc-font-head); font-size: 12px; color: #555; } #cgc .unit-row { display: flex; align-items: center; } #cgc .unit-row input { flex: 1; min-width: 0; border: 1px solid #ccc; border-right: none; border-radius: 6px 0 0 6px; padding: 0 10px; height: 36px; font-size: 14px; font-family: var(--cgc-font-body); font-variant-numeric: oldstyle-nums; background: #fff; color: #1a1a1a; outline: none; transition: border-color .15s; } #cgc .unit-row input:focus { border-color: #4a90d9; } #cgc .unit-row .ubadge { height: 36px; padding: 0 10px; background: #f5f5f5; border: 1px solid #ccc; border-radius: 0 6px 6px 0; font-size: 12px; color: #666; display: flex; align-items: center; white-space: nowrap; } #cgc select { width: 100%; height: 36px; border: 1px solid #ccc; border-radius: 6px; padding: 0 8px; font-size: 13px; font-family: var(--cgc-font-body); background: #fff; color: #1a1a1a; outline: none; } #cgc select:focus { border-color: #4a90d9; } #cgc .divider { border: none; border-top: 1px solid #eee; margin: 1.25rem 0; } #cgc .mode-toggle { display: flex; border: 1px solid #ccc; border-radius: 7px; overflow: hidden; margin-bottom: 1.5rem; } #cgc .mode-toggle button { flex: 1; padding: 8px 10px; font-size: 13px; background: transparent; border: none; border-radius: 0; cursor: pointer; color: #666; font-family: var(--cgc-font-head); transition: background .15s, color .15s; } #cgc .mode-toggle button.active { background: #f0f0f0; color: #1a1a1a; font-weight: 600; } #cgc .mode-toggle button:not(:last-child) { border-right: 1px solid #ccc; } #cgc .unit-toggle { display: inline-flex; border: 1px solid #ccc; border-radius: 7px; overflow: hidden; margin-bottom: 1.5rem; } #cgc .unit-toggle button { padding: 7px 14px; font-size: 12px; background: transparent; border: none; border-radius: 0; cursor: pointer; color: #666; font-family: var(--cgc-font-head); transition: background .15s, color .15s; } #cgc .unit-toggle button.active { background: #f0f0f0; color: #1a1a1a; font-weight: 600; } #cgc .unit-toggle button:not(:last-child) { border-right: 1px solid #ccc; } #cgc .stepper-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } #cgc .stepper { display: flex; align-items: center; border: 1px solid #ccc; border-radius: 6px; overflow: hidden; height: 36px; } #cgc .stepper button { width: 32px; height: 100%; background: #f5f5f5; border: none; font-size: 18px; cursor: pointer; color: #444; line-height: 1; } #cgc .stepper button:hover { background: #e8e8e8; } #cgc .stepper span { min-width: 34px; text-align: center; font-size: 15px; font-weight: 600; font-family: var(--cgc-font-body); font-variant-numeric: oldstyle-nums; color: #1a1a1a; border-left: 1px solid #ccc; border-right: 1px solid #ccc; line-height: 36px; } #cgc .stepper-label { font-family: var(--cgc-font-head); font-size: 13px; color: #555; } #cgc .result-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; } #cgc .metric { background: #f7f7f7; border-radius: 7px; padding: 0.8rem 1rem; } #cgc .metric .m-label { font-family: var(--cgc-font-head); font-size: 11px; color: #777; margin-bottom: 3px; } #cgc .metric .m-value { font-size: 19px; font-weight: 600; font-family: var(--cgc-font-body); font-variant-numeric: oldstyle-nums; color: #1a1a1a; } #cgc .metric .m-sub { font-size: 11px; color: #999; margin-top: 2px; } #cgc .metric.accent .m-value { color: #1a6abf; } #cgc .viz-wrap { border: 1px solid #e0e0e0; border-radius: 8px; padding: 0.75rem; margin-top: 1.5rem; overflow: hidden; } #cgc canvas { display: block; width: 100%; } #cgc .step-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 1.25rem; } #cgc .step-table th { text-align: left; font-family: var(--cgc-font-head); font-size: 10px; font-weight: 600; letter-spacing: 0.07em; text-transform: uppercase; color: #999; padding: 0 8px 7px 0; border-bottom: 1px solid #eee; } #cgc .step-table td { padding: 6px 8px 6px 0; border-bottom: 1px solid #f0f0f0; color: #1a1a1a; font-family: var(--cgc-font-body); font-variant-numeric: oldstyle-nums; font-size: 12px; } #cgc .step-table td.lc { font-family: var(--cgc-font-body); color: #666; font-size: 12px; } #cgc .step-table tr:last-child td { border-bottom: none; } #cgc .warn { font-size: 12px; color: #b45309; background: #fffbeb; border: 1px solid #fcd34d; border-radius: 5px; padding: 6px 10px; margin-top: 8px; }</style><div id="cgc"> <h1>Cake Slicer 1.1</h1> <p class="section-label">Units</p> <div class="unit-toggle"> <button class="active" id="btn-metric" onclick="cgcSetUnits('metric')">Metric (m)</button> <button id="btn-imperial" onclick="cgcSetUnits('imperial')">Imperial (ft)</button> </div> <p class="section-label">Movement mode</p> <div class="mode-toggle"> <button class="active" id="btn-pantilt" onclick="cgcSetMode('pantilt')">↔ Pan & Tilt</button> <button id="btn-shift" onclick="cgcSetMode('shift')">⤢ Camera Shift</button> </div> <p class="section-label">Subject</p> <div class="grid-2" style="margin-bottom:1.25rem"> <div class="field"> <label>Width</label> <div class="unit-row"><input type="number" id="cgc-ow" value="10" min="0.01" step="0.1" oninput="cgcCalc()"><span class="ubadge" id="cgc-ow-badge">m</span></div> </div> <div class="field"> <label>Height</label> <div class="unit-row"><input type="number" id="cgc-oh" value="6" min="0.01" step="0.1" oninput="cgcCalc()"><span class="ubadge" id="cgc-oh-badge">m</span></div> </div> </div> <p class="section-label">Camera & Lens</p> <div style="display:flex; align-items:flex-end; gap:10px; margin-bottom:12px; flex-wrap:wrap"> <div class="field" style="flex:1; min-width:160px"> <label>Film / sensor format</label> <select id="cgc-format" onchange="cgcOnFormat()"> <option value="36,24">35mm Full Frame (36×24mm)</option> <option value="53.975,40.6">4×5 Large Format</option> <option value="89,127">8×10 Large Format</option> <option value="56,41.5">6×4.5 Medium Format</option> <option value="56,56">6×6 Medium Format</option> <option value="70,56">6×7 Medium Format</option> <option value="17.3,13">Micro Four Thirds</option> <option value="23.5,15.6">APS-C (Nikon/Sony)</option> <option value="22.2,14.8">APS-C (Canon)</option> <option value="custom">Custom…</option> </select> </div> <div id="cgc-orient" class="field" style="display:none; flex:0 0 auto"> <label>Camera Orientation</label> <div style="display:flex; border:1px solid #ccc; border-radius:6px; overflow:hidden; height:36px;"> <label for="cgc-orient-landscape" style="display:flex; align-items:center; gap:5px; padding:0 10px; cursor:pointer; background:#f0f0f0; border-right:1px solid #ccc; font-size:12px; color:#1a1a1a; font-weight:600;" id="cgc-orient-landscape-wrap"> <input type="radio" name="cgc-orient-radio" id="cgc-orient-landscape" value="landscape" checked onchange="cgcSetOrient('landscape')" style="margin:0"> <span aria-hidden="true">▭</span> Landscape </label> <label for="cgc-orient-portrait" style="display:flex; align-items:center; gap:5px; padding:0 10px; cursor:pointer; background:transparent; font-size:12px; color:#666;" id="cgc-orient-portrait-wrap"> <input type="radio" name="cgc-orient-radio" id="cgc-orient-portrait" value="portrait" onchange="cgcSetOrient('portrait')" style="margin:0"> <span aria-hidden="true">▯</span> Portrait </label> </div> </div> </div> <div class="grid-2" style="margin-bottom:1.25rem"> <div class="field"> <label>Focal length</label> <div class="unit-row"><input type="number" id="cgc-fl" value="50" min="1" step="1" oninput="cgcCalc()"><span class="ubadge">mm</span></div> </div> <div class="field"> <label>Distance to subject</label> <div class="unit-row"><input type="number" id="cgc-dist" value="100" min="0.1" step="1" oninput="cgcCalc()"><span class="ubadge" id="cgc-dist-badge">m</span></div> </div> </div> <div id="cgc-custom" style="display:none; margin-bottom:1.25rem"> <p class="section-label">Custom sensor size</p> <div class="grid-2"> <div class="field"><label>Sensor width</label><div class="unit-row"><input type="number" id="cgc-cw" value="36" min="0.1" step="0.1" oninput="cgcUpdateOrientButton(); cgcCalc()"><span class="ubadge">mm</span></div></div> <div class="field"><label>Sensor height</label><div class="unit-row"><input type="number" id="cgc-ch" value="24" min="0.1" step="0.1" oninput="cgcUpdateOrientButton(); cgcCalc()"><span class="ubadge">mm</span></div></div> </div> </div> <p class="section-label">Frame Overlap</p> <div class="field" style="margin-bottom:1.25rem; max-width:220px"> <label>Overlap between frames</label> <select id="cgc-overlap" onchange="cgcCalc()"> <option value="-5">-5% (gap)</option> <option value="0" selected>0%</option> <option value="5">5%</option> <option value="10">10%</option> <option value="15">15%</option> <option value="20">20%</option> </select> </div> <hr class="divider"> <p class="section-label">Grid size</p> <div style="margin-bottom:1.25rem"> <div class="stepper-row"> <span class="stepper-label">Columns</span> <div class="stepper"> <button onclick="cgcAdj('cols',-1)">−</button> <span id="cgc-cols">3</span> <button onclick="cgcAdj('cols',1)">+</button> </div> </div> <br> <div class="stepper-row"> <span class="stepper-label">Rows</span> <div class="stepper"> <button onclick="cgcAdj('rows',-1)">−</button> <span id="cgc-rows">2</span> <button onclick="cgcAdj('rows',1)">+</button> </div> <span id="cgc-total" style="font-size:12px;color:#aaa;margin-left:4px"></span> </div> </div> <hr class="divider"> <p class="section-label" style="margin-bottom:10px">Results</p> <div class="result-grid" id="cgc-results"></div> <div class="viz-wrap"> <canvas id="cgc-canvas" height="220"></canvas> </div> <div id="cgc-table"></div> <div id="cgc-warn"></div></div><script>(function() { var mode = 'pantilt'; var gridCols = 3, gridRows = 2; var orientation = 'landscape'; var unitSystem = 'metric'; var FT_PER_M = 3.28084; window.cgcSetMode = function(m) { mode = m; document.getElementById('btn-pantilt').className = m === 'pantilt' ? 'active' : ''; document.getElementById('btn-shift').className = m === 'shift' ? 'active' : ''; cgcCalc(); }; window.cgcSetUnits = function(u) { if (u === unitSystem) return; var ids = ['cgc-ow', 'cgc-oh', 'cgc-dist']; ids.forEach(function(id) { var el = document.getElementById(id); var v = parseFloat(el.value); if (!isNaN(v)) { el.value = u === 'imperial' ? +(v * FT_PER_M).toFixed(2) : +(v / FT_PER_M).toFixed(3); } }); unitSystem = u; var unitLabel = u === 'imperial' ? 'ft' : 'm'; document.getElementById('cgc-ow-badge').textContent = unitLabel; document.getElementById('cgc-oh-badge').textContent = unitLabel; document.getElementById('cgc-dist-badge').textContent = unitLabel; document.getElementById('btn-metric').className = u === 'metric' ? 'active' : ''; document.getElementById('btn-imperial').className = u === 'imperial' ? 'active' : ''; cgcCalc(); }; window.cgcAdj = function(dim, delta) { if (dim === 'cols') { gridCols = Math.max(1, gridCols + delta); document.getElementById('cgc-cols').textContent = gridCols; } else { gridRows = Math.max(1, gridRows + delta); document.getElementById('cgc-rows').textContent = gridRows; } cgcCalc(); }; window.cgcSetOrient = function(o) { orientation = o; var lw = document.getElementById('cgc-orient-landscape-wrap'); var pw = document.getElementById('cgc-orient-portrait-wrap'); if (o === 'landscape') { lw.style.background = '#f0f0f0'; lw.style.color = '#1a1a1a'; lw.style.fontWeight = '600'; pw.style.background = 'transparent'; pw.style.color = '#666'; pw.style.fontWeight = 'normal'; } else { pw.style.background = '#f0f0f0'; pw.style.color = '#1a1a1a'; pw.style.fontWeight = '600'; lw.style.background = 'transparent'; lw.style.color = '#666'; lw.style.fontWeight = 'normal'; } cgcCalc(); }; window.cgcOnFormat = function() { document.getElementById('cgc-custom').style.display = document.getElementById('cgc-format').value === 'custom' ? 'block' : 'none'; cgcUpdateOrientButton(); cgcCalc(); }; window.cgcUpdateOrientButton = function() { var raw = getRawSensor(); var wrap = document.getElementById('cgc-orient'); if (Math.abs(raw[0] - raw[1]) < 1e-6) { wrap.style.display = 'none'; document.getElementById('cgc-orient-landscape').checked = true; cgcSetOrient('landscape'); } else { wrap.style.display = 'flex'; } } function getRawSensor() { var f = document.getElementById('cgc-format').value; if (f === 'custom') return [ parseFloat(document.getElementById('cgc-cw').value) || 36, parseFloat(document.getElementById('cgc-ch').value) || 24 ]; return f.split(',').map(Number); } function getSensor() { var raw = getRawSensor(); if (orientation === 'portrait') return [raw[1], raw[0]]; return raw; } function fovDeg(s, fl) { return 2 * Math.atan(s / (2 * fl)) * 180 / Math.PI; } function frameCov(fov, d) { return 2 * d * Math.tan(fov * Math.PI / 360); } function toMeters(v) { return unitSystem === 'imperial' ? v / FT_PER_M : v; } function fromMeters(v) { return unitSystem === 'imperial' ? v * FT_PER_M : v; } function distUnit() { return unitSystem === 'imperial' ? 'ft' : 'm'; } window.cgcCalc = function() { var s = getSensor(), sw = s[0], sh = s[1]; var fl = parseFloat(document.getElementById('cgc-fl').value) || 50; var dist = toMeters(parseFloat(document.getElementById('cgc-dist').value) || (unitSystem === 'imperial' ? 328 : 100)); var ow = toMeters(parseFloat(document.getElementById('cgc-ow').value) || (unitSystem === 'imperial' ? 33 : 10)); var oh = toMeters(parseFloat(document.getElementById('cgc-oh').value) || (unitSystem === 'imperial' ? 20 : 6)); var cols = gridCols, rows = gridRows; var overlapPct = parseFloat(document.getElementById('cgc-overlap').value) || 0; var factor = 1 - overlapPct / 100; document.getElementById('cgc-total').textContent = '= ' + (cols * rows) + ' shots'; var fovH = fovDeg(sw, fl), fovV = fovDeg(sh, fl); var frameW = frameCov(fovH, dist), frameH = frameCov(fovV, dist); var shiftH = dist * Math.tan(fovH * Math.PI / 180); var shiftV = dist * Math.tan(fovV * Math.PI / 180); var stepDegH = fovH * factor, stepDegV = fovV * factor; var stepShiftH = shiftH * factor, stepShiftV = shiftV * factor; var stepMetersW = frameW * factor, stepMetersH = frameH * factor; var totalW = frameW + (cols - 1) * stepMetersW; var totalH = frameH + (rows - 1) * stepMetersH; var isPT = mode === 'pantilt'; var res = document.getElementById('cgc-results'); var u = distUnit(); if (isPT) { res.innerHTML = metric('accent', 'Shot grid', cols + ' × ' + rows, cols*rows + ' total frames') + metric('', 'H pan step', stepDegH.toFixed(2) + '°', 'per column') + metric('', 'V tilt step', stepDegV.toFixed(2) + '°', 'per row'); } else { var dispShiftH = fromMeters(stepShiftH), dispShiftV = fromMeters(stepShiftV); var subH = unitSystem === 'imperial' ? (dispShiftH*12).toFixed(2) + ' in' : (stepShiftH*1000).toFixed(1) + ' mm'; var subV = unitSystem === 'imperial' ? (dispShiftV*12).toFixed(2) + ' in' : (stepShiftV*1000).toFixed(1) + ' mm'; res.innerHTML = metric('accent', 'Shot grid', cols + ' × ' + rows, cols*rows + ' total frames') + metric('', 'H shift step', dispShiftH.toFixed(3) + ' ' + u, subH) + metric('', 'V shift step', dispShiftV.toFixed(3) + ' ' + u, subV); } var steps = []; for (var r = 0; r < rows; r++) { for (var c = 0; c < cols; c++) { var ho = c - (cols-1)/2, vo = r - (rows-1)/2; steps.push({ shot: r*cols+c+1, col: c+1, row: r+1, hDeg: ho * stepDegH, vDeg: vo * stepDegV, hShift: ho * stepShiftH, vShift: vo * stepShiftV }); } } var tbl = '<table class="step-table"><thead><tr>' + '<th>Shot</th><th>Col</th><th>Row</th>' + (isPT ? '<th>H pan from center</th><th>V tilt from center</th>' : ('<th>H shift from center (' + u + ')</th><th>V shift from center (' + u + ')</th>')) + '</tr></thead><tbody>'; steps.forEach(function(s) { tbl += '<tr><td>' + s.shot + '</td><td>' + s.col + '</td><td>' + s.row + '</td>'; if (isPT) { tbl += '<td>' + sgn(s.hDeg) + s.hDeg.toFixed(2) + '°</td>' + '<td>' + sgn(s.vDeg) + s.vDeg.toFixed(2) + '°</td>'; } else { var dh = fromMeters(s.hShift), dv = fromMeters(s.vShift); tbl += '<td>' + sgn(dh) + dh.toFixed(4) + '</td>' + '<td>' + sgn(dv) + dv.toFixed(4) + '</td>'; } tbl += '</tr>'; }); tbl += '</tbody></table>'; document.getElementById('cgc-table').innerHTML = tbl; var warns = []; if (totalW < ow) warns.push('Grid is ' + fromMeters(ow-totalW).toFixed(2) + ' ' + u + ' too narrow — add more columns.'); if (totalH < oh) warns.push('Grid is ' + fromMeters(oh-totalH).toFixed(2) + ' ' + u + ' too short — add more rows.'); document.getElementById('cgc-warn').innerHTML = warns.map(function(w) { return '<p class="warn">⚠ ' + w + '</p>'; }).join(''); drawGrid(cols, rows, frameW, frameH, stepMetersW, stepMetersH, ow, oh); }; function metric(cls, label, value, sub) { return '<div class="metric ' + cls + '">' + '<div class="m-label">' + label + '</div>' + '<div class="m-value">' + value + '</div>' + '<div class="m-sub">' + sub + '</div></div>'; } function sgn(v) { return v >= 0 ? '+' : ''; } function drawGrid(cols, rows, frameW, frameH, stepW, stepH, ow, oh) { var canvas = document.getElementById('cgc-canvas'); var dpr = window.devicePixelRatio || 1; var W = canvas.offsetWidth || 680; var H = 220; canvas.width = W * dpr; canvas.height = H * dpr; var ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); ctx.clearRect(0, 0, W, H); var totalW = frameW + (cols - 1) * stepW; var totalH = frameH + (rows - 1) * stepH; var padL = 54, padR = 16, padT = 16, padB = 28; var uw = W - padL - padR, uh = H - padT - padB; var scale = Math.min(uw / totalW, uh / totalH); var dw = totalW * scale, dh = totalH * scale; var ox = padL + (uw - dw) / 2, oy = padT + (uh - dh) / 2; for (var r = 0; r < rows; r++) { for (var c = 0; c < cols; c++) { var x = ox + c * stepW * scale, y = oy + r * stepH * scale; var fw = frameW * scale, fh = frameH * scale; ctx.fillStyle = 'rgba(55,120,230,0.07)'; ctx.fillRect(x, y, fw, fh); ctx.strokeStyle = 'rgba(40,100,220,0.4)'; ctx.lineWidth = 0.75; ctx.strokeRect(x+.5, y+.5, fw-1, fh-1); if (fw > 22 && fh > 16) { ctx.fillStyle = 'rgba(40,100,220,0.55)'; ctx.font = '500 10px "Courier New", monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(r*cols+c+1, x+fw/2, y+fh/2); } } } var sx = ox + (dw - ow*scale)/2, sy = oy + (dh - oh*scale)/2; var sw2 = ow*scale, sh2 = oh*scale; ctx.fillStyle = 'rgba(220,38,38,0.55)'; ctx.fillRect(sx, sy, sw2, sh2); if (sy > 16) { ctx.fillStyle = '#DC2626'; ctx.font = '600 11px -apple-system,sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; ctx.fillText('Subject', sx+sw2/2, sy-3); } ctx.fillStyle = '#aaa'; ctx.font = '11px "Courier New",monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; var u = distUnit(); ctx.fillText(fromMeters(totalW).toFixed(1)+' '+u+' covered ('+cols+' cols)', ox+dw/2, oy+dh+6); ctx.save(); ctx.translate(padL-12, oy+dh/2); ctx.rotate(-Math.PI/2); ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; ctx.fillText(fromMeters(totalH).toFixed(1)+' '+u+' ('+rows+' rows)', 0, 0); ctx.restore(); } document.addEventListener('DOMContentLoaded', function() { cgcUpdateOrientButton(); cgcCalc(); }); window.addEventListener('resize', cgcCalc); cgcUpdateOrientButton(); cgcCalc();})();</script>
