feat(attiny85): add Web Component for ATtiny85 with pinInfo support
This commit is contained in:
parent
0a66c70512
commit
7a26d01965
55
CLAUDE.md
55
CLAUDE.md
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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` }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
Loading…
Reference in New Issue