<!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="650">
    <span class="slider-value" id="dot-value">650 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>