← Commit history

pinout: a hit area you can actually hit, and sync the three copies

Ray ·657d72f117 ·1mo ago ·parent 155603b
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}