// src/library.ts class BinaryReader { view; pos = 0; constructor(buffer) { this.view = new DataView(buffer); } get offset() { return this.pos; } get byteLength() { return this.view.byteLength; } get remaining() { return this.view.byteLength - this.pos; } readByte() { this.#assertRemaining(1); return this.view.getUint8(this.pos++); } readUint16() { this.#assertRemaining(2); const v = this.view.getUint16(this.pos, true); this.pos += 2; return v; } readShort() { return this.readUint16(); } readUint32() { this.#assertRemaining(4); const v = this.view.getUint32(this.pos, true); this.pos += 4; return v; } readUint64() { this.#assertRemaining(8); const lo = BigInt(this.view.getUint32(this.pos, true)); const hi = BigInt(this.view.getUint32(this.pos + 4, true)); this.pos += 8; return hi << 32n | lo; } readBytes(n) { this.#assertRemaining(n); const slice = new Uint8Array(this.view.buffer, this.pos, n); this.pos += n; return slice; } readUtf8(n) { return new TextDecoder().decode(this.readBytes(n)); } seek(pos) { if (pos < 0 || pos > this.view.byteLength) { throw new RangeError(`seek(${pos}) out of bounds [0, ${this.view.byteLength}]`); } this.pos = pos; } #assertRemaining(n) { if (this.remaining < n) { throw new RangeError(`BinaryReader: need ${n} byte(s) at offset ${this.pos}, only ${this.remaining} remain`); } } } var MAGIC = 1330532173; var ContentTypeByte = { 0: "static", 1: "animation", 2: "text", 3: "scrolltext" }; class MonoDisplayParser { parse(buffer) { const r = new BinaryReader(buffer); const magic = r.readUint32(); if (magic !== MAGIC) { throw new Error(`Invalid magic 0x${magic.toString(16).toUpperCase()} — expected 0x4F4E4F4D ("MONO")`); } const version = r.readByte(); if (version !== 1) { throw new Error(`Unsupported format version ${version}`); } const contentTypeByte = r.readByte(); const contentType = ContentTypeByte[contentTypeByte]; if (!contentType) { throw new Error(`Unknown content type byte 0x${contentTypeByte.toString(16)}`); } const width = r.readUint16(); const height = r.readUint16(); switch (contentType) { case "static": return this.#parseStatic(r, width, height); case "animation": return this.#parseAnimation(r, width, height); case "text": return this.#parseText(r); case "scrolltext": return this.#parseScrollText(r); } } #parseStatic(r, width, height) { const raw = r.readBytes(width * height); return { type: "static", width, height, pixels: new Uint8Array(raw) }; } #parseAnimation(r, width, height) { const frameCount = r.readUint16(); const frames = []; for (let i = 0;i < frameCount; i++) { const durationMs = r.readUint32(); const raw = r.readBytes(width * height); frames.push({ durationMs, pixels: new Uint8Array(raw) }); } return { type: "animation", width, height, frames }; } #parseText(r) { const textLen = r.readUint16(); const text = r.readUtf8(textLen); const fontId = r.readByte(); const alignByte = r.readByte(); const align = alignByte === 1 ? 1 : alignByte === 2 ? 2 : 0; return { type: "text", text, fontId, align }; } #parseScrollText(r) { const textLen = r.readUint16(); const text = r.readUtf8(textLen); const speedPps = r.readUint16(); const dirByte = r.readByte(); const direction = dirByte === 1 ? 1 : 0; return { type: "scrolltext", text, speedPps, direction }; } } class MonoDisplayRenderer { ctx; opts; animFrame = 0; animTimer = null; scrollX = 0; scrollRaf = null; scrollLastTs = null; constructor(canvas, opts) { const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Cannot get 2D canvas context"); this.ctx = ctx; this.opts = opts; } stop() { if (this.animTimer !== null) { clearTimeout(this.animTimer); this.animTimer = null; } if (this.scrollRaf !== null) { cancelAnimationFrame(this.scrollRaf); this.scrollRaf = null; } } render(content) { this.stop(); switch (content.type) { case "static": this.#renderStatic(content); break; case "animation": this.#startAnimation(content); break; case "text": this.#renderText(content); break; case "scrolltext": this.#startScrollText(content); break; } } #resizeCanvas(w, h) { const s = this.opts.scale; this.ctx.canvas.width = w * s; this.ctx.canvas.height = h * s; } #renderPixels(pixels, width, height) { const { onColor, offColor, scale: s } = this.opts; const ctx = this.ctx; ctx.fillStyle = offColor; ctx.fillRect(0, 0, width * s, height * s); ctx.fillStyle = onColor; for (let y = 0;y < height; y++) { for (let x = 0;x < width; x++) { if (pixels[y * width + x]) { ctx.fillRect(x * s, y * s, s, s); } } } } #renderStatic(content) { this.#resizeCanvas(content.width, content.height); this.#renderPixels(content.pixels, content.width, content.height); } #startAnimation(content) { this.#resizeCanvas(content.width, content.height); this.animFrame = 0; const tick = () => { if (content.frames.length === 0) return; const frame = content.frames[this.animFrame]; this.#renderPixels(frame.pixels, content.width, content.height); this.animFrame++; if (this.animFrame >= content.frames.length) { if (!this.opts.loop) return; this.animFrame = 0; } this.animTimer = setTimeout(tick, frame.durationMs); }; tick(); } #renderText(content) { const ctx = this.ctx; const { displayWidth: dw, displayHeight: dh, scale: s, offColor, onColor } = this.opts; ctx.canvas.width = dw * s; ctx.canvas.height = dh * s; ctx.fillStyle = offColor; ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); ctx.fillStyle = onColor; const fontSize = Math.max(8, Math.floor(dh * s * 0.6)); ctx.font = `${fontSize}px monospace`; ctx.textBaseline = "middle"; const padding = 4 * s; ctx.fillText(content.text, padding, ctx.canvas.height / 2); } #startScrollText(content) { const ctx = this.ctx; const { displayWidth: dw, displayHeight: dh, scale: s } = this.opts; ctx.canvas.width = dw * s; ctx.canvas.height = dh * s; this.scrollX = content.direction === 1 ? -(content.text.length * 8 * s) : ctx.canvas.width; const tick = (ts) => { const dt = this.scrollLastTs !== null ? (ts - this.scrollLastTs) / 1000 : 0; this.scrollLastTs = ts; const { displayWidth: dw2, displayHeight: dh2, scale: s2, offColor, onColor } = this.opts; const delta = content.speedPps * s2 * dt * (content.direction === 1 ? 1 : -1); this.scrollX += delta; const cpCount = [...content.text].length; const textWidth = cpCount * 8 * s2; if (content.direction === 0 && this.scrollX < -textWidth) { this.scrollX = ctx.canvas.width; } else if (content.direction === 1 && this.scrollX > ctx.canvas.width) { this.scrollX = -textWidth; } ctx.fillStyle = offColor; ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); ctx.fillStyle = onColor; const fontSize = Math.max(8, Math.floor(dh2 * s2 * 0.6)); ctx.font = `${fontSize}px monospace`; ctx.textBaseline = "middle"; ctx.fillText(content.text, this.scrollX, ctx.canvas.height / 2); this.scrollRaf = requestAnimationFrame(tick); }; this.scrollRaf = requestAnimationFrame(tick); } } class MonoDisplayDriver { canvas; opts; parser; renderer = null; constructor(canvasId, options = {}) { const el = document.getElementById(canvasId); if (!el || el.tagName !== "CANVAS") { throw new Error(`MonoDisplayDriver: element #${canvasId} is not a `); } this.canvas = el; this.opts = { onColor: options.onColor ?? "#ffffff", offColor: options.offColor ?? "#000000", scale: options.scale ?? 1, displayWidth: options.displayWidth ?? 160, displayHeight: options.displayHeight ?? 80, loop: options.loop ?? true, onError: options.onError ?? ((e) => { throw e; }) }; this.parser = new MonoDisplayParser; } async load(loader) { try { const buffer = await loader(); const content = this.parser.parse(buffer); if (!this.renderer) { this.renderer = new MonoDisplayRenderer(this.canvas, this.opts); } this.renderer.render(content); } catch (err) { this.opts.onError(err instanceof Error ? err : new Error(String(err))); } } stop() { this.renderer?.stop(); } } function buildBinBuffer(content) { if (content.type !== "static") { throw new Error(`buildBinBuffer: encoding "${content.type}" not yet implemented`); } const headerSize = 4 + 1 + 1 + 2 + 2; const pixelSize = content.width * content.height; const buf = new ArrayBuffer(headerSize + pixelSize); const view = new DataView(buf); view.setUint32(0, MAGIC, true); view.setUint8(4, 1); view.setUint8(5, 0); view.setUint16(6, content.width, true); view.setUint16(8, content.height, true); new Uint8Array(buf, headerSize).set(content.pixels); return buf; } // demo.ts function makeCheckerboard() { const W = 160, H = 80; const pixels = new Uint8Array(W * H); for (let y = 0;y < H; y++) for (let x = 0;x < W; x++) pixels[y * W + x] = (x + y) % 2; return buildBinBuffer({ type: "static", width: W, height: H, pixels }); } function makeAnimation() { const W = 160, H = 80; const on = new Uint8Array(W * H).fill(1); const off = new Uint8Array(W * H).fill(0); const MAGIC2 = new Uint8Array([77, 79, 78, 79]); const header = new Uint8Array(10); header.set(MAGIC2, 0); header[4] = 1; header[5] = 1; new DataView(header.buffer).setUint16(6, W, true); new DataView(header.buffer).setUint16(8, H, true); const frameCount = new Uint8Array(2); new DataView(frameCount.buffer).setUint16(0, 2, true); function frame(pixels, durationMs) { const dur = new Uint8Array(4); new DataView(dur.buffer).setUint32(0, durationMs, true); const f = new Uint8Array(4 + pixels.byteLength); f.set(dur, 0); f.set(pixels, 4); return f; } const parts = [header, frameCount, frame(on, 500), frame(off, 500)]; const total = parts.reduce((s, p) => s + p.byteLength, 0); const buf = new Uint8Array(total); let off2 = 0; for (const p of parts) { buf.set(p, off2); off2 += p.byteLength; } return buf.buffer; } function makeTextBin(text, align = 1) { const enc = new TextEncoder().encode(text); const MAGIC2 = new Uint8Array([77, 79, 78, 79]); const header = new Uint8Array(10); header.set(MAGIC2, 0); header[4] = 1; header[5] = 2; const textLen = new Uint8Array(2); new DataView(textLen.buffer).setUint16(0, enc.byteLength, true); const tail = new Uint8Array([0, align]); const total = 10 + 2 + enc.byteLength + 2; const out = new Uint8Array(total); let o = 0; out.set(header, o); o += 10; out.set(textLen, o); o += 2; out.set(enc, o); o += enc.byteLength; out.set(tail, o); return out.buffer; } function makeScrollTextBin(text, speedPps = 60, direction = 0) { const enc = new TextEncoder().encode(text); const MAGIC2 = new Uint8Array([77, 79, 78, 79]); const header = new Uint8Array(10); header.set(MAGIC2, 0); header[4] = 1; header[5] = 3; const textLen = new Uint8Array(2); new DataView(textLen.buffer).setUint16(0, enc.byteLength, true); const speed = new Uint8Array(2); new DataView(speed.buffer).setUint16(0, speedPps, true); const total = 10 + 2 + enc.byteLength + 2 + 1; const out = new Uint8Array(total); let o = 0; out.set(header, o); o += 10; out.set(textLen, o); o += 2; out.set(enc, o); o += enc.byteLength; out.set(speed, o); o += 2; out[o] = direction; return out.buffer; } var driver = new MonoDisplayDriver("canvas_root", { onColor: "#33ff66", offColor: "#0a0a0a" }); var demos = { "Checkerboard (static)": makeCheckerboard, "Blink (animation)": makeAnimation, "Text (centered)": () => makeTextBin("Hello, World! \uD83D\uDFE2", 1), "Scrolltext (left)": () => makeScrollTextBin("MONO DISPLAY — scrolling ticker test — 日本語テスト \uD83D\uDE80 ", 80, 0) }; var controls = document.getElementById("controls"); var active = null; for (const [label, factory] of Object.entries(demos)) { const btn = document.createElement("button"); btn.textContent = label; btn.onclick = () => { active?.classList.remove("active"); btn.classList.add("active"); active = btn; driver.load(() => Promise.resolve(factory())); }; controls.appendChild(btn); } controls.firstElementChild?.click();