skill
Solder Jet Sizer
Public Made by Adomby adom
Interactive widget that visualizes how solder jet paste dots (250-800 um) pack into footprint pads. Real-time sliders for dot diameter and spacing, with coverage stats and per-pad tooltips.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
<!DOCTYPE html>
<html lang="en">
<head><script>(function(){var n=0;function show(p,m){var el=document.createElement('div');el.style.cssText='position:fixed;left:0;right:0;background:#e04040;color:#fff;padding:6px 12px;z-index:'+(99999-n)+';font:13px/1.4 monospace;white-space:pre-wrap;border-bottom:1px solid #b02020;top:'+n*30+'px';el.textContent=p+m;document.body.appendChild(el);n++}window.onerror=function(msg,src,line){show('ERROR '+(src||'').split('/').pop()+':'+line+' \u2014 ',msg)};window.addEventListener('unhandledrejection',function(e){show('REJECT \u2014 ',e.reason&&e.reason.message||e.reason||'unknown')})})()</script>
<meta charset="utf-8">
<title>Solder Jet Sizer — AO3400A_SOT-23</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0d1117;
color: #e6edf3;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
overflow: hidden;
height: 100vh;
display: flex;
flex-direction: column;
}
/* ── Toolbar ── */
.toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 14px;
background: #161b22;
border-bottom: 1px solid #30363d;
flex-shrink: 0;
flex-wrap: wrap;
}
.toolbar .title {
font-weight: 600;
font-size: 13px;
color: #00b8b0;
margin-right: 8px;
white-space: nowrap;
}
.toolbar .separator {
width: 1px;
height: 20px;
background: #30363d;
}
.slider-group {
display: flex;
align-items: center;
gap: 8px;
}
.slider-group label {
font-size: 12px;
color: #8b949e;
white-space: nowrap;
}
.slider-group input[type=range] {
width: 180px;
accent-color: #00b8b0;
}
.slider-value {
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
font-size: 13px;
font-weight: 600;
color: #00e6dc;
min-width: 52px;
}
/* ── Stats bar ── */
.stats {
display: flex;
align-items: center;
gap: 16px;
padding: 5px 14px;
background: #1c2128;
border-bottom: 1px solid #21262d;
flex-shrink: 0;
font-size: 12px;
color: #8b949e;
flex-wrap: wrap;
}
.stats .stat-item {
display: flex;
align-items: center;
gap: 4px;
}
.stats .stat-val {
color: #e6edf3;
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
font-weight: 600;
}
.stats .coverage-good { color: #3fb950; }
.stats .coverage-ok { color: #d29922; }
.stats .coverage-low { color: #f85149; }
/* ── SVG canvas ── */
.canvas-wrap {
flex: 1;
overflow: hidden;
position: relative;
cursor: grab;
}
.canvas-wrap:active { cursor: grabbing; }
svg { display: block; }
/* ── Pad tooltip ── */
.pad-tip {
position: absolute;
pointer-events: none;
background: #21262d;
border: 1px solid #30363d;
border-radius: 4px;
padding: 6px 10px;
font-size: 11px;
line-height: 1.5;
color: #e6edf3;
white-space: nowrap;
display: none;
z-index: 10;
}
.pad-tip .tip-label { color: #8b949e; }
.pad-tip .tip-accent { color: #00e6dc; font-weight: 600; }
/* ── Spacing toggle ── */
.toolbar button {
background: #1c2128;
border: 1px solid #30363d;
color: #8b949e;
font-size: 12px;
padding: 3px 10px;
border-radius: 4px;
cursor: pointer;
}
.toolbar button:hover { background: #21262d; color: #e6edf3; }
.toolbar button.active {
background: rgba(0, 184, 176, 0.12);
border-color: rgba(0, 184, 176, 0.25);
color: #00e6dc;
}
</style>
</head>
<body>
<div class="toolbar">
<span class="title">Solder Jet Sizer — AO3400A_SOT-23</span>
<div class="separator"></div>
<div class="slider-group">
<label>Dot diameter</label>
<input type="range" id="dot-slider" min="250" max="800" step="10" value="250">
<span class="slider-value" id="dot-value">250 um</span>
</div>
<div class="separator"></div>
<div class="slider-group">
<label>Spacing</label>
<input type="range" id="gap-slider" min="0" max="200" step="5" value="0">
<span class="slider-value" id="gap-value">0 um</span>
</div>
<div class="separator"></div>
<button id="btn-labels" class="active" onclick="toggleLabels()">Labels</button>
<button id="btn-grid" class="active" onclick="toggleGrid()">Grid</button>
</div>
<div class="stats" id="stats-bar"></div>
<div class="canvas-wrap" id="canvas-wrap">
<svg id="svg-root" xmlns="http://www.w3.org/2000/svg"></svg>
</div>
<div class="pad-tip" id="pad-tip"></div>
<script>
const pads = [{"number":"1","mountType":"smd","shape":"rect","x":-1.1,"y":-0.95,"rotation":0,"width":1,"height":0.6},{"number":"2","mountType":"smd","shape":"rect","x":-1.1,"y":0.95,"rotation":0,"width":1,"height":0.6},{"number":"3","mountType":"smd","shape":"rect","x":1.1,"y":0,"rotation":0,"width":1,"height":0.6}];
const DOT_MIN = 0.250; // mm
const DOT_MAX = 0.800; // mm
// ── Compute viewport from pads ──
const MARGIN = 1.2; // mm padding around footprint
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
pads.forEach(p => {
const hw = p.width / 2, hh = p.height / 2;
const corners = [[-hw,-hh],[hw,-hh],[hw,hh],[-hw,hh]];
const rot = (p.rotation || 0) * Math.PI / 180;
const cosR = Math.cos(rot), sinR = Math.sin(rot);
corners.forEach(([lx, ly]) => {
const rx = lx * cosR - ly * sinR + p.x;
const ry = lx * sinR + ly * cosR + p.y;
if (rx < minX) minX = rx; if (rx > maxX) maxX = rx;
if (ry < minY) minY = ry; if (ry > maxY) maxY = ry;
});
});
minX -= MARGIN; minY -= MARGIN; maxX += MARGIN; maxY += MARGIN;
const vbW = maxX - minX, vbH = maxY - minY;
const svg = document.getElementById('svg-root');
const wrap = document.getElementById('canvas-wrap');
const tip = document.getElementById('pad-tip');
// ── Zoom / pan state ──
let scale = 1, panX = 0, panY = 0, dragging = false, dragStart = {};
function fitView() {
const wW = wrap.clientWidth, wH = wrap.clientHeight;
scale = Math.min(wW / vbW, wH / vbH) * 0.92;
panX = (wW - vbW * scale) / 2;
panY = (wH - vbH * scale) / 2;
applySvg();
}
function applySvg() {
const wW = wrap.clientWidth, wH = wrap.clientHeight;
svg.setAttribute('width', wW);
svg.setAttribute('height', wH);
svg.setAttribute('viewBox',
`${minX - panX / scale} ${minY - panY / scale} ${wW / scale} ${wH / scale}`);
}
wrap.addEventListener('wheel', e => {
e.preventDefault();
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
const rect = wrap.getBoundingClientRect();
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
panX = mx - (mx - panX) * factor;
panY = my - (my - panY) * factor;
scale *= factor;
applySvg();
}, { passive: false });
wrap.addEventListener('pointerdown', e => {
dragging = true; dragStart = { x: e.clientX - panX, y: e.clientY - panY };
wrap.setPointerCapture(e.pointerId);
});
wrap.addEventListener('pointermove', e => {
if (!dragging) return;
panX = e.clientX - dragStart.x;
panY = e.clientY - dragStart.y;
applySvg();
});
wrap.addEventListener('pointerup', () => dragging = false);
// ── SVG helpers ──
const NS = 'http://www.w3.org/2000/svg';
function svgEl(tag, attrs) {
const el = document.createElementNS(NS, tag);
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
return el;
}
// ── Grid layer ──
let gridVisible = true;
const gridGroup = svgEl('g', { id: 'grid-layer' });
svg.appendChild(gridGroup);
function drawGrid() {
gridGroup.innerHTML = '';
const step = 0.5; // 0.5mm grid
const x0 = Math.floor(minX / step) * step;
const y0 = Math.floor(minY / step) * step;
for (let x = x0; x <= maxX; x += step) {
gridGroup.appendChild(svgEl('line', {
x1: x, y1: minY, x2: x, y2: maxY,
stroke: '#21262d', 'stroke-width': 0.015, opacity: 0.5
}));
}
for (let y = y0; y <= maxY; y += step) {
gridGroup.appendChild(svgEl('line', {
x1: minX, y1: y, x2: maxX, y2: y,
stroke: '#21262d', 'stroke-width': 0.015, opacity: 0.5
}));
}
// Origin crosshair
gridGroup.appendChild(svgEl('line', {
x1: minX, y1: 0, x2: maxX, y2: 0,
stroke: '#30363d', 'stroke-width': 0.025, opacity: 0.6
}));
gridGroup.appendChild(svgEl('line', {
x1: 0, y1: minY, x2: 0, y2: maxY,
stroke: '#30363d', 'stroke-width': 0.025, opacity: 0.6
}));
}
drawGrid();
function toggleGrid() {
gridVisible = !gridVisible;
gridGroup.style.display = gridVisible ? '' : 'none';
document.getElementById('btn-grid').classList.toggle('active', gridVisible);
}
// ── Pad layer ──
const padGroup = svgEl('g', { id: 'pad-layer' });
svg.appendChild(padGroup);
function padCornerRadius(p) {
if (p.shape === 'roundrect') return Math.min(p.width, p.height) * 0.25;
if (p.shape === 'circle' || p.shape === 'oval') return Math.min(p.width, p.height) / 2;
return 0;
}
pads.forEach((p, i) => {
if (p.mountType !== 'smd') return;
const g = svgEl('g', {
transform: `translate(${p.x}, ${p.y}) rotate(${p.rotation || 0})`
});
const r = padCornerRadius(p);
const rect = svgEl('rect', {
x: -p.width / 2, y: -p.height / 2,
width: p.width, height: p.height,
rx: r, ry: r,
fill: '#C83434', 'fill-opacity': 0.35,
stroke: '#C83434', 'stroke-width': 0.03
});
g.appendChild(rect);
// Hit zone for tooltip
const hit = svgEl('rect', {
x: -p.width / 2, y: -p.height / 2,
width: p.width, height: p.height,
fill: 'transparent', stroke: 'none', cursor: 'pointer',
'data-idx': i
});
hit.addEventListener('pointerenter', e => showTip(e, i));
hit.addEventListener('pointerleave', () => tip.style.display = 'none');
g.appendChild(hit);
padGroup.appendChild(g);
});
// ── Dot layer ──
const dotGroup = svgEl('g', { id: 'dot-layer', style: 'pointer-events:none' });
svg.appendChild(dotGroup);
// ── Label layer ──
let labelsVisible = true;
const labelGroup = svgEl('g', { id: 'label-layer', style: 'pointer-events:none' });
svg.appendChild(labelGroup);
pads.forEach(p => {
if (p.mountType !== 'smd') return;
const txt = svgEl('text', {
x: p.x, y: p.y,
fill: '#e6edf3', 'font-size': 0.25,
'font-family': 'monospace', 'font-weight': 'bold',
'text-anchor': 'middle', 'dominant-baseline': 'central',
opacity: 0.9
});
txt.textContent = p.number;
labelGroup.appendChild(txt);
});
function toggleLabels() {
labelsVisible = !labelsVisible;
labelGroup.style.display = labelsVisible ? '' : 'none';
document.getElementById('btn-labels').classList.toggle('active', labelsVisible);
}
// ── Core: compute & render dots ──
function renderDots() {
dotGroup.innerHTML = '';
const dotDiameter = parseInt(document.getElementById('dot-slider').value) / 1000; // mm
const gapMm = parseInt(document.getElementById('gap-slider').value) / 1000; // mm
const dotRadius = dotDiameter / 2;
const spacing = dotDiameter + gapMm; // center-to-center
document.getElementById('dot-value').textContent = Math.round(dotDiameter * 1000) + ' \u00b5m';
document.getElementById('gap-value').textContent = Math.round(gapMm * 1000) + ' \u00b5m';
let totalDots = 0;
let totalPadArea = 0;
let totalDotArea = 0;
const dotArea = Math.PI * dotRadius * dotRadius;
pads.forEach(p => {
if (p.mountType !== 'smd') return;
const padArea = p.width * p.height;
totalPadArea += padArea;
const rot = (p.rotation || 0) * Math.PI / 180;
const cosR = Math.cos(rot), sinR = Math.sin(rot);
// Inset: dot edges flush with pad edges
const inset = dotRadius;
const availW = p.width - 2 * inset;
const availH = p.height - 2 * inset;
const nCols = availW >= 0 ? Math.max(1, Math.floor(availW / spacing) + 1) : 0;
const nRows = availH >= 0 ? Math.max(1, Math.floor(availH / spacing) + 1) : 0;
if (nCols === 0 || nRows === 0) return; // dot too big for pad
const spanW = nCols > 1 ? Math.min(availW, (nCols - 1) * spacing) : 0;
const spanH = nRows > 1 ? Math.min(availH, (nRows - 1) * spacing) : 0;
const stepX = nCols > 1 ? spanW / (nCols - 1) : 0;
const stepY = nRows > 1 ? spanH / (nRows - 1) : 0;
const startX = -spanW / 2;
const startY = -spanH / 2;
let padDots = 0;
for (let row = 0; row < nRows; row++) {
for (let col = 0; col < nCols; col++) {
const lx = startX + col * stepX;
const ly = startY + row * stepY;
const rx = lx * cosR - ly * sinR;
const ry = lx * sinR + ly * cosR;
const circle = svgEl('circle', {
cx: (p.x + rx).toFixed(4),
cy: (p.y + ry).toFixed(4),
r: dotRadius.toFixed(4),
fill: '#D4AA70', 'fill-opacity': 0.8,
stroke: '#B8860B', 'stroke-width': 0.012
});
dotGroup.appendChild(circle);
padDots++;
}
}
totalDots += padDots;
totalDotArea += padDots * dotArea;
});
// Update stats
const coverage = totalPadArea > 0 ? (totalDotArea / totalPadArea * 100) : 0;
const covClass = coverage >= 60 ? 'coverage-good' : coverage >= 35 ? 'coverage-ok' : 'coverage-low';
const smds = pads.filter(p => p.mountType === 'smd').length;
const avgPerPad = smds > 0 ? (totalDots / smds).toFixed(1) : 0;
document.getElementById('stats-bar').innerHTML =
'<div class="stat-item">Total dots: <span class="stat-val">' + totalDots + '</span></div>' +
'<div class="stat-item">Pads: <span class="stat-val">' + smds + '</span></div>' +
'<div class="stat-item">Avg/pad: <span class="stat-val">' + avgPerPad + '</span></div>' +
'<div class="stat-item">Dot area coverage: <span class="stat-val ' + covClass + '">' + coverage.toFixed(1) + '%</span></div>' +
'<div class="stat-item">Dot \u00f8: <span class="stat-val">' + Math.round(dotDiameter * 1000) + ' \u00b5m</span></div>' +
'<div class="stat-item">Spacing: <span class="stat-val">' + Math.round(spacing * 1000) + ' \u00b5m c-c</span></div>';
}
// ── Tooltip ──
function showTip(e, idx) {
const p = pads[idx];
const dotDiameter = parseInt(document.getElementById('dot-slider').value) / 1000;
const gapMm = parseInt(document.getElementById('gap-slider').value) / 1000;
const spacing = dotDiameter + gapMm;
const dotRadius = dotDiameter / 2;
const inset = dotRadius;
const availW = p.width - 2 * inset;
const availH = p.height - 2 * inset;
const nCols = availW >= 0 ? Math.max(1, Math.floor(availW / spacing) + 1) : 0;
const nRows = availH >= 0 ? Math.max(1, Math.floor(availH / spacing) + 1) : 0;
const dots = nCols * nRows;
const dotArea = Math.PI * dotRadius * dotRadius;
const padArea = p.width * p.height;
const cov = padArea > 0 ? (dots * dotArea / padArea * 100).toFixed(1) : 0;
tip.innerHTML =
'<span class="tip-accent">Pad ' + p.number + '</span><br>' +
'<span class="tip-label">Size:</span> ' + p.width.toFixed(3) + ' x ' + p.height.toFixed(3) + ' mm<br>' +
'<span class="tip-label">Shape:</span> ' + p.shape + '<br>' +
'<span class="tip-label">Dots:</span> ' + dots + ' (' + nCols + ' x ' + nRows + ')<br>' +
'<span class="tip-label">Coverage:</span> ' + cov + '%';
const rect = wrap.getBoundingClientRect();
tip.style.display = 'block';
tip.style.left = (e.clientX - rect.left + 14) + 'px';
tip.style.top = (e.clientY - rect.top - 10) + 'px';
}
// ── Wire up slider ──
document.getElementById('dot-slider').addEventListener('input', renderDots);
document.getElementById('gap-slider').addEventListener('input', renderDots);
// ── Init ──
window.addEventListener('resize', () => { fitView(); });
fitView();
renderDots();
</script>
</body>
</html>