parent
27deb4cbf8
commit
4d9bb7d26a
@ -0,0 +1,222 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8" /> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
||||||
|
<title>MonoDisplay Test</title> |
||||||
|
<style> |
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } |
||||||
|
|
||||||
|
body { |
||||||
|
background: #111; |
||||||
|
color: #ccc; |
||||||
|
font-family: monospace; |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
min-height: 100vh; |
||||||
|
gap: 20px; |
||||||
|
padding: 20px; |
||||||
|
} |
||||||
|
|
||||||
|
h1 { |
||||||
|
font-size: 14px; |
||||||
|
letter-spacing: 0.15em; |
||||||
|
color: #555; |
||||||
|
text-transform: uppercase; |
||||||
|
} |
||||||
|
|
||||||
|
/* |
||||||
|
* Display container: holds the canvas at a fixed 2:1 aspect ratio (160:80). |
||||||
|
* Canvas internal resolution stays at 160×80 logical pixels. |
||||||
|
* CSS stretches it to fill this box — image-rendering: pixelated keeps crisp edges. |
||||||
|
* To resize the on-screen display: change max-width here. Driver is unaffected. |
||||||
|
*/ |
||||||
|
#display-box { |
||||||
|
width: 100%; |
||||||
|
max-width: 800px; |
||||||
|
aspect-ratio: 2 / 1; |
||||||
|
background: #000; |
||||||
|
border: 2px solid #333; |
||||||
|
border-radius: 4px; |
||||||
|
overflow: hidden; |
||||||
|
position: relative; |
||||||
|
} |
||||||
|
|
||||||
|
#canvas_root { |
||||||
|
width: 100%; |
||||||
|
height: 100%; |
||||||
|
display: block; |
||||||
|
image-rendering: pixelated; |
||||||
|
image-rendering: crisp-edges; /* Firefox */ |
||||||
|
} |
||||||
|
|
||||||
|
#resolution { |
||||||
|
position: absolute; |
||||||
|
bottom: 6px; |
||||||
|
right: 8px; |
||||||
|
font-size: 10px; |
||||||
|
color: #333; |
||||||
|
pointer-events: none; |
||||||
|
} |
||||||
|
|
||||||
|
#controls { |
||||||
|
display: flex; |
||||||
|
flex-wrap: wrap; |
||||||
|
gap: 8px; |
||||||
|
justify-content: center; |
||||||
|
} |
||||||
|
|
||||||
|
#controls button { |
||||||
|
background: #1a1a1a; |
||||||
|
color: #888; |
||||||
|
border: 1px solid #333; |
||||||
|
border-radius: 3px; |
||||||
|
padding: 6px 14px; |
||||||
|
font-family: monospace; |
||||||
|
font-size: 12px; |
||||||
|
cursor: pointer; |
||||||
|
transition: background 0.1s, color 0.1s; |
||||||
|
} |
||||||
|
|
||||||
|
#controls button:hover { background: #222; color: #bbb; } |
||||||
|
#controls button.active { background: #1e3a1e; color: #33ff66; border-color: #33ff66; } |
||||||
|
|
||||||
|
#status { font-size: 11px; color: #444; min-height: 1em; } |
||||||
|
</style> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
|
||||||
|
<h1>MonoDisplay — 160 × 80</h1> |
||||||
|
|
||||||
|
<div id="display-box"> |
||||||
|
<!-- Internal res = displayWidth × displayHeight (160×80). CSS does the upscaling. --> |
||||||
|
<canvas id="canvas_root" width="160" height="80"></canvas> |
||||||
|
<div id="resolution">160 × 80 @ 25fps</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div id="controls"></div> |
||||||
|
<div id="status">select a demo</div> |
||||||
|
|
||||||
|
<!-- |
||||||
|
Build the library first: bun run build |
||||||
|
Produces public/mono-display.js — exposes all types on window.MonoDisplay. |
||||||
|
Then serve index.html from any static file server (or file:// directly). |
||||||
|
--> |
||||||
|
<script src="./public/mono-display.js"></script> |
||||||
|
|
||||||
|
<script> |
||||||
|
// ------------------------------------------------------------------------- |
||||||
|
// Demo helpers — build synthetic .bin ArrayBuffers for all 4 content types |
||||||
|
// ------------------------------------------------------------------------- |
||||||
|
|
||||||
|
const MAGIC = new Uint8Array([0x4d, 0x4f, 0x4e, 0x4f]); // "MONO" |
||||||
|
|
||||||
|
function makeHeader(contentType, width, height) { |
||||||
|
const h = new Uint8Array(10); |
||||||
|
h.set(MAGIC, 0); |
||||||
|
h[4] = 1; // version |
||||||
|
h[5] = contentType; |
||||||
|
new DataView(h.buffer).setUint16(6, width, true); |
||||||
|
new DataView(h.buffer).setUint16(8, height, true); |
||||||
|
return h; |
||||||
|
} |
||||||
|
|
||||||
|
function concat(...parts) { |
||||||
|
const total = parts.reduce((s, p) => s + p.byteLength, 0); |
||||||
|
const out = new Uint8Array(total); |
||||||
|
let off = 0; |
||||||
|
for (const p of parts) { out.set(new Uint8Array(p), off); off += p.byteLength; } |
||||||
|
return out.buffer; |
||||||
|
} |
||||||
|
|
||||||
|
function u16le(n) { const b = new Uint8Array(2); new DataView(b.buffer).setUint16(0, n, true); return b; } |
||||||
|
function u32le(n) { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, n, true); return b; } |
||||||
|
|
||||||
|
// 160×80 checkerboard — static |
||||||
|
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 concat(makeHeader(0, W, H), pixels); |
||||||
|
} |
||||||
|
|
||||||
|
// 160×80 two-frame blink — animation |
||||||
|
function makeAnimation() { |
||||||
|
const W = 160, H = 80; |
||||||
|
function frame(fill, durationMs) { |
||||||
|
return concat(u32le(durationMs), new Uint8Array(W * H).fill(fill)); |
||||||
|
} |
||||||
|
return concat(makeHeader(1, W, H), u16le(2), frame(1, 200), frame(0, 200)); |
||||||
|
} |
||||||
|
|
||||||
|
// Text content type |
||||||
|
function makeTextBin(text, align /* 0=left 1=center 2=right */) { |
||||||
|
const enc = new TextEncoder().encode(text); |
||||||
|
return concat( |
||||||
|
makeHeader(2, 0, 0), |
||||||
|
u16le(enc.byteLength), enc, |
||||||
|
new Uint8Array([0, align ?? 1]) // fontId=0, align |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
// Scrolltext content type |
||||||
|
function makeScrollTextBin(text, speedPps, direction /* 0=left 1=right */) { |
||||||
|
const enc = new TextEncoder().encode(text); |
||||||
|
return concat( |
||||||
|
makeHeader(3, 0, 0), |
||||||
|
u16le(enc.byteLength), enc, |
||||||
|
u16le(speedPps ?? 60), |
||||||
|
new Uint8Array([direction ?? 0]) |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
// ------------------------------------------------------------------------- |
||||||
|
// Driver — 160×80, 25fps, green-on-dark |
||||||
|
// ------------------------------------------------------------------------- |
||||||
|
|
||||||
|
const { MonoDisplayDriver } = window.MonoDisplay; |
||||||
|
|
||||||
|
const driver = new MonoDisplayDriver("canvas_root", { |
||||||
|
onColor: "#33ff66", |
||||||
|
offColor: "#0a0a0a", |
||||||
|
fps: 25, |
||||||
|
// displayWidth/Height default to 160×80 |
||||||
|
// scale stays 1 — CSS handles visual upscaling |
||||||
|
}); |
||||||
|
|
||||||
|
// ------------------------------------------------------------------------- |
||||||
|
// Demo catalogue |
||||||
|
// ------------------------------------------------------------------------- |
||||||
|
|
||||||
|
const demos = [ |
||||||
|
{ label: "Checkerboard (static)", make: makeCheckerboard }, |
||||||
|
{ label: "Blink (animation)", make: makeAnimation }, |
||||||
|
{ label: "Text (centered)", make: () => makeTextBin("Hello, World!", 1) }, |
||||||
|
{ label: "Scrolltext (left)", make: () => makeScrollTextBin("MONO DISPLAY — scrolling ticker — 日本語テスト 🚀 ", 80, 0) }, |
||||||
|
]; |
||||||
|
|
||||||
|
const controls = document.getElementById("controls"); |
||||||
|
let activeBtn = null; |
||||||
|
|
||||||
|
for (const demo of demos) { |
||||||
|
const btn = document.createElement("button"); |
||||||
|
btn.textContent = demo.label; |
||||||
|
btn.onclick = () => { |
||||||
|
if (activeBtn) activeBtn.classList.remove("active"); |
||||||
|
btn.classList.add("active"); |
||||||
|
activeBtn = btn; |
||||||
|
driver.load(() => Promise.resolve(demo.make())); |
||||||
|
}; |
||||||
|
controls.appendChild(btn); |
||||||
|
} |
||||||
|
|
||||||
|
// Auto-load first demo |
||||||
|
controls.firstElementChild.click(); |
||||||
|
</script> |
||||||
|
|
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -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 <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(); |
||||||
@ -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 <script> tags can use it
|
||||||
|
// without ES module syntax.
|
||||||
|
|
||||||
|
import * as MonoDisplay from "./index"; |
||||||
|
|
||||||
|
// Expose on globalThis (= window in browser, globalThis elsewhere)
|
||||||
|
(globalThis as typeof globalThis & { MonoDisplay: typeof MonoDisplay }).MonoDisplay = MonoDisplay; |
||||||
Loading…
Reference in new issue