feat(attiny85): add Web Component for ATtiny85 with pinInfo support

This commit is contained in:
David Montero Crespo 2026-04-28 22:57:50 -03:00
parent 0a66c70512
commit 7a26d01965
3 changed files with 256 additions and 145 deletions

View File

@ -307,6 +307,61 @@ declare global {
}
```
### 6a. Boards/components MUST be Web Components, not React SVG ⚠️
The wire system reads pin coordinates via `element.pinInfo` from the rendered
DOM node (`frontend/src/utils/pinPositionCalculator.ts:38`). This **only**
works for real DOM custom elements (Web Components) — a plain React `<svg>`
component has no `pinInfo`, so every wire endpoint silently falls back to
`(0, 0)` of the board and visually attaches to the **corner** instead of the
pin. The user has reported this exact symptom multiple times.
**Rule:** any board or component that needs wire connections must be a Web
Component (`class Foo extends HTMLElement`) with a `pinInfo` getter. The
React `.tsx` file is a thin wrapper.
Reference implementations:
- `frontend/src/components/velxio-components/Esp32Element.ts` (board)
- `frontend/src/components/velxio-components/PiPicoWElement.ts` (board)
- `frontend/src/components/velxio-components/Attiny85Element.ts` (board)
- `frontend/src/components/velxio-components/Bmp280Element.ts` (component)
Required shape:
```ts
class FooElement extends HTMLElement {
constructor() { super(); this.attachShadow({ mode: 'open' }); }
connectedCallback() { this.render(); }
get pinInfo() {
// Pin tip coordinates in CSS pixels relative to element top-left.
// `name` must match what examples reference in wires.
return [
{ name: 'GP0', x: 6, y: 24, description: 'UART0 TX' },
// …
];
}
private render() { /* shadowRoot.innerHTML = ... */ }
}
if (!customElements.get('velxio-foo')) {
customElements.define('velxio-foo', FooElement);
}
```
The `.tsx` wrapper:
```tsx
import './FooElement';
declare global {
namespace JSX { interface IntrinsicElements { 'velxio-foo': any; } }
}
export const Foo = ({ id, x, y }: Props) => (
<velxio-foo id={id} style={{ position: 'absolute', left: x, top: y }} />
);
```
**Verification before claiming a board/component is done:** load an example
that wires to it, confirm wires terminate on the pin tips (not the corner),
and add the pin coords to `BoardOnCanvas.tsx`'s `BOARD_SIZE` table if it's a
board.
### 7. Pre-existing TypeScript Errors
There are known pre-existing TS errors that do NOT block the app from running:

View File

@ -1,162 +1,42 @@
/**
* ATtiny85 visual component DIP-8 package (Digispark-style layout)
* ATtiny85 React wrapper around the `velxio-attiny85` Web Component.
*
* Pin layout (DIP-8):
* Left side (top to bottom): PB5/RST, PB3, PB4, GND
* Right side (bottom to top): PB0, PB1, PB2, VCC
*
* Built-in LED on PB1 (standard Digispark LED pin).
* The wire system reads `pinInfo` directly from the rendered DOM element,
* so the actual SVG + pin coordinates live in `Attiny85Element.ts`. This
* file just renders the custom element and forwards the `led1` prop.
*/
import './Attiny85Element';
import { useEffect, useRef } from 'react';
interface Attiny85Props {
id?: string;
x?: number;
y?: number;
/** State of PB1 (built-in LED pin on Digispark) */
/** State of PB1 (built-in LED on Digispark) */
led1?: boolean;
}
// DIP-8 dimensions
const W = 160; // total SVG width
const H = 100; // total SVG height
const BX = 30; // chip body left
const BY = 10; // chip body top
const BW = 100; // chip body width
const BH = 80; // chip body height
const PIN_W = 28; // pin stub length (horizontal)
const PIN_SPACING = 20; // vertical spacing between pins
// 4 pins on each side, centred vertically in the body
const PIN_STARTS_Y = [BY + 10, BY + 30, BY + 50, BY + 70];
const PIN_LABELS_LEFT = ['PB5/RST', 'PB3', 'PB4', 'GND'];
const PIN_LABELS_RIGHT = ['VCC', 'PB2', 'PB1', 'PB0'];
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace JSX {
interface IntrinsicElements {
'velxio-attiny85': any;
}
}
}
export const Attiny85 = ({ id = 'attiny85', x = 0, y = 0, led1 = false }: Attiny85Props) => {
const ref = useRef<HTMLElement>(null);
useEffect(() => {
if (ref.current) (ref.current as any).led1 = led1;
}, [led1]);
return (
<svg
<velxio-attiny85
id={id}
style={{ position: 'absolute', left: x, top: y, overflow: 'visible' }}
width={W}
height={H}
viewBox={`0 0 ${W} ${H}`}
>
{/* Pin stubs — left side */}
{PIN_STARTS_Y.map((py, i) => (
<line
key={`lpin-${i}`}
x1={BX}
y1={py}
x2={BX - PIN_W}
y2={py}
stroke="#aaa"
strokeWidth="3"
strokeLinecap="round"
/>
))}
{/* Pin stubs — right side (reversed: bottom=PB0) */}
{PIN_STARTS_Y.map((py, i) => (
<line
key={`rpin-${i}`}
x1={BX + BW}
y1={py}
x2={BX + BW + PIN_W}
y2={py}
stroke="#aaa"
strokeWidth="3"
strokeLinecap="round"
/>
))}
{/* IC body */}
<rect
x={BX}
y={BY}
width={BW}
height={BH}
rx={4}
ry={4}
fill="#1a1a2e"
stroke="#4a4a7a"
strokeWidth="1.5"
/>
{/* Orientation notch (top centre of body) */}
<path
d={`M${BX + BW / 2 - 7} ${BY} A7 7 0 0 1 ${BX + BW / 2 + 7} ${BY}`}
fill="none"
stroke="#4a4a7a"
strokeWidth="1.5"
/>
{/* Pin 1 dot (bottom-left corner of body → PB5/RST) */}
<circle cx={BX + 8} cy={BY + BH - 8} r={2.5} fill="#7a7aaa" />
{/* Chip label */}
<text
x={BX + BW / 2}
y={BY + BH / 2 - 8}
fontSize="10"
fontWeight="bold"
fontFamily="monospace"
fill="#c8c8f0"
textAnchor="middle"
>
ATtiny85
</text>
<text
x={BX + BW / 2}
y={BY + BH / 2 + 8}
fontSize="8"
fontFamily="monospace"
fill="#7a7aaa"
textAnchor="middle"
>
8-bit AVR
</text>
{/* Built-in LED (PB1) — right side, 3rd pin from top = index 2 reversed */}
{/* Right pins are bottom-to-top: index 3=PB0, 2=PB1, 1=PB2, 0=VCC */}
<circle
cx={BX + BW + PIN_W + 6}
cy={PIN_STARTS_Y[2]}
r={5}
fill={led1 ? '#ffee44' : '#333'}
stroke={led1 ? '#ffcc00' : '#555'}
strokeWidth="1"
style={{ filter: led1 ? 'drop-shadow(0 0 4px #ffcc00)' : 'none' }}
/>
{/* Pin labels — left */}
{PIN_LABELS_LEFT.map((label, i) => (
<text
key={`ll-${i}`}
x={BX - PIN_W - 2}
y={PIN_STARTS_Y[i] + 4}
fontSize="7"
fontFamily="monospace"
fill={label === 'GND' ? '#888' : '#aac'}
textAnchor="end"
>
{label}
</text>
))}
{/* Pin labels — right */}
{PIN_LABELS_RIGHT.map((label, i) => (
<text
key={`rl-${i}`}
x={BX + BW + PIN_W + 14}
y={PIN_STARTS_Y[i] + 4}
fontSize="7"
fontFamily="monospace"
fill={label === 'VCC' ? '#888' : label === 'PB1' ? '#ffdd88' : '#aac'}
textAnchor="start"
>
{label}
</text>
))}
</svg>
ref={ref}
style={{ position: 'absolute', left: `${x}px`, top: `${y}px` }}
/>
);
};

View File

@ -0,0 +1,176 @@
/**
* ATtiny85 Web Component (DIP-8 package, Digispark-style)
*
* Exposes the standard `pinInfo` array used by the wire system to position
* wire endpoints on real pins instead of the component corner.
*
* Pin layout (DIP-8):
* Left side (top to bottom): PB5 (RST), PB3, PB4, GND
* Right side (top to bottom): VCC, PB2, PB1, PB0
*
* The visual layout matches the older React component
* (`Attiny85.tsx` `Attiny85`), but here we own the DOM so wires can read
* `element.pinInfo`. The `led1` attribute mirrors the PB1 (Digispark LED)
* pin state.
*/
// DIP-8 dimensions (must match BoardOnCanvas BOARD_SIZE = 160 × 100)
const W = 160;
const H = 100;
const BX = 30; // chip body left
const BY = 10; // chip body top
const BW = 100; // chip body width
const BH = 80; // chip body height
const PIN_W = 28; // pin stub length
// Vertical pin centres
const PIN_STARTS_Y = [BY + 10, BY + 30, BY + 50, BY + 70];
const PIN_LABELS_LEFT = ['PB5', 'PB3', 'PB4', 'GND'];
const PIN_LABELS_RIGHT = ['VCC', 'PB2', 'PB1', 'PB0'];
/**
* Pin tip coordinates (where wires must attach), in SVG pixels relative to
* the element's top-left corner. The wire system reads these via the
* `pinInfo` property on the rendered DOM element.
*/
const PIN_INFO: ReadonlyArray<{ name: string; x: number; y: number; description?: string }> = [
// Left column — pin tip = BX - PIN_W
{ name: 'PB5', x: BX - PIN_W, y: PIN_STARTS_Y[0], description: 'PB5 / RST' },
{ name: 'PB3', x: BX - PIN_W, y: PIN_STARTS_Y[1] },
{ name: 'PB4', x: BX - PIN_W, y: PIN_STARTS_Y[2] },
{ name: 'GND', x: BX - PIN_W, y: PIN_STARTS_Y[3] },
// Right column — pin tip = BX + BW + PIN_W
{ name: 'VCC', x: BX + BW + PIN_W, y: PIN_STARTS_Y[0] },
{ name: 'PB2', x: BX + BW + PIN_W, y: PIN_STARTS_Y[1] },
{ name: 'PB1', x: BX + BW + PIN_W, y: PIN_STARTS_Y[2], description: 'PB1 / built-in LED' },
{ name: 'PB0', x: BX + BW + PIN_W, y: PIN_STARTS_Y[3] },
];
class Attiny85Element extends HTMLElement {
static get observedAttributes(): string[] {
return ['led1'];
}
private _led1 = false;
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
}
attributeChangedCallback(name: string, _old: string | null, value: string | null) {
if (name === 'led1') {
this._led1 = value !== null && value !== 'false';
this.updateLed();
}
}
/** Wires use this to find pin coordinates. */
get pinInfo() {
return PIN_INFO;
}
/** Property setter so React's `useEffect` path can drive the LED. */
set led1(v: boolean) {
this._led1 = !!v;
this.updateLed();
}
get led1(): boolean {
return this._led1;
}
private updateLed() {
const led = this.shadowRoot?.getElementById('attiny-led1') as unknown as SVGElement | null;
if (!led) return;
if (this._led1) {
led.setAttribute('fill', '#ffee44');
led.setAttribute('stroke', '#ffcc00');
led.style.filter = 'drop-shadow(0 0 4px #ffcc00)';
} else {
led.setAttribute('fill', '#333');
led.setAttribute('stroke', '#555');
led.style.filter = 'none';
}
}
private render() {
if (!this.shadowRoot) return;
// Build pin stubs + labels
const leftStubs = PIN_STARTS_Y.map(
(py) => `<line x1="${BX}" y1="${py}" x2="${BX - PIN_W}" y2="${py}"
stroke="#aaa" stroke-width="3" stroke-linecap="round" />`,
).join('');
const rightStubs = PIN_STARTS_Y.map(
(py) => `<line x1="${BX + BW}" y1="${py}" x2="${BX + BW + PIN_W}" y2="${py}"
stroke="#aaa" stroke-width="3" stroke-linecap="round" />`,
).join('');
const leftLabels = PIN_LABELS_LEFT.map(
(label, i) =>
`<text x="${BX - PIN_W - 2}" y="${PIN_STARTS_Y[i] + 4}" font-size="7"
font-family="monospace" text-anchor="end"
fill="${label === 'GND' ? '#888' : '#aac'}">${label === 'PB5' ? 'PB5/RST' : label}</text>`,
).join('');
const rightLabels = PIN_LABELS_RIGHT.map(
(label, i) =>
`<text x="${BX + BW + PIN_W + 14}" y="${PIN_STARTS_Y[i] + 4}" font-size="7"
font-family="monospace" text-anchor="start"
fill="${label === 'VCC' ? '#888' : label === 'PB1' ? '#ffdd88' : '#aac'}">${label}</text>`,
).join('');
// Built-in LED on PB1 (right side, 3rd from top → PIN_STARTS_Y[2])
const ledFill = this._led1 ? '#ffee44' : '#333';
const ledStroke = this._led1 ? '#ffcc00' : '#555';
const ledFilter = this._led1 ? 'drop-shadow(0 0 4px #ffcc00)' : 'none';
this.shadowRoot.innerHTML = `
<style>
:host { display: inline-block; line-height: 0; }
svg { display: block; overflow: visible; }
</style>
<svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
<!-- Pin stubs -->
${leftStubs}
${rightStubs}
<!-- IC body -->
<rect x="${BX}" y="${BY}" width="${BW}" height="${BH}" rx="4" ry="4"
fill="#1a1a2e" stroke="#4a4a7a" stroke-width="1.5" />
<!-- Orientation notch -->
<path d="M${BX + BW / 2 - 7} ${BY} A7 7 0 0 1 ${BX + BW / 2 + 7} ${BY}"
fill="none" stroke="#4a4a7a" stroke-width="1.5" />
<!-- Pin 1 dot (bottom-left of body PB5/RST corner) -->
<circle cx="${BX + 8}" cy="${BY + BH - 8}" r="2.5" fill="#7a7aaa" />
<!-- Chip label -->
<text x="${BX + BW / 2}" y="${BY + BH / 2 - 8}" font-size="10" font-weight="bold"
font-family="monospace" fill="#c8c8f0" text-anchor="middle">ATtiny85</text>
<text x="${BX + BW / 2}" y="${BY + BH / 2 + 8}" font-size="8"
font-family="monospace" fill="#7a7aaa" text-anchor="middle">8-bit AVR</text>
<!-- Built-in LED on PB1 -->
<circle id="attiny-led1"
cx="${BX + BW + PIN_W + 6}" cy="${PIN_STARTS_Y[2]}" r="5"
fill="${ledFill}" stroke="${ledStroke}" stroke-width="1"
style="filter: ${ledFilter};" />
<!-- Labels -->
${leftLabels}
${rightLabels}
</svg>
`;
}
}
if (!customElements.get('velxio-attiny85')) {
customElements.define('velxio-attiny85', Attiny85Element);
}
export {};