velxio/frontend/src/utils/pinInspectorLayout.ts

338 lines
12 KiB
TypeScript
Raw Normal View History

feat(ui): the Part Inspector - right-click dialog merged with the picker card The right-click dialog was a 250px column: every pin as a text row (an ESP32 means 30 rows of scrolling), a few editors, Rotate/Delete. The picker's info card (datasheet, brand, Buy) was a separate surface you could only reach by hovering the catalogue. One dialog now serves both: - ~560px, two panes: live preview + actions on the left, Properties/Datasheet tabs on the right. The Datasheet tab IS the picker card's body - ComponentInfoPanel was split into an embeddable ComponentInfoBody (+ useComponentDoc, and its .cip-* styles moved to their own stylesheet) so both surfaces render one implementation. - "Show pins" (off by default): the preview is the REAL web component, mounted and scaled, with pin markers at their true pinInfo positions and labels flanking the side each pin lives on. pinInspectorLayout is pure geometry - nearest-edge classification, per-side label stacking with a minimum spacing and leader lines when nudged, interior pins as dot+tooltip - driven only by pinInfo plus the measured element size, so every part and board, present or future, works with no per-part data. Dots are tinted by the pin's declared signal (i2c/spi/power/...). - Clicking a pin keeps the old contract (onPinSelect -> handlePinClick), so wires start with the same undo, colors and running-state gating. - The two open gestures finally agree on coordinates: right-click always passed viewport coords while touch passed canvas-local ones; the dialog is now portaled to body in viewport space and the touch site converts. - Keybind capture, the SD Card panel, the two-step delete, Escape and click-outside all migrate unchanged. Mobile keeps the full-sheet layout. Geometry covered by pin-inspector-layout.test.ts with coordinate tables copied verbatim from the shipped parts (ESP32 two-column board, LED, an interior-pin case).
2026-08-04 02:44:46 +07:00
/**
* pinInspectorLayout — pure geometry for the Part Inspector's spatial pin view.
*
* Input: a part's pinInfo (element-local CSS px at scale 1 — the same numbers
* the canvas snaps wires to) and its measured natural size. Output: where to
* draw each pin dot and its label around a scaled preview of the part, so the
* dialog can show "pins where they really are" for ANY part or board without
* per-component data. That genericity is the point: pinInfo + size is the
* whole input, so future parts work unmodified.
*
* No DOM, no React, deterministic — unit-tested in
* __tests__/pin-inspector-layout.test.ts with real pin tables.
*/
export type PinEdge = 'left' | 'right' | 'top' | 'bottom' | 'interior';
export interface InspectorPinInput {
name: string;
x: number;
y: number;
signals?: Array<{ type?: string; signal?: string }>;
}
export type PinSignalKind =
| 'i2c'
| 'spi'
| 'usart'
| 'power-gnd'
| 'power-vcc'
| 'pwm'
| 'analog'
| 'other';
export interface LaidOutPin {
name: string;
edge: PinEdge;
/** Scaled px, relative to the ART box top-left. */
dotX: number;
dotY: number;
/** Scaled px, label anchor point, same origin. */
labelX: number;
labelY: number;
/** True when the label was nudged away from its dot row/column. */
needsLeader: boolean;
signalKind: PinSignalKind;
}
export interface PinLayoutResult {
/** Scale applied to the natural size. */
scale: number;
artWidth: number;
artHeight: number;
pins: LaidOutPin[];
/** Gutter reserved on each side for labels; 0 when that side has no pins. */
padLeft: number;
padRight: number;
padTop: number;
padBottom: number;
}
export interface PinLayoutOptions {
maxArtWidth?: number;
maxArtHeight?: number;
/** Minimum label spacing along an edge, px (left/right stacks). */
minSpacing?: number;
/** A pin farther than this fraction of the dimension from EVERY edge is interior. */
interiorFrac?: number;
}
/** Horizontal gutter for left/right label columns (room for "GND2" etc.). */
const SIDE_GUTTER = 72;
/**
* Vertical gutter for top/bottom labels. Those are drawn as VERTICAL text —
* the way printed pinout diagrams do it — because horizontal ones do not fit:
* an SSD1306 has 8 pins along a 240px edge, and at the ~36px a horizontal
* "DATA" needs they demand 288px and spill out of the dialog. Vertical text
* needs only the same spacing as a side column, and the gutter grows instead.
*/
const CAP_GUTTER = 52;
feat(ui): the Part Inspector - right-click dialog merged with the picker card The right-click dialog was a 250px column: every pin as a text row (an ESP32 means 30 rows of scrolling), a few editors, Rotate/Delete. The picker's info card (datasheet, brand, Buy) was a separate surface you could only reach by hovering the catalogue. One dialog now serves both: - ~560px, two panes: live preview + actions on the left, Properties/Datasheet tabs on the right. The Datasheet tab IS the picker card's body - ComponentInfoPanel was split into an embeddable ComponentInfoBody (+ useComponentDoc, and its .cip-* styles moved to their own stylesheet) so both surfaces render one implementation. - "Show pins" (off by default): the preview is the REAL web component, mounted and scaled, with pin markers at their true pinInfo positions and labels flanking the side each pin lives on. pinInspectorLayout is pure geometry - nearest-edge classification, per-side label stacking with a minimum spacing and leader lines when nudged, interior pins as dot+tooltip - driven only by pinInfo plus the measured element size, so every part and board, present or future, works with no per-part data. Dots are tinted by the pin's declared signal (i2c/spi/power/...). - Clicking a pin keeps the old contract (onPinSelect -> handlePinClick), so wires start with the same undo, colors and running-state gating. - The two open gestures finally agree on coordinates: right-click always passed viewport coords while touch passed canvas-local ones; the dialog is now portaled to body in viewport space and the touch site converts. - Keybind capture, the SD Card panel, the two-step delete, Escape and click-outside all migrate unchanged. Mobile keeps the full-sheet layout. Geometry covered by pin-inspector-layout.test.ts with coordinate tables copied verbatim from the shipped parts (ESP32 two-column board, LED, an interior-pin case).
2026-08-04 02:44:46 +07:00
/** Small parts (a resistor is 107x11) are upscaled, but only this far. */
const MAX_UPSCALE = 2;
export function signalKindOf(pin: InspectorPinInput): PinSignalKind {
const s = pin.signals?.[0];
if (!s || typeof s !== 'object') return 'other';
switch (s.type) {
case 'i2c':
return 'i2c';
case 'spi':
return 'spi';
case 'usart':
return 'usart';
case 'pwm':
return 'pwm';
case 'analog':
return 'analog';
case 'power':
return s.signal === 'GND' ? 'power-gnd' : 'power-vcc';
default:
return 'other';
}
}
/**
* Stack labels along one edge: process in position order and push each label
* forward so no two sit closer than `spacing`. If the stack overruns the art,
* shift it back as one block (clamped at 0) — this keeps a 15-pin column
* centred on its side instead of trailing off the bottom.
*/
function stackLabels(
positions: number[],
spacing: number,
extent: number,
): { out: number[]; nudged: boolean[] } {
const order = positions.map((p, i) => [p, i] as const).sort((a, b) => a[0] - b[0]);
const placed: number[] = [];
for (let k = 0; k < order.length; k++) {
const want = order[k][0];
placed.push(k === 0 ? want : Math.max(want, placed[k - 1] + spacing));
}
// Shift the whole stack up if it overran the art extent and there is room.
const overrun = placed.length ? placed[placed.length - 1] - extent : 0;
if (overrun > 0) {
const room = placed[0]; // how far the first label can move toward 0
const shift = Math.min(overrun, Math.max(0, room));
for (let k = 0; k < placed.length; k++) placed[k] -= shift;
}
const out = new Array<number>(positions.length);
const nudged = new Array<boolean>(positions.length);
for (let k = 0; k < order.length; k++) {
const i = order[k][1];
out[i] = placed[k];
nudged[i] = Math.abs(placed[k] - positions[i]) > 1;
}
return { out, nudged };
}
export function layoutInspectorPins(
pins: InspectorPinInput[],
natural: { width: number; height: number },
opts: PinLayoutOptions = {},
): PinLayoutResult {
const maxW = opts.maxArtWidth ?? 260;
const maxH = opts.maxArtHeight ?? 260;
const minSpacing = opts.minSpacing ?? 16;
const interiorFrac = opts.interiorFrac ?? 0.18;
const w = Math.max(1, natural.width);
const h = Math.max(1, natural.height);
const scale = Math.min(maxW / w, maxH / h, MAX_UPSCALE);
const W = w * scale;
const H = h * scale;
2026-08-04 04:04:37 +07:00
// Classify each pin.
//
// Nearest-edge alone is wrong for real parts, and got caught by the
// ReSpeaker Lite: its XIAO socket is two INSET columns (x=50.5 and 126.5 on
// a 178-wide body) plus two rows of breakout pads. Every one of those sat
// far enough from all four edges to be called "interior", which printed no
// label — the pins were on screen as dots and looked missing. And a plain
// nearest-edge rule scatters a column's end pins to the top/bottom gutter.
//
// So: find the LINES first. Pins sharing an x form a vertical column, pins
// sharing a y form a horizontal row; a column belongs to the side of the
// body it sits on, however far inset it is, and a leader line joins its
// labels to the dots. Only a pin that belongs to no line falls back to its
// nearest edge. Every pin gets a printed label — that is the contract.
const TOL = 6 * scale; // pads within this are the same column/row
const cluster = (vals: number[]): number[][] => {
const idx = vals.map((v, i) => [v, i] as const).sort((a, b) => a[0] - b[0]);
const groups: number[][] = [];
let cur: number[] = [];
let last = NaN;
for (const [v, i] of idx) {
if (cur.length && Math.abs(v - last) > TOL) {
groups.push(cur);
cur = [];
}
cur.push(i);
last = v;
}
if (cur.length) groups.push(cur);
return groups;
};
const xs = pins.map((p) => p.x * scale);
const ys = pins.map((p) => p.y * scale);
const side = new Array<PinEdge | null>(pins.length).fill(null);
/** Largest gap between consecutive values — a real header has small ones. */
const maxGap = (vals: number[]): number => {
const s = [...vals].sort((a, b) => a - b);
let m = 0;
for (let i = 1; i < s.length; i++) m = Math.max(m, s[i] - s[i - 1]);
return m;
};
2026-08-04 04:04:37 +07:00
// Vertical columns win first: they are the dominant shape on breakout
// boards, shields and every DIP part. A column must be a RUN of pads, which
// is why it needs three of them and small gaps: an Arduino Uno has two
// opposite headers with six x values shared between them, and reading those
// pairs as columns threw most of the board's pins into the side gutters
// with a fan of leader lines across the art.
2026-08-04 04:04:37 +07:00
for (const g of cluster(xs)) {
if (g.length < 3) continue;
const gys = g.map((i) => ys[i]);
if (Math.max(...gys) - Math.min(...gys) < TOL) continue; // a row, not a column
if (maxGap(gys) > 0.4 * H) continue; // opposite ends of the body, not a run
2026-08-04 04:04:37 +07:00
const cx = g.reduce((s, i) => s + xs[i], 0) / g.length;
const e: PinEdge = cx <= W / 2 ? 'left' : 'right';
for (const i of g) side[i] = e;
}
// Then horizontal rows, for whatever is still unassigned (headers).
for (const g of cluster(ys)) {
const free = g.filter((i) => side[i] === null);
if (free.length < 2) continue;
const gxs = free.map((i) => xs[i]);
if (Math.max(...gxs) - Math.min(...gxs) < TOL) continue;
if (maxGap(gxs) > 0.4 * W) continue;
2026-08-04 04:04:37 +07:00
const cy = free.reduce((s, i) => s + ys[i], 0) / free.length;
const e: PinEdge = cy <= H / 2 ? 'top' : 'bottom';
for (const i of free) side[i] = e;
}
const classified = pins.map((pin, i) => {
const x = xs[i];
const y = ys[i];
let edge = side[i];
if (!edge) {
// A lone pad: nearest edge.
const d = [
['left', x],
['right', W - x],
['top', y],
['bottom', H - y],
] as Array<[PinEdge, number]>;
d.sort((a, b) => a[1] - b[1]);
edge = d[0][0];
feat(ui): the Part Inspector - right-click dialog merged with the picker card The right-click dialog was a 250px column: every pin as a text row (an ESP32 means 30 rows of scrolling), a few editors, Rotate/Delete. The picker's info card (datasheet, brand, Buy) was a separate surface you could only reach by hovering the catalogue. One dialog now serves both: - ~560px, two panes: live preview + actions on the left, Properties/Datasheet tabs on the right. The Datasheet tab IS the picker card's body - ComponentInfoPanel was split into an embeddable ComponentInfoBody (+ useComponentDoc, and its .cip-* styles moved to their own stylesheet) so both surfaces render one implementation. - "Show pins" (off by default): the preview is the REAL web component, mounted and scaled, with pin markers at their true pinInfo positions and labels flanking the side each pin lives on. pinInspectorLayout is pure geometry - nearest-edge classification, per-side label stacking with a minimum spacing and leader lines when nudged, interior pins as dot+tooltip - driven only by pinInfo plus the measured element size, so every part and board, present or future, works with no per-part data. Dots are tinted by the pin's declared signal (i2c/spi/power/...). - Clicking a pin keeps the old contract (onPinSelect -> handlePinClick), so wires start with the same undo, colors and running-state gating. - The two open gestures finally agree on coordinates: right-click always passed viewport coords while touch passed canvas-local ones; the dialog is now portaled to body in viewport space and the touch site converts. - Keybind capture, the SD Card panel, the two-step delete, Escape and click-outside all migrate unchanged. Mobile keeps the full-sheet layout. Geometry covered by pin-inspector-layout.test.ts with coordinate tables copied verbatim from the shipped parts (ESP32 two-column board, LED, an interior-pin case).
2026-08-04 02:44:46 +07:00
}
return { pin, x, y, edge };
});
2026-08-04 04:04:37 +07:00
// interiorFrac is retained in the options for callers that want the old
// behaviour; the line-first classifier makes it unnecessary here.
void interiorFrac;
feat(ui): the Part Inspector - right-click dialog merged with the picker card The right-click dialog was a 250px column: every pin as a text row (an ESP32 means 30 rows of scrolling), a few editors, Rotate/Delete. The picker's info card (datasheet, brand, Buy) was a separate surface you could only reach by hovering the catalogue. One dialog now serves both: - ~560px, two panes: live preview + actions on the left, Properties/Datasheet tabs on the right. The Datasheet tab IS the picker card's body - ComponentInfoPanel was split into an embeddable ComponentInfoBody (+ useComponentDoc, and its .cip-* styles moved to their own stylesheet) so both surfaces render one implementation. - "Show pins" (off by default): the preview is the REAL web component, mounted and scaled, with pin markers at their true pinInfo positions and labels flanking the side each pin lives on. pinInspectorLayout is pure geometry - nearest-edge classification, per-side label stacking with a minimum spacing and leader lines when nudged, interior pins as dot+tooltip - driven only by pinInfo plus the measured element size, so every part and board, present or future, works with no per-part data. Dots are tinted by the pin's declared signal (i2c/spi/power/...). - Clicking a pin keeps the old contract (onPinSelect -> handlePinClick), so wires start with the same undo, colors and running-state gating. - The two open gestures finally agree on coordinates: right-click always passed viewport coords while touch passed canvas-local ones; the dialog is now portaled to body in viewport space and the touch site converts. - Keybind capture, the SD Card panel, the two-step delete, Escape and click-outside all migrate unchanged. Mobile keeps the full-sheet layout. Geometry covered by pin-inspector-layout.test.ts with coordinate tables copied verbatim from the shipped parts (ESP32 two-column board, LED, an interior-pin case).
2026-08-04 02:44:46 +07:00
// Lay labels per edge. Left/right stack along y; top/bottom along x
// (with wider spacing — their labels are horizontal text).
const laid: LaidOutPin[] = new Array(pins.length);
const byEdge = (e: PinEdge) =>
classified.map((c, i) => [c, i] as const).filter(([c]) => c.edge === e);
for (const edge of ['left', 'right'] as const) {
const group = byEdge(edge);
const { out, nudged } = stackLabels(
group.map(([c]) => c.y),
minSpacing,
H,
);
group.forEach(([c, i], k) => {
laid[i] = {
name: c.pin.name,
edge,
dotX: c.x,
dotY: c.y,
labelX: edge === 'left' ? -8 : W + 8,
labelY: out[k],
needsLeader: nudged[k],
signalKind: signalKindOf(c.pin),
};
});
}
for (const edge of ['top', 'bottom'] as const) {
const group = byEdge(edge);
const { out, nudged } = stackLabels(
group.map(([c]) => c.x),
minSpacing,
feat(ui): the Part Inspector - right-click dialog merged with the picker card The right-click dialog was a 250px column: every pin as a text row (an ESP32 means 30 rows of scrolling), a few editors, Rotate/Delete. The picker's info card (datasheet, brand, Buy) was a separate surface you could only reach by hovering the catalogue. One dialog now serves both: - ~560px, two panes: live preview + actions on the left, Properties/Datasheet tabs on the right. The Datasheet tab IS the picker card's body - ComponentInfoPanel was split into an embeddable ComponentInfoBody (+ useComponentDoc, and its .cip-* styles moved to their own stylesheet) so both surfaces render one implementation. - "Show pins" (off by default): the preview is the REAL web component, mounted and scaled, with pin markers at their true pinInfo positions and labels flanking the side each pin lives on. pinInspectorLayout is pure geometry - nearest-edge classification, per-side label stacking with a minimum spacing and leader lines when nudged, interior pins as dot+tooltip - driven only by pinInfo plus the measured element size, so every part and board, present or future, works with no per-part data. Dots are tinted by the pin's declared signal (i2c/spi/power/...). - Clicking a pin keeps the old contract (onPinSelect -> handlePinClick), so wires start with the same undo, colors and running-state gating. - The two open gestures finally agree on coordinates: right-click always passed viewport coords while touch passed canvas-local ones; the dialog is now portaled to body in viewport space and the touch site converts. - Keybind capture, the SD Card panel, the two-step delete, Escape and click-outside all migrate unchanged. Mobile keeps the full-sheet layout. Geometry covered by pin-inspector-layout.test.ts with coordinate tables copied verbatim from the shipped parts (ESP32 two-column board, LED, an interior-pin case).
2026-08-04 02:44:46 +07:00
W,
);
group.forEach(([c, i], k) => {
laid[i] = {
name: c.pin.name,
edge,
dotX: c.x,
dotY: c.y,
labelX: out[k],
labelY: edge === 'top' ? -8 : H + 8,
needsLeader: nudged[k],
signalKind: signalKindOf(c.pin),
};
});
}
2026-08-04 04:04:37 +07:00
// A leader is also needed whenever the DOT is inset from its gutter, even
// if the label sits on the dot's own row: without it an inset column's
// labels look unattached to anything.
for (const p of laid) {
if (!p) continue;
if (p.edge === 'left' && p.dotX > 6) p.needsLeader = true;
else if (p.edge === 'right' && p.dotX < W - 6) p.needsLeader = true;
else if (p.edge === 'top' && p.dotY > 6) p.needsLeader = true;
else if (p.edge === 'bottom' && p.dotY < H - 6) p.needsLeader = true;
feat(ui): the Part Inspector - right-click dialog merged with the picker card The right-click dialog was a 250px column: every pin as a text row (an ESP32 means 30 rows of scrolling), a few editors, Rotate/Delete. The picker's info card (datasheet, brand, Buy) was a separate surface you could only reach by hovering the catalogue. One dialog now serves both: - ~560px, two panes: live preview + actions on the left, Properties/Datasheet tabs on the right. The Datasheet tab IS the picker card's body - ComponentInfoPanel was split into an embeddable ComponentInfoBody (+ useComponentDoc, and its .cip-* styles moved to their own stylesheet) so both surfaces render one implementation. - "Show pins" (off by default): the preview is the REAL web component, mounted and scaled, with pin markers at their true pinInfo positions and labels flanking the side each pin lives on. pinInspectorLayout is pure geometry - nearest-edge classification, per-side label stacking with a minimum spacing and leader lines when nudged, interior pins as dot+tooltip - driven only by pinInfo plus the measured element size, so every part and board, present or future, works with no per-part data. Dots are tinted by the pin's declared signal (i2c/spi/power/...). - Clicking a pin keeps the old contract (onPinSelect -> handlePinClick), so wires start with the same undo, colors and running-state gating. - The two open gestures finally agree on coordinates: right-click always passed viewport coords while touch passed canvas-local ones; the dialog is now portaled to body in viewport space and the touch site converts. - Keybind capture, the SD Card panel, the two-step delete, Escape and click-outside all migrate unchanged. Mobile keeps the full-sheet layout. Geometry covered by pin-inspector-layout.test.ts with coordinate tables copied verbatim from the shipped parts (ESP32 two-column board, LED, an interior-pin case).
2026-08-04 02:44:46 +07:00
}
const has = (e: PinEdge) => classified.some((c) => c.edge === e);
let padLeft = has('left') ? SIDE_GUTTER : 0;
let padRight = has('right') ? SIDE_GUTTER : 0;
let padTop = has('top') ? CAP_GUTTER : 0;
let padBottom = has('bottom') ? CAP_GUTTER : 0;
// A stack taller than the art must not be clipped. A ReSpeaker's left side
// carries 13 labels: at the minimum spacing that is 208 px against a 190 px
// body, so the column necessarily runs past both ends and the first and
// last labels were cut off. Grow the gutters to whatever the labels
// actually occupy — the box follows the content, not the other way round.
const HALF_LINE = 7; // half a label's line box, plus a hair of margin
for (const p of laid) {
if (!p) continue;
if (p.edge === 'left' || p.edge === 'right') {
padTop = Math.max(padTop, HALF_LINE - p.labelY);
padBottom = Math.max(padBottom, p.labelY + HALF_LINE - H);
} else {
// Vertical text on the caps: its extent along x is the line box.
padLeft = Math.max(padLeft, HALF_LINE - p.labelX);
padRight = Math.max(padRight, p.labelX + HALF_LINE - W);
}
}
feat(ui): the Part Inspector - right-click dialog merged with the picker card The right-click dialog was a 250px column: every pin as a text row (an ESP32 means 30 rows of scrolling), a few editors, Rotate/Delete. The picker's info card (datasheet, brand, Buy) was a separate surface you could only reach by hovering the catalogue. One dialog now serves both: - ~560px, two panes: live preview + actions on the left, Properties/Datasheet tabs on the right. The Datasheet tab IS the picker card's body - ComponentInfoPanel was split into an embeddable ComponentInfoBody (+ useComponentDoc, and its .cip-* styles moved to their own stylesheet) so both surfaces render one implementation. - "Show pins" (off by default): the preview is the REAL web component, mounted and scaled, with pin markers at their true pinInfo positions and labels flanking the side each pin lives on. pinInspectorLayout is pure geometry - nearest-edge classification, per-side label stacking with a minimum spacing and leader lines when nudged, interior pins as dot+tooltip - driven only by pinInfo plus the measured element size, so every part and board, present or future, works with no per-part data. Dots are tinted by the pin's declared signal (i2c/spi/power/...). - Clicking a pin keeps the old contract (onPinSelect -> handlePinClick), so wires start with the same undo, colors and running-state gating. - The two open gestures finally agree on coordinates: right-click always passed viewport coords while touch passed canvas-local ones; the dialog is now portaled to body in viewport space and the touch site converts. - Keybind capture, the SD Card panel, the two-step delete, Escape and click-outside all migrate unchanged. Mobile keeps the full-sheet layout. Geometry covered by pin-inspector-layout.test.ts with coordinate tables copied verbatim from the shipped parts (ESP32 two-column board, LED, an interior-pin case).
2026-08-04 02:44:46 +07:00
return {
scale,
artWidth: W,
artHeight: H,
pins: laid,
padLeft: Math.max(0, Math.round(padLeft)),
padRight: Math.max(0, Math.round(padRight)),
padTop: Math.max(0, Math.round(padTop)),
padBottom: Math.max(0, Math.round(padBottom)),
feat(ui): the Part Inspector - right-click dialog merged with the picker card The right-click dialog was a 250px column: every pin as a text row (an ESP32 means 30 rows of scrolling), a few editors, Rotate/Delete. The picker's info card (datasheet, brand, Buy) was a separate surface you could only reach by hovering the catalogue. One dialog now serves both: - ~560px, two panes: live preview + actions on the left, Properties/Datasheet tabs on the right. The Datasheet tab IS the picker card's body - ComponentInfoPanel was split into an embeddable ComponentInfoBody (+ useComponentDoc, and its .cip-* styles moved to their own stylesheet) so both surfaces render one implementation. - "Show pins" (off by default): the preview is the REAL web component, mounted and scaled, with pin markers at their true pinInfo positions and labels flanking the side each pin lives on. pinInspectorLayout is pure geometry - nearest-edge classification, per-side label stacking with a minimum spacing and leader lines when nudged, interior pins as dot+tooltip - driven only by pinInfo plus the measured element size, so every part and board, present or future, works with no per-part data. Dots are tinted by the pin's declared signal (i2c/spi/power/...). - Clicking a pin keeps the old contract (onPinSelect -> handlePinClick), so wires start with the same undo, colors and running-state gating. - The two open gestures finally agree on coordinates: right-click always passed viewport coords while touch passed canvas-local ones; the dialog is now portaled to body in viewport space and the touch site converts. - Keybind capture, the SD Card panel, the two-step delete, Escape and click-outside all migrate unchanged. Mobile keeps the full-sheet layout. Geometry covered by pin-inspector-layout.test.ts with coordinate tables copied verbatim from the shipped parts (ESP32 two-column board, LED, an interior-pin case).
2026-08-04 02:44:46 +07:00
};
}