app
Component Widgets
Public Made by Adomby adom
Reusable component widgets for pinouts, thermal vias, molecule relationships, reviewed chip outlines and lightweight four-level LOD cache listings.
← Commit history
pinout: a hit area you can actually hit, and sync the three copies
A pin draws a 2px stub, a 3.1px dot and an 8px label, so selecting one meant
landing almost exactly on the line. Measured perpendicular reach was 1px.
Each pin now carries a transparent fat line down its whole lane, from the body
edge out past the number, and that catches the pointer. Width tracks the real
pin pitch (0.86x, clamped 7..20) so a bigger target never bleeds into the
neighbour on a dense package.
Measured on the 48-pin LQFP: perpendicular reach 1px -> 8px, outward reach
25px -> 40px. All 48 pins still select themselves, none stolen by a neighbour,
none dead. Rendered screenshots before and after are byte-identical, so this is
purely a hit-testing change.
Also syncing files/ and skills/component-widgets/widgets/, which were both stale
by exactly the Aug 24 fixes (corner keep-out and the blob-to-data download).
Three copies of one widget in one repo is how the stm32 page ended up serving a
buggy pinout for a week.
3 files changed
+120−23
files/pinout-widget.js+47−11@@ -61,22 +61,29 @@ return { ...p, x, y, side }; }); } else {+ // The body is drawn inset 16 from the outermost pins, so a run distributed+ // across the whole inner..outer span puts the FIRST and LAST pin of each+ // side outside the body: at 12 per side they landed 4.3px past each corner+ // and collided with the end pin of the neighbouring side (1 into 48, 12+ // into 13, and so on). Real quad packages have a corner keep-out anyway,+ // which is why the xy path never showed this. Inset the run to match. const N = edge.length, inner = pad, outer = S - pad, at = (a, b, t) => a + (b - a) * t;+ const CORNER = 26, runLo = inner + CORNER, runHi = outer - CORNER; const quad = forced === 'quad' || (forced !== 'dual' && /QF|BGA|LGA|LCC|QFN|QFP/i.test(pkg || '')); if (quad) { const per = Math.ceil(N / 4); laid = edge.map((p, i) => { const s = Math.floor(i / per), t = ((i % per) + 0.5) / per;- if (s === 0) return { ...p, x: inner, y: at(inner, outer, t), side: 'left' };- if (s === 1) return { ...p, x: at(inner, outer, t), y: outer, side: 'bottom' };- if (s === 2) return { ...p, x: outer, y: at(outer, inner, t), side: 'right' };- return { ...p, x: at(outer, inner, t), y: inner, side: 'top' };+ if (s === 0) return { ...p, x: inner, y: at(runLo, runHi, t), side: 'left' };+ if (s === 1) return { ...p, x: at(runLo, runHi, t), y: outer, side: 'bottom' };+ if (s === 2) return { ...p, x: outer, y: at(runHi, runLo, t), side: 'right' };+ return { ...p, x: at(runHi, runLo, t), y: inner, side: 'top' }; }); } else { const half = Math.ceil(N / 2); // DIP-style: left top→bottom, right bottom→top laid = edge.map((p, i) => i < half- ? { ...p, x: inner, y: at(inner, outer, (i + 0.5) / half), side: 'left' }- : { ...p, x: outer, y: at(outer, inner, (i - half + 0.5) / (N - half)), side: 'right' });+ ? { ...p, x: inner, y: at(runLo, runHi, (i + 0.5) / half), side: 'left' }+ : { ...p, x: outer, y: at(runHi, runLo, (i - half + 0.5) / (N - half)), side: 'right' }); } } centers.forEach(p => laid.push({ ...p, x: mid, y: mid, side: 'center' }));@@ -156,6 +163,24 @@ + `<rect class="ep" x="${ex - 19}" y="${ey - 13}" width="38" height="26" rx="4" fill="${col}" fill-opacity="0.15" stroke="${col}"/>` + `<text class="ept" x="${ex}" y="${ey + 3}" text-anchor="middle">${esc(p.name)}·${esc(p.number)}</text></g>`; });+ // Hover target. What a pin actually draws is a 2px stub, a 3.1px dot and an+ // 8px label, so the pointer has to land nearly on the line to select it.+ // Each pin gets a transparent fat line down its whole lane instead, from the+ // body edge out past the number, and that is what catches the mouse. The+ // width tracks the real pin pitch so a wider target never bleeds into the+ // neighbouring pin on a dense package.+ const pitch = (() => {+ let m = Infinity;+ for (const side of ['left', 'right', 'top', 'bottom']) {+ const v = edge.filter(q => q.side === side)+ .map(q => (side === 'left' || side === 'right') ? q.y : q.x)+ .sort((a, b) => a - b);+ for (let i = 1; i < v.length; i++) m = Math.min(m, v[i] - v[i - 1]);+ }+ return isFinite(m) && m > 0 ? m : 16;+ })();+ const HIT = Math.max(7, Math.min(20, pitch * 0.86));+ const REACH = 30; // out past the pin number, which sits 9-14 out for (const p of edge) { const col = TYPE_COLORS[p.type] || TYPE_COLORS.other; let ex = p.x, ey = p.y, lx = p.x, ly = p.y, anc = 'middle';@@ -163,7 +188,13 @@ else if (p.side === 'right') { ex = bR; lx = p.x + 11; anc = 'start'; ly = p.y + 3; } else if (p.side === 'top') { ey = bT; ly = p.y - 9; } else { ey = bB; ly = p.y + 14; }+ let hx = p.x, hy = p.y;+ if (p.side === 'left') hx = p.x - REACH;+ else if (p.side === 'right') hx = p.x + REACH;+ else if (p.side === 'top') hy = p.y - REACH;+ else hy = p.y + REACH; s += `<g class="pin" data-pin="${esc(p.number)}">`+ + `<line class="hit" x1="${ex}" y1="${ey}" x2="${hx}" y2="${hy}" stroke-width="${HIT.toFixed(1)}"/>` + `<line class="stub" x1="${p.x}" y1="${p.y}" x2="${ex}" y2="${ey}" stroke="${col}"/>` + `<circle class="dot" cx="${p.x}" cy="${p.y}" r="3.1" fill="${col}"/>` + `<text class="pn" x="${lx}" y="${ly}" text-anchor="${anc}">${esc(p.number)}</text></g>`;@@ -206,10 +237,14 @@ _exportJson() { return JSON.stringify(this.toJSON(), null, 2); } _exportName() { const d = this._data || {}; return ((d.mpn || 'pinout') + (d.package ? '-' + d.package : '')).replace(/[^\w.-]+/g, '_') + '.pins.json'; } _download() {- const blob = new Blob([this._exportJson()], { type: 'application/json' });- const url = URL.createObjectURL(blob), a = document.createElement('a');- a.href = url; a.download = this._exportName(); document.body.appendChild(a); a.click();- a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000);+ // A data: URL, not a blob:. As a registered widget this renders inside the+ // wiki's sandboxed frame, whose CSP has no blob:, so createObjectURL is+ // refused at runtime and the button does nothing at all. data: is the only+ // download route that works in every host we render in.+ const a = document.createElement('a');+ a.href = 'data:application/json;charset=utf-8,' + encodeURIComponent(this._exportJson());+ a.download = this._exportName();+ document.body.appendChild(a); a.click(); a.remove(); } async _copy(btn) { const txt = this._exportJson(), done = () => { if (!btn) return; const o = btn.textContent; btn.textContent = 'copied'; btn.classList.add('ok'); setTimeout(() => { btn.textContent = o; btn.classList.remove('ok'); }, 1100); };@@ -281,11 +316,12 @@ .pkgbody{fill:color-mix(in srgb,var(--text) 6%,var(--panel));stroke:var(--line2);stroke-width:1} .pin1{fill:var(--accent)} .lbl{fill:var(--dim);font:600 11px ui-monospace,monospace} .lbl2{fill:var(--dim2);font:9px ui-monospace,monospace}+ .hit{stroke:transparent;fill:none;stroke-linecap:round;pointer-events:stroke} .pin .stub{stroke-width:2} .pin .pn{fill:var(--dim2);font:8px ui-monospace,monospace} .pin:hover .stub,.pin.hot .stub{stroke-width:3.6} .pin:hover .pn,.pin.hot .pn{fill:var(--text);font-weight:700} .pin .ep{stroke-width:1.2} .pin .ept{fill:var(--dim);font:8px ui-monospace,monospace} .pin:hover .ep,.pin.hot .ep{fill-opacity:.34;stroke-width:2} .pin:hover .ept,.pin.hot .ept{fill:var(--text);font-weight:700}- .pin{cursor:default}+ .pin{cursor:pointer} .legend{display:flex;flex-wrap:wrap;gap:13px;margin:8px 2px 2px;font-size:11.5px;color:var(--dim)} .legend i{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:5px;vertical-align:middle} .tablewrap{max-height:440px;overflow:auto;border:1px solid var(--line);border-radius:10px;scrollbar-width:thin;scrollbar-color:var(--line2) transparent}
skills/component-widgets/widgets/pinout-widget.js+47−11@@ -61,22 +61,29 @@ return { ...p, x, y, side }; }); } else {+ // The body is drawn inset 16 from the outermost pins, so a run distributed+ // across the whole inner..outer span puts the FIRST and LAST pin of each+ // side outside the body: at 12 per side they landed 4.3px past each corner+ // and collided with the end pin of the neighbouring side (1 into 48, 12+ // into 13, and so on). Real quad packages have a corner keep-out anyway,+ // which is why the xy path never showed this. Inset the run to match. const N = edge.length, inner = pad, outer = S - pad, at = (a, b, t) => a + (b - a) * t;+ const CORNER = 26, runLo = inner + CORNER, runHi = outer - CORNER; const quad = forced === 'quad' || (forced !== 'dual' && /QF|BGA|LGA|LCC|QFN|QFP/i.test(pkg || '')); if (quad) { const per = Math.ceil(N / 4); laid = edge.map((p, i) => { const s = Math.floor(i / per), t = ((i % per) + 0.5) / per;- if (s === 0) return { ...p, x: inner, y: at(inner, outer, t), side: 'left' };- if (s === 1) return { ...p, x: at(inner, outer, t), y: outer, side: 'bottom' };- if (s === 2) return { ...p, x: outer, y: at(outer, inner, t), side: 'right' };- return { ...p, x: at(outer, inner, t), y: inner, side: 'top' };+ if (s === 0) return { ...p, x: inner, y: at(runLo, runHi, t), side: 'left' };+ if (s === 1) return { ...p, x: at(runLo, runHi, t), y: outer, side: 'bottom' };+ if (s === 2) return { ...p, x: outer, y: at(runHi, runLo, t), side: 'right' };+ return { ...p, x: at(runHi, runLo, t), y: inner, side: 'top' }; }); } else { const half = Math.ceil(N / 2); // DIP-style: left top→bottom, right bottom→top laid = edge.map((p, i) => i < half- ? { ...p, x: inner, y: at(inner, outer, (i + 0.5) / half), side: 'left' }- : { ...p, x: outer, y: at(outer, inner, (i - half + 0.5) / (N - half)), side: 'right' });+ ? { ...p, x: inner, y: at(runLo, runHi, (i + 0.5) / half), side: 'left' }+ : { ...p, x: outer, y: at(runHi, runLo, (i - half + 0.5) / (N - half)), side: 'right' }); } } centers.forEach(p => laid.push({ ...p, x: mid, y: mid, side: 'center' }));@@ -156,6 +163,24 @@ + `<rect class="ep" x="${ex - 19}" y="${ey - 13}" width="38" height="26" rx="4" fill="${col}" fill-opacity="0.15" stroke="${col}"/>` + `<text class="ept" x="${ex}" y="${ey + 3}" text-anchor="middle">${esc(p.name)}·${esc(p.number)}</text></g>`; });+ // Hover target. What a pin actually draws is a 2px stub, a 3.1px dot and an+ // 8px label, so the pointer has to land nearly on the line to select it.+ // Each pin gets a transparent fat line down its whole lane instead, from the+ // body edge out past the number, and that is what catches the mouse. The+ // width tracks the real pin pitch so a wider target never bleeds into the+ // neighbouring pin on a dense package.+ const pitch = (() => {+ let m = Infinity;+ for (const side of ['left', 'right', 'top', 'bottom']) {+ const v = edge.filter(q => q.side === side)+ .map(q => (side === 'left' || side === 'right') ? q.y : q.x)+ .sort((a, b) => a - b);+ for (let i = 1; i < v.length; i++) m = Math.min(m, v[i] - v[i - 1]);+ }+ return isFinite(m) && m > 0 ? m : 16;+ })();+ const HIT = Math.max(7, Math.min(20, pitch * 0.86));+ const REACH = 30; // out past the pin number, which sits 9-14 out for (const p of edge) { const col = TYPE_COLORS[p.type] || TYPE_COLORS.other; let ex = p.x, ey = p.y, lx = p.x, ly = p.y, anc = 'middle';@@ -163,7 +188,13 @@ else if (p.side === 'right') { ex = bR; lx = p.x + 11; anc = 'start'; ly = p.y + 3; } else if (p.side === 'top') { ey = bT; ly = p.y - 9; } else { ey = bB; ly = p.y + 14; }+ let hx = p.x, hy = p.y;+ if (p.side === 'left') hx = p.x - REACH;+ else if (p.side === 'right') hx = p.x + REACH;+ else if (p.side === 'top') hy = p.y - REACH;+ else hy = p.y + REACH; s += `<g class="pin" data-pin="${esc(p.number)}">`+ + `<line class="hit" x1="${ex}" y1="${ey}" x2="${hx}" y2="${hy}" stroke-width="${HIT.toFixed(1)}"/>` + `<line class="stub" x1="${p.x}" y1="${p.y}" x2="${ex}" y2="${ey}" stroke="${col}"/>` + `<circle class="dot" cx="${p.x}" cy="${p.y}" r="3.1" fill="${col}"/>` + `<text class="pn" x="${lx}" y="${ly}" text-anchor="${anc}">${esc(p.number)}</text></g>`;@@ -206,10 +237,14 @@ _exportJson() { return JSON.stringify(this.toJSON(), null, 2); } _exportName() { const d = this._data || {}; return ((d.mpn || 'pinout') + (d.package ? '-' + d.package : '')).replace(/[^\w.-]+/g, '_') + '.pins.json'; } _download() {- const blob = new Blob([this._exportJson()], { type: 'application/json' });- const url = URL.createObjectURL(blob), a = document.createElement('a');- a.href = url; a.download = this._exportName(); document.body.appendChild(a); a.click();- a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000);+ // A data: URL, not a blob:. As a registered widget this renders inside the+ // wiki's sandboxed frame, whose CSP has no blob:, so createObjectURL is+ // refused at runtime and the button does nothing at all. data: is the only+ // download route that works in every host we render in.+ const a = document.createElement('a');+ a.href = 'data:application/json;charset=utf-8,' + encodeURIComponent(this._exportJson());+ a.download = this._exportName();+ document.body.appendChild(a); a.click(); a.remove(); } async _copy(btn) { const txt = this._exportJson(), done = () => { if (!btn) return; const o = btn.textContent; btn.textContent = 'copied'; btn.classList.add('ok'); setTimeout(() => { btn.textContent = o; btn.classList.remove('ok'); }, 1100); };@@ -281,11 +316,12 @@ .pkgbody{fill:color-mix(in srgb,var(--text) 6%,var(--panel));stroke:var(--line2);stroke-width:1} .pin1{fill:var(--accent)} .lbl{fill:var(--dim);font:600 11px ui-monospace,monospace} .lbl2{fill:var(--dim2);font:9px ui-monospace,monospace}+ .hit{stroke:transparent;fill:none;stroke-linecap:round;pointer-events:stroke} .pin .stub{stroke-width:2} .pin .pn{fill:var(--dim2);font:8px ui-monospace,monospace} .pin:hover .stub,.pin.hot .stub{stroke-width:3.6} .pin:hover .pn,.pin.hot .pn{fill:var(--text);font-weight:700} .pin .ep{stroke-width:1.2} .pin .ept{fill:var(--dim);font:8px ui-monospace,monospace} .pin:hover .ep,.pin.hot .ep{fill-opacity:.34;stroke-width:2} .pin:hover .ept,.pin.hot .ept{fill:var(--text);font-weight:700}- .pin{cursor:default}+ .pin{cursor:pointer} .legend{display:flex;flex-wrap:wrap;gap:13px;margin:8px 2px 2px;font-size:11.5px;color:var(--dim)} .legend i{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:5px;vertical-align:middle} .tablewrap{max-height:440px;overflow:auto;border:1px solid var(--line);border-radius:10px;scrollbar-width:thin;scrollbar-color:var(--line2) transparent}
widgets/pinout-widget.js+26−1@@ -163,6 +163,24 @@ + `<rect class="ep" x="${ex - 19}" y="${ey - 13}" width="38" height="26" rx="4" fill="${col}" fill-opacity="0.15" stroke="${col}"/>` + `<text class="ept" x="${ex}" y="${ey + 3}" text-anchor="middle">${esc(p.name)}·${esc(p.number)}</text></g>`; });+ // Hover target. What a pin actually draws is a 2px stub, a 3.1px dot and an+ // 8px label, so the pointer has to land nearly on the line to select it.+ // Each pin gets a transparent fat line down its whole lane instead, from the+ // body edge out past the number, and that is what catches the mouse. The+ // width tracks the real pin pitch so a wider target never bleeds into the+ // neighbouring pin on a dense package.+ const pitch = (() => {+ let m = Infinity;+ for (const side of ['left', 'right', 'top', 'bottom']) {+ const v = edge.filter(q => q.side === side)+ .map(q => (side === 'left' || side === 'right') ? q.y : q.x)+ .sort((a, b) => a - b);+ for (let i = 1; i < v.length; i++) m = Math.min(m, v[i] - v[i - 1]);+ }+ return isFinite(m) && m > 0 ? m : 16;+ })();+ const HIT = Math.max(7, Math.min(20, pitch * 0.86));+ const REACH = 30; // out past the pin number, which sits 9-14 out for (const p of edge) { const col = TYPE_COLORS[p.type] || TYPE_COLORS.other; let ex = p.x, ey = p.y, lx = p.x, ly = p.y, anc = 'middle';@@ -170,7 +188,13 @@ else if (p.side === 'right') { ex = bR; lx = p.x + 11; anc = 'start'; ly = p.y + 3; } else if (p.side === 'top') { ey = bT; ly = p.y - 9; } else { ey = bB; ly = p.y + 14; }+ let hx = p.x, hy = p.y;+ if (p.side === 'left') hx = p.x - REACH;+ else if (p.side === 'right') hx = p.x + REACH;+ else if (p.side === 'top') hy = p.y - REACH;+ else hy = p.y + REACH; s += `<g class="pin" data-pin="${esc(p.number)}">`+ + `<line class="hit" x1="${ex}" y1="${ey}" x2="${hx}" y2="${hy}" stroke-width="${HIT.toFixed(1)}"/>` + `<line class="stub" x1="${p.x}" y1="${p.y}" x2="${ex}" y2="${ey}" stroke="${col}"/>` + `<circle class="dot" cx="${p.x}" cy="${p.y}" r="3.1" fill="${col}"/>` + `<text class="pn" x="${lx}" y="${ly}" text-anchor="${anc}">${esc(p.number)}</text></g>`;@@ -292,11 +316,12 @@ .pkgbody{fill:color-mix(in srgb,var(--text) 6%,var(--panel));stroke:var(--line2);stroke-width:1} .pin1{fill:var(--accent)} .lbl{fill:var(--dim);font:600 11px ui-monospace,monospace} .lbl2{fill:var(--dim2);font:9px ui-monospace,monospace}+ .hit{stroke:transparent;fill:none;stroke-linecap:round;pointer-events:stroke} .pin .stub{stroke-width:2} .pin .pn{fill:var(--dim2);font:8px ui-monospace,monospace} .pin:hover .stub,.pin.hot .stub{stroke-width:3.6} .pin:hover .pn,.pin.hot .pn{fill:var(--text);font-weight:700} .pin .ep{stroke-width:1.2} .pin .ept{fill:var(--dim);font:8px ui-monospace,monospace} .pin:hover .ep,.pin.hot .ep{fill-opacity:.34;stroke-width:2} .pin:hover .ept,.pin.hot .ept{fill:var(--text);font-weight:700}- .pin{cursor:default}+ .pin{cursor:pointer} .legend{display:flex;flex-wrap:wrap;gap:13px;margin:8px 2px 2px;font-size:11.5px;color:var(--dim)} .legend i{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:5px;vertical-align:middle} .tablewrap{max-height:440px;overflow:auto;border:1px solid var(--line);border-radius:10px;scrollbar-width:thin;scrollbar-color:var(--line2) transparent}