From 4d9bb7d26a2b6bca57f0b1196b27cac92514821a Mon Sep 17 00:00:00 2001 From: flop Date: Wed, 13 May 2026 13:40:50 +0200 Subject: [PATCH] feat: update for browser run --- ts/index.html | 222 +++++++++++++++++++++++ ts/package.json | 6 +- ts/public/bundle.js | 428 ++++++++++++++++++++++++++++++++++++++++++++ ts/src/browser.ts | 9 + ts/src/library.ts | 43 ++++- 5 files changed, 698 insertions(+), 10 deletions(-) create mode 100644 ts/index.html create mode 100644 ts/public/bundle.js create mode 100644 ts/src/browser.ts diff --git a/ts/index.html b/ts/index.html new file mode 100644 index 0000000..73bd9a8 --- /dev/null +++ b/ts/index.html @@ -0,0 +1,222 @@ + + + + + + MonoDisplay Test + + + + +

MonoDisplay — 160 × 80

+ +
+ + +
160 × 80 @ 25fps
+
+ +
+
select a demo
+ + + + + + + + diff --git a/ts/package.json b/ts/package.json index 05a6735..1b8399c 100644 --- a/ts/package.json +++ b/ts/package.json @@ -1,7 +1,11 @@ { "name": "libmonoformat", - "module": "index.ts", + "module": "src/index.ts", "type": "module", + "scripts": { + "build": "bun build src/browser.ts --target browser --outfile public/mono-display.js", + "test": "bun test" + }, "devDependencies": { "@types/bun": "latest" }, diff --git a/ts/public/bundle.js b/ts/public/bundle.js new file mode 100644 index 0000000..1c9cfa8 --- /dev/null +++ b/ts/public/bundle.js @@ -0,0 +1,428 @@ +// 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(); diff --git a/ts/src/browser.ts b/ts/src/browser.ts new file mode 100644 index 0000000..51ee591 --- /dev/null +++ b/ts/src/browser.ts @@ -0,0 +1,9 @@ +// browser.ts — IIFE-style browser entry point +// Built by: bun build src/browser.ts --target browser --outfile public/mono-display.js +// Attaches everything to window.MonoDisplay so inline