161 lines
5.4 KiB
TypeScript
161 lines
5.4 KiB
TypeScript
/**
|
||
* Ssd1306I2cElement — `<velxio-ssd1306-i2c-4pin>` Web Component.
|
||
*
|
||
* The common cheap 0.96" SSD1306 OLED **I2C module with only 4 pins**
|
||
* (GND / VCC / SCL / SDA) — the counterpart to the 8-pin `wokwi-ssd1306`
|
||
* breakout, and the layout most beginners actually have on their desk
|
||
* (matches Wokwi's `board-ssd1306`). A velxio-local element because
|
||
* `@wokwi/elements` only ships the 8-pin variant. See issue #215.
|
||
*
|
||
* Same rendering surface as `wokwi-ssd1306` so `SSD1306Core.syncElement`
|
||
* (simulation/parts/ProtocolParts.ts) drives it unchanged:
|
||
* - `element.imageData` — a 128×64 ImageData (RGBA)
|
||
* - `element.redraw()` — flushes imageData to the internal canvas
|
||
*
|
||
* Per CLAUDE.md §6a this MUST be a real Web Component — the wire system reads
|
||
* `pinInfo` from the DOM node to place wire endpoints on the pin tips.
|
||
*/
|
||
|
||
const SCREEN_W = 128;
|
||
const SCREEN_H = 64;
|
||
|
||
// Body geometry (CSS px). Kept visually in the same family as wokwi-ssd1306
|
||
// (blue PCB, dark screen, corner mounting holes, star) but narrower — a 4-pin
|
||
// header instead of 8. Screen sits at (SCREEN_X, SCREEN_Y).
|
||
const BODY_W = 150;
|
||
const BODY_H = 108;
|
||
const SCREEN_X = 11;
|
||
const SCREEN_Y = 30;
|
||
|
||
// 4-pin 0.1" header, centred along the top edge. `x`/`y` are the pin tips the
|
||
// wire system snaps to.
|
||
const PIN_Y = 11;
|
||
const PIN_SPACING = 15;
|
||
const PIN_NAMES = ['GND', 'VCC', 'SCL', 'SDA'] as const;
|
||
const PIN_X0 = BODY_W / 2 - ((PIN_NAMES.length - 1) * PIN_SPACING) / 2;
|
||
// Ring colours echo the wokwi-ssd1306 pin styling.
|
||
const PIN_RING = ['#B4AEAB', '#C08540', '#009E9B', '#007ADB'];
|
||
|
||
function signalsFor(name: string) {
|
||
if (name === 'GND') return [{ type: 'power', signal: 'GND' }];
|
||
if (name === 'VCC') return [{ type: 'power', signal: 'VCC' }];
|
||
if (name === 'SCL') return [{ type: 'i2c', signal: 'SCL' }];
|
||
if (name === 'SDA') return [{ type: 'i2c', signal: 'SDA' }];
|
||
return [];
|
||
}
|
||
|
||
class Ssd1306I2cElement extends HTMLElement {
|
||
private _imageData: ImageData = new ImageData(SCREEN_W, SCREEN_H);
|
||
private ctx: CanvasRenderingContext2D | null = null;
|
||
|
||
constructor() {
|
||
super();
|
||
this.attachShadow({ mode: 'open' });
|
||
}
|
||
|
||
connectedCallback(): void {
|
||
this.render();
|
||
this.initContext();
|
||
}
|
||
|
||
/** Wire system reads this from the DOM — do NOT memoize. */
|
||
get pinInfo() {
|
||
return PIN_NAMES.map((name, i) => ({
|
||
name,
|
||
x: PIN_X0 + i * PIN_SPACING,
|
||
y: PIN_Y,
|
||
number: i + 1,
|
||
signals: signalsFor(name),
|
||
}));
|
||
}
|
||
|
||
get canvas(): HTMLCanvasElement | null {
|
||
return this.shadowRoot?.querySelector('canvas') ?? null;
|
||
}
|
||
|
||
/** Accepts the ImageData pushed by SSD1306Core; ignores anything else (e.g. a
|
||
* stray string property assignment from the loader). */
|
||
set imageData(v: ImageData) {
|
||
if (v instanceof ImageData) this._imageData = v;
|
||
}
|
||
get imageData(): ImageData {
|
||
return this._imageData;
|
||
}
|
||
|
||
/** Flush the current imageData to the canvas (called by SSD1306Core). */
|
||
redraw(): void {
|
||
if (!this.ctx) this.initContext();
|
||
try {
|
||
this.ctx?.putImageData(this._imageData, 0, 0);
|
||
} catch {
|
||
/* canvas not ready yet */
|
||
}
|
||
}
|
||
|
||
private initContext(): void {
|
||
const c = this.canvas;
|
||
this.ctx = c?.getContext('2d') ?? null;
|
||
this.ctx?.putImageData(this._imageData, 0, 0);
|
||
}
|
||
|
||
private render(): void {
|
||
if (!this.shadowRoot) return;
|
||
const holes = [
|
||
[8, 8],
|
||
[BODY_W - 8, 8],
|
||
[8, BODY_H - 8],
|
||
[BODY_W - 8, BODY_H - 8],
|
||
];
|
||
this.shadowRoot.innerHTML = `
|
||
<style>
|
||
:host { display: inline-block; line-height: 0; position: relative; }
|
||
svg { display: block; user-select: none; -webkit-user-select: none; }
|
||
canvas {
|
||
position: absolute;
|
||
left: ${SCREEN_X}px;
|
||
top: ${SCREEN_Y}px;
|
||
image-rendering: pixelated;
|
||
pointer-events: none;
|
||
}
|
||
</style>
|
||
<svg width="${BODY_W}" height="${BODY_H}" xmlns="http://www.w3.org/2000/svg">
|
||
<!-- Blue PCB -->
|
||
<rect stroke="#BE9B72" fill="#025CAF" x=".5" y=".5"
|
||
width="${BODY_W - 1}" height="${BODY_H - 1}" rx="10" />
|
||
|
||
<!-- Corner mounting holes -->
|
||
<g fill="#59340A" stroke="#BE9B72" stroke-width="0.6">
|
||
${holes.map(([cx, cy]) => `<circle cx="${cx}" cy="${cy}" r="4.5" />`).join('')}
|
||
</g>
|
||
|
||
<!-- 128 x 64 screen (the <canvas> paints on top of this) -->
|
||
<rect x="${SCREEN_X}" y="${SCREEN_Y}" width="${SCREEN_W}" height="${SCREEN_H}" fill="#1A1A1A" />
|
||
|
||
<!-- Star decoration, echoing the 8-pin part -->
|
||
<path fill="#FFF" stroke="#FFF"
|
||
d="M133 6.5l-1.4 2.6-3 .4 2.2 2-.53 2.83 2.75-1.34 2.75 1.34-.53-2.83 2.2-2-3-.4-1.4-2.6z" />
|
||
|
||
<!-- 4-pin header + labels -->
|
||
<g font-family="monospace" font-size="6" font-weight="300" fill="#FFF" text-anchor="middle">
|
||
${PIN_NAMES.map(
|
||
(name, i) => `<text x="${PIN_X0 + i * PIN_SPACING}" y="${PIN_Y + 12}">${name}</text>`,
|
||
).join('')}
|
||
</g>
|
||
<g fill="#9D9D9A" stroke-width="0.4">
|
||
${PIN_NAMES.map(
|
||
(_, i) =>
|
||
`<circle stroke="${PIN_RING[i]}" cx="${PIN_X0 + i * PIN_SPACING}" cy="${PIN_Y}" r="3.5" />`,
|
||
).join('')}
|
||
</g>
|
||
</svg>
|
||
<canvas width="${SCREEN_W}" height="${SCREEN_H}"></canvas>
|
||
`;
|
||
}
|
||
}
|
||
|
||
if (!customElements.get('velxio-ssd1306-i2c-4pin')) {
|
||
customElements.define('velxio-ssd1306-i2c-4pin', Ssd1306I2cElement);
|
||
}
|
||
|
||
export type { Ssd1306I2cElement };
|