Stitching, Grids, Panoramas

(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

m
m
mm
m

Columns
3

Rows
2

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 &amp; 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 &amp; 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>
Fediverse reactions

Subscribe to get the latest posts sent to your email.

Alex Galt

Copyright 2016—2026 Moss & Lichen LLC, All Rights Reserved (unless specified),

Please direct correspondence to:

1426 S. (Mahalia) Jackson St.

Green Bay, WI 54301

alex@starheartnautilus.com

Discover more from Alex Galt

Subscribe now to keep reading and get access to the full archive.

Continue reading