main
Caleb Publish 0.5.0 1984b40
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Nets</title>
<style>
:root{--bg:#0d1117;--panel:#161b22;--line:#30363d;--text:#e6edf3;--dim:#8b949e;--accent:#2f81f7}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--bg);color:var(--text);font:14px/1.45 Inter,system-ui,-apple-system,"Segoe UI",sans-serif}
main{display:grid;grid-template-columns:300px 1fr;height:100%}
aside{border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0}
.top{padding:10px;border-bottom:1px solid var(--line);display:flex;flex-direction:column;gap:8px}
.modes{display:flex;gap:6px}
.modes button{flex:1;min-height:44px;border:1px solid var(--line);background:none;color:var(--text);border-radius:6px;cursor:pointer;font:inherit}
.modes button.sel{border-color:var(--accent);background:#1f2a3a}
input{min-height:36px;border:1px solid var(--line);background:var(--panel);color:var(--text);border-radius:6px;padding:0 10px;font:inherit}
#list{overflow:auto;flex:1;padding:6px}
h2{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--dim);margin:12px 6px 4px}
.net{display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;cursor:pointer;min-height:32px}
.net:hover{background:#1f2630}.net.sel{background:#1f2a3a;outline:1px solid var(--accent)}
.net i{width:14px;height:14px;border-radius:3px;flex:none;border:1px solid #ffffff55}
.net b{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.net span{margin-left:auto;color:var(--dim);font-size:12px;white-space:nowrap}
section{display:flex;flex-direction:column;min-width:0;min-height:0}
#info{padding:10px 14px;border-bottom:1px solid var(--line);min-height:64px}
#info .name{font-size:20px;font-weight:700;margin-right:10px}
#info .lv{font-weight:700;padding:2px 8px;border-radius:5px;background:#2c3036}
#info .why{color:var(--dim);font-size:13px;margin-top:2px}
#faces{flex:1;display:flex;gap:10px;padding:10px;min-height:0}
.face{flex:1;min-width:0;display:flex;flex-direction:column}
.face .cap{font-size:11px;letter-spacing:.05em;color:var(--dim);margin-bottom:4px;text-transform:uppercase}
.stack{position:relative;flex:1;min-height:0}
.stack img,.stack canvas{position:absolute;left:0;top:0;width:100%;height:100%;object-fit:contain}
.stack img{filter:grayscale(.72) brightness(.6)}
.stack canvas{cursor:crosshair;image-rendering:auto}
#legend{display:flex;flex-wrap:wrap;gap:14px;padding:8px 14px;border-top:1px solid var(--line);font-size:12px;color:var(--dim)}
#legend div{display:flex;align-items:center;gap:6px}
#legend canvas{width:18px;height:18px;border:1px solid #ffffff55}
</style>
</head>
<body>
<main>
  <aside>
    <div class="top">
      <div class="modes"><button id="m-conf" class="sel">By confidence</button><button id="m-net">By net</button></div>
      <input id="q" placeholder="Find a net" autocomplete="off">
    </div>
    <div id="list"></div>
  </aside>
  <section>
    <div id="info"></div>
    <div id="faces"></div>
    <div id="legend"></div>
  </section>
</main>
<script>
const D = /*DATA*/null/*END*/;
const MAPS = /*MAPS*/{}/*END*/;
const $ = id => document.getElementById(id);
const esc = s => String(s ?? "").replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
const RUNG = D.rungColors, HUES = D.netHues, LADDER = D.ladder;
const WORDS = {copper: "every pin seen joined by continuous copper", via: "pins joined, the weakest join through verified vias", inferred: "at least one pin or join rests on inference", group: "unnamed copper joined to other unnamed copper", none: "isolated copper: no pad, no valid via", conflict: "two nets meet and nothing settles it"};
let mode = "conf", sel = null, faces = [];
const rank = l => Math.max(0, LADDER.indexOf(l));
const byName = Object.fromEntries(D.nets.map(n => [n.name, n]));
// the level a piece is drawn at: the weaker of its own rung and its net's level
const idLevel = D.ids.map(e => e.u !== undefined ? e.u : rank((byName[e.n] || {}).level || "none"));
const idNet = D.ids.map(e => e.n || null);
const hueOf = {}; D.nets.forEach((n, i) => hueOf[n.name] = HUES[i % HUES.length]);

function pattern(k, x, y) {
  const per = 10, m = (v, n) => ((v % n) + n) % n;
  switch (k) {
    case 0: return 1;
    case 1: return m(y, per) < 6 ? 1 : 0.38;
    case 2: return m(x + y, per) < 5 ? 1 : 0.28;
    case 3: return (m(x, per) < 3 || m(y, per) < 3) ? 1 : 0.22;
    case 4: return (m(x, per) < 4 && m(y, per) < 4) ? 1 : 0.3;
    default: return m(x - y, 15) < 7 ? 1 : -1;
  }
}

function loadFace(side) {
  return new Promise(res => {
    if (!MAPS[side]) return res(null);
    const im = new Image();
    im.onload = () => {
      const c = document.createElement("canvas"); c.width = im.width; c.height = im.height;
      const g = c.getContext("2d", {willReadFrequently: true}); g.drawImage(im, 0, 0);
      res({side, w: im.width, h: im.height, map: g.getImageData(0, 0, im.width, im.height).data});
    };
    im.src = MAPS[side];
  });
}

function paint(f) {
  const g = f.canvas.getContext("2d"), out = g.createImageData(f.w, f.h), o = out.data, m = f.map;
  const selId = sel ? D.ids.findIndex(e => e.n === sel) : -1;
  for (let y = 0, i = 0; y < f.h; y++) for (let x = 0; x < f.w; x++, i += 4) {
    const b = m[i + 2]; if (!b) continue;
    const id = m[i] + (m[i + 1] << 8) - 1, rung = b - 1;
    let c, a;
    if (sel) {
      if (id !== selId) continue;
      if (mode === "conf") { const k = pattern(rung, x, y); c = k < 0 ? [12, 14, 18] : RUNG[rung]; a = 0.92 * Math.abs(k); } else { c = [255, 221, 60]; a = 0.92; }
    } else if (mode === "conf") {
      const lv = Math.max(rung, idLevel[id]), k = pattern(lv, x, y); c = k < 0 ? [12, 14, 18] : RUNG[lv]; a = 0.86 * Math.abs(k);
    } else {
      const n = idNet[id]; if (n && byName[n]) { c = hueOf[n]; a = 0.82; } else { c = [150, 150, 150]; a = 0.3; }
    }
    o[i] = c[0]; o[i + 1] = c[1]; o[i + 2] = c[2]; o[i + 3] = Math.round(255 * a);
  }
  g.putImageData(out, 0, 0);
  if (!sel) return;
  // the selected net's pins: filled when seen joined, hollow when on the net by inference only; the weakest link ringed
  const n = byName[sel], s = D.res, bit = f.side === "top" ? 1 : 2, px = (x, y) => [(x - 100) * s, (y - 100) * s];
  for (const p of (D.pins[sel] || [])) {
    if (!(p[2] & bit)) continue;
    const [x, y] = px(p[0], p[1]), r = 5;
    g.lineWidth = 4; g.strokeStyle = "#0c0e12"; g.strokeRect(x - r, y - r, 2 * r, 2 * r);
    if (p[3]) { g.fillStyle = "#fff"; g.fillRect(x - r, y - r, 2 * r, 2 * r); } else { g.lineWidth = 2; g.strokeStyle = "#fff"; g.strokeRect(x - r, y - r, 2 * r, 2 * r); }
  }
  if (n && n.at && n.level !== "copper" && (!n.side || n.side === f.side)) {
    const [x, y] = px(n.at[0], n.at[1]);
    g.beginPath(); g.arc(x, y, 16, 0, 6.3); g.lineWidth = 6; g.strokeStyle = "#0c0e12"; g.stroke(); g.lineWidth = 3; g.strokeStyle = "#ffeb5a"; g.stroke();
  }
}

function list() {
  const q = $("q").value.trim().toLowerCase();
  const show = n => !q || n.name.toLowerCase().includes(q);
  const row = n => `<div class="net ${sel === n.name ? "sel" : ""}" data-n="${esc(n.name)}" title="${esc(n.weakest || "")}"><i style="background:rgb(${(mode === "conf" ? RUNG[rank(n.level)] : hueOf[n.name]).join(",")})"></i><b>${esc(n.name)}</b><span>${mode === "conf" ? `${n.seen} of ${n.pins} pins` : n.level}</span></div>`;
  let html = "";
  if (mode === "conf") {
    for (const l of ["copper", "via", "inferred", "none"]) {
      const v = D.nets.filter(n => n.level === l); if (!v.length) continue;
      const w = v.filter(show); if (!w.length) continue;
      html += `<h2>${l}: ${v.length} net${v.length === 1 ? "" : "s"}</h2>` + w.map(row).join("");
    }
    html += `<h2>No net</h2><div class="net" style="cursor:default"><span style="margin:0">${D.unnamed.islands} isolated islands, ${D.unnamed.groups} groups, ${D.unnamed.conflicts} in conflict: drawn, not listed</span></div>`;
  } else {
    for (const s of D.sections) { const w = s.nets.map(x => byName[x]).filter(n => n && show(n)); if (w.length) html += `<h2>${esc(s.title)}</h2>` + w.map(row).join(""); }
  }
  $("list").innerHTML = html;
  document.querySelectorAll(".net[data-n]").forEach(el => el.onclick = () => pick(el.dataset.n === sel ? null : el.dataset.n));
}

function info() {
  const n = sel && byName[sel];
  if (!n) { $("info").innerHTML = mode === "conf" ? `<span class="name">Net confidence</span><div class="why">Every piece of copper in the colour and pattern of how well its net is proven: a net is as proven as the weakest link joining its pins. Click a net in the list or on the board.</div>` : `<span class="name">Nets</span><div class="why">Each named net in its own hue, copper with no net in grey. Click a net in the list or on the board to light it on both faces.</div>`; return; }
  const c = RUNG[rank(n.level)].join(",");
  $("info").innerHTML = `<span class="name">${esc(n.name)}</span><span class="lv" style="color:rgb(${c})">${n.level}</span> &nbsp; ${n.pins} pin${n.pins === 1 ? "" : "s"}, ${n.seen} seen joined to its copper${n.disputes ? `, meets another net at ${n.disputes} place${n.disputes === 1 ? "" : "s"}` : ""} &nbsp; <a style="color:var(--accent)" href="../nets/net/${encodeURIComponent(n.file)}.jpg" target="_blank">picture</a><div class="why">Weakest link: ${esc(n.weakest || "none")}</div>`;
}

function legend() {
  $("legend").innerHTML = (mode === "conf" || sel ? LADDER.map((l, k) => `<div><canvas width="18" height="18" data-k="${k}"></canvas>${l}: ${WORDS[l]}</div>`).join("") : `<div>each named net has its own hue; grey is copper with no net</div>`) + `<div>filled square: pin seen joined; hollow square: pin by inference only; yellow ring: weakest link; the bottom face is turned over to line up with the top</div>`;
  document.querySelectorAll("#legend canvas").forEach(c => { const g = c.getContext("2d"), k = +c.dataset.k; g.fillStyle = "#2c3036"; g.fillRect(0, 0, 18, 18); for (let y = 0; y < 18; y++) for (let x = 0; x < 18; x++) { const p = pattern(k, x, y); g.fillStyle = p < 0 ? "#0c0e12" : `rgba(${RUNG[k].join(",")},${p})`; g.fillRect(x, y, 1, 1); } });
}

function pick(name) { sel = name; list(); info(); legend(); faces.forEach(paint); const el = document.querySelector(".net.sel"); if (el) el.scrollIntoView({block: "nearest"}); }
function setMode(m) { mode = m; $("m-conf").classList.toggle("sel", m === "conf"); $("m-net").classList.toggle("sel", m === "net"); pick(sel); }

(async () => {
  faces = (await Promise.all(["top", "bottom"].map(loadFace))).filter(Boolean);
  $("faces").innerHTML = faces.map(f => `<div class="face"><div class="cap">${f.side === "top" ? "Top face" : "Bottom face, turned over to line up with the top"}</div><div class="stack"><img src="../board/${f.side}.jpg" alt=""><canvas id="c-${f.side}" width="${f.w}" height="${f.h}"></canvas></div></div>`).join("");
  for (const f of faces) {
    f.canvas = $("c-" + f.side);
    f.canvas.onclick = ev => {
      // the canvas is letterboxed in its box: map the click to a map pixel, then to the net under it
      const r = f.canvas.getBoundingClientRect(), k = Math.min(r.width / f.w, r.height / f.h);
      const x = Math.floor((ev.clientX - r.left - (r.width - f.w * k) / 2) / k), y = Math.floor((ev.clientY - r.top - (r.height - f.h * k) / 2) / k);
      if (x < 0 || y < 0 || x >= f.w || y >= f.h) return pick(null);
      const i = 4 * (y * f.w + x), id = f.map[i + 2] ? f.map[i] + (f.map[i + 1] << 8) - 1 : -1, n = id >= 0 ? idNet[id] : null;
      pick(n && byName[n] && n !== sel ? n : null);
    };
  }
  $("m-conf").onclick = () => setMode("conf"); $("m-net").onclick = () => setMode("net");
  $("q").oninput = list;
  document.addEventListener("keydown", e => { if (e.key === "Escape") pick(null); });
  const h = new URLSearchParams(location.hash.slice(1)); if (h.get("mode") === "net") mode = "net";
  setMode(mode); if (h.get("net") && byName[h.get("net")]) pick(h.get("net"));
})();
</script>
</body>
</html>