You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
428 lines
13 KiB
428 lines
13 KiB
// 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 <canvas>`);
|
|
}
|
|
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();
|
|
|