velxio/frontend/src/components/velxio-components/Ssd1306I2cElement.ts

161 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 };