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.
872 lines
30 KiB
872 lines
30 KiB
// =============================================================================
|
|
// MonoDisplay Library — binary format parser + canvas renderer
|
|
//
|
|
// Binary format (little-endian throughout):
|
|
// [FILE HEADER]
|
|
// 4 bytes magic "MONO" (0x4D 0x4F 0x4E 0x4F)
|
|
// 1 byte version format version (currently 1)
|
|
// 1 byte content_type 0=static 1=animation 2=text 3=scrolltext
|
|
// 2 bytes width display width in pixels
|
|
// 2 bytes height display height in pixels
|
|
//
|
|
// [CONTENT — varies by content_type]
|
|
// static:
|
|
// (width*height) bytes raw 1bpp pixel rows, MSB-first within each byte
|
|
//
|
|
// animation:
|
|
// 2 bytes frame_count
|
|
// per frame:
|
|
// 4 bytes duration_ms
|
|
// (width*height) bytes pixel rows same as static
|
|
//
|
|
// text:
|
|
// 2 bytes text_len
|
|
// text_len bytes UTF-8 string (no null terminator)
|
|
// 1 byte font_id reserved, ignored for now
|
|
// 1 byte align 0=left 1=center 2=right
|
|
//
|
|
// scrolltext:
|
|
// 2 bytes text_len
|
|
// text_len bytes UTF-8 string
|
|
// 2 bytes speed_pps pixels per second (scroll rate)
|
|
// 1 byte direction 0=left 1=right
|
|
//
|
|
// PLAN: once the real spec arrives, swap out header offsets / field widths here.
|
|
// All parsing is centralised in MonoDisplayParser.parse() so callers never
|
|
// touch raw offsets.
|
|
// =============================================================================
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public types — all exported so browser users can import them
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Supported content types embedded in a .bin display file */
|
|
export type DisplayContentType = "static" | "animation" | "text" | "scrolltext";
|
|
|
|
/** A single monochrome bitmap. Pixels are row-major, 1 byte per pixel (0/1). */
|
|
export interface StaticFrame {
|
|
type: "static";
|
|
width: number;
|
|
height: number;
|
|
/** Unpacked: 1 byte per pixel, 0 = off, 1 = on */
|
|
pixels: Uint8Array;
|
|
}
|
|
|
|
/** One frame inside an animation */
|
|
export interface AnimFrame {
|
|
/** How long this frame is displayed */
|
|
durationMs: number;
|
|
pixels: Uint8Array;
|
|
}
|
|
|
|
export interface Animation {
|
|
type: "animation";
|
|
width: number;
|
|
height: number;
|
|
frames: AnimFrame[];
|
|
}
|
|
|
|
export interface TextDisplay {
|
|
type: "text";
|
|
text: string;
|
|
/** 0=left 1=center 2=right — used by renderer */
|
|
align: 0 | 1 | 2;
|
|
/** Reserved font index; renderer maps this to an embedded bitmap font */
|
|
fontId: number;
|
|
}
|
|
|
|
export interface ScrollText {
|
|
type: "scrolltext";
|
|
text: string;
|
|
/** Scroll speed in pixels per second */
|
|
speedPps: number;
|
|
/** 0=left 1=right */
|
|
direction: 0 | 1;
|
|
}
|
|
|
|
export type DisplayContent = StaticFrame | Animation | TextDisplay | ScrollText;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MonoDisplayFile — JSON-descriptor → binary builder
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Element types that can appear in a display descriptor.
|
|
* PLAN: extend with Text, ScrollText, Animation once section spec is complete.
|
|
*/
|
|
export enum ElementType {
|
|
/** A 1bpp rectangular bitmap placed at an (xoffset, yoffset) position */
|
|
Image2D = "Image2D",
|
|
// PLAN: Text = "Text",
|
|
// PLAN: ScrollText = "ScrollText",
|
|
// PLAN: Animation = "Animation",
|
|
}
|
|
|
|
/** A positioned 1bpp image placed on the display canvas */
|
|
export interface Image2DElement {
|
|
type: ElementType.Image2D;
|
|
/** Raw pixel data — 1 byte per pixel (0=off 1=on), row-major */
|
|
pixels: Uint8Array;
|
|
width: number;
|
|
height: number;
|
|
/** Horizontal offset from display left edge. Default 0. */
|
|
xoffset?: number;
|
|
/** Vertical offset from display top edge. Default 0. */
|
|
yoffset?: number;
|
|
}
|
|
|
|
// Union for future element types
|
|
export type DisplayElement = Image2DElement;
|
|
// PLAN: | TextElement | ScrollTextElement | AnimationElement
|
|
|
|
/**
|
|
* JSON descriptor passed to MonoDisplayFile.
|
|
*
|
|
* elements_always — rendered on every frame regardless of state.
|
|
* PLAN: elements_conditional — rendered only when a named condition is active
|
|
* (e.g. for state-machine-driven displays, ticker overrides, alerts).
|
|
*
|
|
* width / height — display canvas size in pixels.
|
|
* If omitted, inferred as the bounding box of all elements.
|
|
* Explicit values are required if elements don't fill the display.
|
|
*/
|
|
export interface MonoDisplayFileDescriptor {
|
|
width?: number;
|
|
height?: number;
|
|
elements_always?: DisplayElement[];
|
|
}
|
|
|
|
/**
|
|
* Builds a binary .bin file from a JSON descriptor.
|
|
*
|
|
* Usage:
|
|
* ```ts
|
|
* const file = new MonoDisplayFile({
|
|
* width: 160, height: 80,
|
|
* elements_always: [
|
|
* { type: ElementType.Image2D, pixels: myUint8Array, width: 120, height: 80, xoffset: 20 }
|
|
* ]
|
|
* });
|
|
* driver.load(() => Promise.resolve(file.toBuffer()));
|
|
* ```
|
|
*
|
|
* PLAN: toBuffer() currently composites all elements_always into one static frame.
|
|
* When animated elements are added, it will produce an animation section instead.
|
|
*/
|
|
export class MonoDisplayFile {
|
|
private desc: MonoDisplayFileDescriptor;
|
|
|
|
constructor(descriptor: MonoDisplayFileDescriptor) {
|
|
this.desc = descriptor;
|
|
}
|
|
|
|
/**
|
|
* Serialize to a valid .bin ArrayBuffer that MonoDisplayParser can read.
|
|
* Composites all elements_always onto a single static frame.
|
|
*/
|
|
toBuffer(): ArrayBuffer {
|
|
const { width, height } = this.#resolveSize();
|
|
const canvas = this.#composite(width, height);
|
|
|
|
// Encode as static section (0x01): width(u16) height(u16) pixels[w*h]
|
|
const sectionDataSize = 2 + 2 + width * height;
|
|
const totalSize = 12 + 4 + sectionDataSize; // fileHeader + sectionHeader + data
|
|
|
|
const buf = new ArrayBuffer(totalSize);
|
|
const view = new DataView(buf);
|
|
let off = 0;
|
|
|
|
// File header
|
|
view.setUint32(off, MAGIC, true); off += 4; // magic
|
|
view.setUint32(off, 1, true); off += 4; // version = 1
|
|
view.setUint16(off, 1, true); off += 2; // numSections = 1
|
|
view.setUint16(off, 0, true); off += 2; // reserved = 0
|
|
|
|
// Section header: type(1) + size uint24(3)
|
|
view.setUint8(off, 0x01); off += 1; // static section
|
|
view.setUint8(off, sectionDataSize & 0xFF);
|
|
view.setUint8(off + 1, (sectionDataSize >> 8) & 0xFF);
|
|
view.setUint8(off + 2, (sectionDataSize >> 16) & 0xFF);
|
|
off += 3;
|
|
|
|
// Section data
|
|
view.setUint16(off, width, true); off += 2;
|
|
view.setUint16(off, height, true); off += 2;
|
|
new Uint8Array(buf, off).set(canvas);
|
|
|
|
return buf;
|
|
}
|
|
|
|
/** Determine display dimensions: explicit > inferred from element bounding box > defaults */
|
|
#resolveSize(): { width: number; height: number } {
|
|
const elements = this.desc.elements_always ?? [];
|
|
|
|
let width = this.desc.width;
|
|
let height = this.desc.height;
|
|
|
|
if (width === undefined || height === undefined) {
|
|
let maxX = 0, maxY = 0;
|
|
for (const el of elements) {
|
|
maxX = Math.max(maxX, (el.xoffset ?? 0) + el.width);
|
|
maxY = Math.max(maxY, (el.yoffset ?? 0) + el.height);
|
|
}
|
|
width ??= maxX || 160;
|
|
height ??= maxY || 80;
|
|
}
|
|
|
|
return { width, height };
|
|
}
|
|
|
|
/** Composite all elements_always onto a blank canvas (0=off, 1=on) */
|
|
#composite(width: number, height: number): Uint8Array {
|
|
const canvas = new Uint8Array(width * height); // starts all-off
|
|
|
|
for (const el of this.desc.elements_always ?? []) {
|
|
this.#blitElement(el, canvas, width, height);
|
|
}
|
|
|
|
return canvas;
|
|
}
|
|
|
|
/** Blit one element onto the canvas, clipping to display bounds */
|
|
#blitElement(el: DisplayElement, canvas: Uint8Array, canvasW: number, canvasH: number): void {
|
|
if (el.type !== ElementType.Image2D) {
|
|
// PLAN: dispatch other element types here
|
|
throw new Error(`MonoDisplayFile: element type "${el.type}" not yet implemented`);
|
|
}
|
|
|
|
const ox = el.xoffset ?? 0;
|
|
const oy = el.yoffset ?? 0;
|
|
|
|
for (let row = 0; row < el.height; row++) {
|
|
const destY = oy + row;
|
|
if (destY < 0 || destY >= canvasH) continue; // clip top/bottom
|
|
|
|
for (let col = 0; col < el.width; col++) {
|
|
const destX = ox + col;
|
|
if (destX < 0 || destX >= canvasW) continue; // clip left/right
|
|
|
|
const srcIdx = row * el.width + col;
|
|
const destIdx = destY * canvasW + destX;
|
|
// OR-blend: any element can turn a pixel on; none can turn it off
|
|
// PLAN: add blend mode (OR / XOR / replace) to Image2DElement once spec covers it
|
|
if (el.pixels[srcIdx]) canvas[destIdx] = 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Optional constructor arguments for MonoDisplayDriver */
|
|
export interface MonoDisplayDriverOptions {
|
|
/** CSS colour for "on" pixels; default "#ffffff" */
|
|
onColor?: string;
|
|
/** CSS colour for "off" pixels / background; default "#000000" */
|
|
offColor?: string;
|
|
/**
|
|
* Scale factor: each logical pixel becomes NxN canvas pixels.
|
|
* Default 1. When CSS stretches the canvas for display, keep this at 1
|
|
* and let the browser handle upscaling (use image-rendering: pixelated).
|
|
*/
|
|
scale?: number;
|
|
/**
|
|
* Logical display width in pixels. Used as canvas resolution baseline for
|
|
* content types that have no inherent size (text, scrolltext).
|
|
* Also used when static/animation content overflows this width — clipped.
|
|
* Default 160.
|
|
*/
|
|
displayWidth?: number;
|
|
/**
|
|
* Logical display height in pixels. Default 80.
|
|
*/
|
|
displayHeight?: number;
|
|
/**
|
|
* If true, driver loops animations forever. Default true.
|
|
* Set false to stop after one pass.
|
|
*/
|
|
loop?: boolean;
|
|
/**
|
|
* Target render rate in frames per second for continuous content
|
|
* (scrolltext, animation). Default 25. The browser rAF / setTimeout loop
|
|
* skips drawing if the frame would land earlier than 1000/fps ms after the
|
|
* previous draw, keeping CPU/GPU load close to the target rate.
|
|
* Note: animation frame *duration* from the file takes precedence when it
|
|
* is longer than the render interval (slow animations stay slow).
|
|
*/
|
|
fps?: number;
|
|
/**
|
|
* Callback fired whenever the driver encounters a parse or render error.
|
|
* If omitted, errors are thrown.
|
|
*/
|
|
onError?: (err: Error) => void;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BinaryReader — little-endian cursor over an ArrayBuffer
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Stateful cursor that reads little-endian primitives from an ArrayBuffer.
|
|
*
|
|
* PLAN: if the spec ever needs signed types (int16, int32) or floats, add
|
|
* readInt16 / readFloat32 etc. here — all other code stays unchanged.
|
|
*/
|
|
export class BinaryReader {
|
|
private view: DataView;
|
|
private pos: number = 0;
|
|
|
|
constructor(buffer: ArrayBuffer) {
|
|
this.view = new DataView(buffer);
|
|
}
|
|
|
|
get offset(): number {
|
|
return this.pos;
|
|
}
|
|
|
|
get byteLength(): number {
|
|
return this.view.byteLength;
|
|
}
|
|
|
|
get remaining(): number {
|
|
return this.view.byteLength - this.pos;
|
|
}
|
|
|
|
/** Read one unsigned 8-bit integer */
|
|
readByte(): number {
|
|
this.#assertRemaining(1);
|
|
return this.view.getUint8(this.pos++);
|
|
}
|
|
|
|
/** Read unsigned 16-bit integer, little-endian */
|
|
readUint16(): number {
|
|
this.#assertRemaining(2);
|
|
const v = this.view.getUint16(this.pos, true);
|
|
this.pos += 2;
|
|
return v;
|
|
}
|
|
|
|
/** Alias kept for API clarity ("short" in spec language) */
|
|
readShort(): number {
|
|
return this.readUint16();
|
|
}
|
|
|
|
/** Read unsigned 32-bit integer, little-endian */
|
|
readUint32(): number {
|
|
this.#assertRemaining(4);
|
|
const v = this.view.getUint32(this.pos, true);
|
|
this.pos += 4;
|
|
return v;
|
|
}
|
|
|
|
/**
|
|
* Read unsigned 64-bit integer, little-endian.
|
|
* Returns bigint because JS number cannot represent all uint64 values.
|
|
*/
|
|
readUint64(): bigint {
|
|
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;
|
|
}
|
|
|
|
/** Read N raw bytes as a Uint8Array (zero-copy view) */
|
|
readBytes(n: number): Uint8Array {
|
|
this.#assertRemaining(n);
|
|
const slice = new Uint8Array(this.view.buffer, this.pos, n);
|
|
this.pos += n;
|
|
return slice;
|
|
}
|
|
|
|
/** Read N bytes and decode as UTF-8 */
|
|
readUtf8(n: number): string {
|
|
return new TextDecoder().decode(this.readBytes(n));
|
|
}
|
|
|
|
/** Read unsigned 24-bit integer, little-endian (3-byte field in section headers) */
|
|
readUint24(): number {
|
|
this.#assertRemaining(3);
|
|
const lo = this.view.getUint8(this.pos);
|
|
const mid = this.view.getUint8(this.pos + 1);
|
|
const hi = this.view.getUint8(this.pos + 2);
|
|
this.pos += 3;
|
|
return lo | (mid << 8) | (hi << 16);
|
|
}
|
|
|
|
/** Move cursor to absolute byte position */
|
|
seek(pos: number): void {
|
|
if (pos < 0 || pos > this.view.byteLength) {
|
|
throw new RangeError(`seek(${pos}) out of bounds [0, ${this.view.byteLength}]`);
|
|
}
|
|
this.pos = pos;
|
|
}
|
|
|
|
#assertRemaining(n: number): void {
|
|
if (this.remaining < n) {
|
|
throw new RangeError(
|
|
`BinaryReader: need ${n} byte(s) at offset ${this.pos}, only ${this.remaining} remain`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// File format constants (real spec)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// File magic: 0xAF 0x7E 0x2B 0x63 (read as uint32 LE = 0x632B7EAF)
|
|
const MAGIC = 0x632B7EAF;
|
|
|
|
/**
|
|
* File header layout (12 bytes total):
|
|
*
|
|
* offset size field
|
|
* ────── ──── ─────────────────────────────────────────────
|
|
* 0 4 Magic (0xAF 0x7E 0x2B 0x63)
|
|
* 4 4 Version (uint32 LE; 0=illegal, 1=current, 2+=reserved)
|
|
* PLAN: spec shows full 4-byte row — confirm if uint8+padding or uint32
|
|
* 8 2 Number of sections (uint16 LE)
|
|
* 10 2 Reserved (must be 0)
|
|
*
|
|
* Section header layout (4 bytes):
|
|
*
|
|
* offset size field
|
|
* ────── ──── ─────────────────────────────────────────────
|
|
* 0 1 Section type (uint8)
|
|
* 1 3 Section data size in bytes (uint24 LE), excludes header
|
|
*
|
|
* Section types (placeholder values — PLAN: confirm with full spec):
|
|
* 0x01 static frame width(u16) height(u16) pixels[w*h]
|
|
* 0x02 animation width(u16) height(u16) frameCount(u16) frames…
|
|
* 0x03 text textLen(u16) utf8… fontId(u8) align(u8)
|
|
* 0x04 scrolltext textLen(u16) utf8… speedPps(u16) direction(u8)
|
|
*/
|
|
|
|
const SectionType: Record<number, DisplayContentType> = {
|
|
0x01: "static",
|
|
0x02: "animation",
|
|
0x03: "text",
|
|
0x04: "scrolltext",
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MonoDisplayParser
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Parses a .bin ArrayBuffer into a strongly-typed DisplayContent value.
|
|
*
|
|
* Returns the first recognised section's content. Files with numSections=0
|
|
* throw. Multiple sections: only the first supported type is returned for now.
|
|
* PLAN: expose all sections as DisplayContent[] once the full spec is clear.
|
|
*/
|
|
export class MonoDisplayParser {
|
|
parse(buffer: ArrayBuffer): DisplayContent {
|
|
const r = new BinaryReader(buffer);
|
|
|
|
// --- file header (12 bytes) ---
|
|
const magic = r.readUint32();
|
|
if (magic !== MAGIC) {
|
|
throw new Error(
|
|
`Invalid magic 0x${magic.toString(16).toUpperCase()} — expected 0x632B7EAF`
|
|
);
|
|
}
|
|
|
|
const version = r.readUint32(); // PLAN: confirm uint32 vs uint8+3pad in spec
|
|
if (version === 0 || version > 1) {
|
|
throw new Error(`Unsupported format version ${version}`);
|
|
}
|
|
|
|
const numSections = r.readUint16();
|
|
r.readUint16(); // reserved — ignored
|
|
|
|
if (numSections === 0) {
|
|
throw new Error("File contains 0 sections — nothing to render");
|
|
}
|
|
|
|
// --- section loop ---
|
|
for (let i = 0; i < numSections; i++) {
|
|
// section header (4 bytes)
|
|
const sectionType = r.readByte();
|
|
const sectionSize = r.readUint24();
|
|
const sectionStart = r.offset;
|
|
|
|
const contentType = SectionType[sectionType];
|
|
if (!contentType) {
|
|
// Unknown section — skip it and continue to next
|
|
r.seek(sectionStart + sectionSize);
|
|
continue;
|
|
}
|
|
|
|
// Parse this section; width/height read from section data for bitmap types
|
|
const content = this.#parseSection(r, contentType);
|
|
|
|
// Seek past any remaining section bytes (handles padding / future fields)
|
|
r.seek(sectionStart + sectionSize);
|
|
|
|
return content; // return first recognised section
|
|
}
|
|
|
|
throw new Error("No recognised sections found in file");
|
|
}
|
|
|
|
#parseSection(r: BinaryReader, contentType: DisplayContentType): DisplayContent {
|
|
switch (contentType) {
|
|
case "static": {
|
|
const width = r.readUint16();
|
|
const height = r.readUint16();
|
|
return this.#parseStatic(r, width, height);
|
|
}
|
|
case "animation": {
|
|
const width = r.readUint16();
|
|
const height = r.readUint16();
|
|
return this.#parseAnimation(r, width, height);
|
|
}
|
|
case "text": return this.#parseText(r);
|
|
case "scrolltext": return this.#parseScrollText(r);
|
|
}
|
|
}
|
|
|
|
#parseStatic(r: BinaryReader, width: number, height: number): StaticFrame {
|
|
// PLAN: spec may use packed 1bpp; for now each byte = 1 pixel (0 or 1)
|
|
const raw = r.readBytes(width * height);
|
|
return { type: "static", width, height, pixels: new Uint8Array(raw) };
|
|
}
|
|
|
|
#parseAnimation(r: BinaryReader, width: number, height: number): Animation {
|
|
const frameCount = r.readUint16();
|
|
const frames: AnimFrame[] = [];
|
|
|
|
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: BinaryReader): TextDisplay {
|
|
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) as 0 | 1 | 2;
|
|
return { type: "text", text, fontId, align };
|
|
}
|
|
|
|
#parseScrollText(r: BinaryReader): ScrollText {
|
|
const textLen = r.readUint16();
|
|
const text = r.readUtf8(textLen);
|
|
const speedPps = r.readUint16();
|
|
const dirByte = r.readByte();
|
|
const direction = dirByte === 1 ? 1 : 0 as 0 | 1;
|
|
return { type: "scrolltext", text, speedPps, direction };
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MonoDisplayRenderer — draws DisplayContent onto an HTMLCanvasElement
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Internal renderer; MonoDisplayDriver owns one instance.
|
|
*
|
|
* PLAN: text/scrolltext rendering currently draws a placeholder box.
|
|
* Replace #renderText / #renderScrollText with a real embedded bitmap font
|
|
* (5x7 or similar) once the font spec is finalised.
|
|
*/
|
|
export class MonoDisplayRenderer {
|
|
private ctx: CanvasRenderingContext2D;
|
|
private opts: Required<MonoDisplayDriverOptions>;
|
|
|
|
// Animation state
|
|
private animFrame: number = 0;
|
|
private animTimer: number | null = null;
|
|
// ScrollText state
|
|
private scrollX: number = 0;
|
|
private scrollRaf: number | null = null;
|
|
// Tracks last rAF timestamp for motion integration (runs every rAF)
|
|
private scrollLastTs: number | null = null;
|
|
// Tracks last actual draw timestamp for fps throttle
|
|
private scrollLastDrawTs: number | null = null;
|
|
|
|
constructor(canvas: HTMLCanvasElement, opts: Required<MonoDisplayDriverOptions>) {
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) throw new Error("Cannot get 2D canvas context");
|
|
this.ctx = ctx;
|
|
this.opts = opts;
|
|
}
|
|
|
|
/** Stop any running animation / scroll before loading new content */
|
|
stop(): void {
|
|
if (this.animTimer !== null) {
|
|
clearTimeout(this.animTimer);
|
|
this.animTimer = null;
|
|
}
|
|
if (this.scrollRaf !== null) {
|
|
cancelAnimationFrame(this.scrollRaf);
|
|
this.scrollRaf = null;
|
|
}
|
|
this.scrollLastTs = null;
|
|
this.scrollLastDrawTs = null;
|
|
}
|
|
|
|
render(content: DisplayContent): void {
|
|
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: number, h: number): void {
|
|
const s = this.opts.scale;
|
|
this.ctx.canvas.width = w * s;
|
|
this.ctx.canvas.height = h * s;
|
|
}
|
|
|
|
#renderPixels(pixels: Uint8Array, width: number, height: number): void {
|
|
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: StaticFrame): void {
|
|
this.#resizeCanvas(content.width, content.height);
|
|
this.#renderPixels(content.pixels, content.width, content.height);
|
|
}
|
|
|
|
#startAnimation(content: Animation): void {
|
|
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;
|
|
}
|
|
// Clamp to fps budget: never draw faster than 1000/fps ms per frame
|
|
const minInterval = 1000 / this.opts.fps;
|
|
this.animTimer = setTimeout(tick, Math.max(frame.durationMs, minInterval)) as unknown as number;
|
|
};
|
|
tick();
|
|
}
|
|
|
|
#renderText(content: TextDisplay): void {
|
|
// PLAN: replace with bitmap font renderer; align/fontId will be honoured then
|
|
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;
|
|
// Scale font to roughly fill display height; real bitmap font will replace this
|
|
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: ScrollText): void {
|
|
// PLAN: replace inner draw with bitmap font; direction/speed wired in
|
|
const ctx = this.ctx;
|
|
const { displayWidth: dw, displayHeight: dh, scale: s } = this.opts;
|
|
ctx.canvas.width = dw * s;
|
|
ctx.canvas.height = dh * s;
|
|
// Start position: off-screen on the entry side
|
|
this.scrollX = content.direction === 1 ? -(content.text.length * 8 * s) : ctx.canvas.width;
|
|
|
|
const tick = (ts: number) => {
|
|
// --- motion integration runs every rAF for smooth position accumulation ---
|
|
const dt = this.scrollLastTs !== null ? (ts - this.scrollLastTs) / 1000 : 0;
|
|
this.scrollLastTs = ts;
|
|
|
|
const { displayWidth: dw, displayHeight: dh, scale: s, offColor, onColor, fps } = this.opts;
|
|
const frameInterval = 1000 / fps; // e.g. 40ms at 25fps
|
|
|
|
// speedPps is in logical pixels/s; scale to canvas pixels
|
|
const delta = content.speedPps * s * dt * (content.direction === 1 ? 1 : -1);
|
|
this.scrollX += delta;
|
|
|
|
// codepoint count (not UTF-16 units) for width estimate
|
|
const cpCount = [...content.text].length;
|
|
const textWidth = cpCount * 8 * s; // rough 8px/cp until bitmap font lands
|
|
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;
|
|
}
|
|
|
|
// --- draw only when fps budget allows (~25Hz) ---
|
|
const sinceLastDraw = this.scrollLastDrawTs !== null ? ts - this.scrollLastDrawTs : Infinity;
|
|
if (sinceLastDraw >= frameInterval) {
|
|
this.scrollLastDrawTs = ts;
|
|
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";
|
|
ctx.fillText(content.text, this.scrollX, ctx.canvas.height / 2);
|
|
}
|
|
|
|
this.scrollRaf = requestAnimationFrame(tick);
|
|
};
|
|
|
|
this.scrollRaf = requestAnimationFrame(tick);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MonoDisplayDriver — public API surface
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Top-level driver. Attach to a canvas element by ID, then call load() with
|
|
* a function that returns a Promise<ArrayBuffer> of the .bin file.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* const driver = new MonoDisplayDriver("canvas_root");
|
|
* driver.load(() => loadBinFile("default_test.bin"));
|
|
* ```
|
|
*
|
|
* PLAN: add driver.stop() / driver.play() / driver.setContent(DisplayContent)
|
|
* to allow external playback control without reloading.
|
|
*/
|
|
export class MonoDisplayDriver {
|
|
private canvas: HTMLCanvasElement;
|
|
private opts: Required<MonoDisplayDriverOptions>;
|
|
private parser: MonoDisplayParser;
|
|
private renderer: MonoDisplayRenderer | null = null;
|
|
|
|
constructor(canvasId: string, options: MonoDisplayDriverOptions = {}) {
|
|
const el = document.getElementById(canvasId);
|
|
if (!el || el.tagName !== "CANVAS") {
|
|
throw new Error(`MonoDisplayDriver: element #${canvasId} is not a <canvas>`);
|
|
}
|
|
this.canvas = el as HTMLCanvasElement;
|
|
|
|
// Apply defaults
|
|
this.opts = {
|
|
onColor: options.onColor ?? "#ffffff",
|
|
offColor: options.offColor ?? "#000000",
|
|
scale: options.scale ?? 1,
|
|
displayWidth: options.displayWidth ?? 160,
|
|
displayHeight: options.displayHeight ?? 80,
|
|
fps: options.fps ?? 25,
|
|
loop: options.loop ?? true,
|
|
onError: options.onError ?? ((e: Error) => { throw e; }),
|
|
};
|
|
|
|
this.parser = new MonoDisplayParser();
|
|
}
|
|
|
|
/**
|
|
* Load binary data from the provided async factory, parse, and render.
|
|
* Calling load() again stops any current playback and starts fresh.
|
|
*
|
|
* @param loader Async function returning the raw .bin ArrayBuffer
|
|
*/
|
|
async load(loader: () => Promise<ArrayBuffer>): Promise<void> {
|
|
try {
|
|
const buffer = await loader();
|
|
const content = this.parser.parse(buffer);
|
|
|
|
// Lazy-create renderer on first load, reuse after
|
|
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 current animation / scroll without unloading */
|
|
stop(): void {
|
|
this.renderer?.stop();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers exposed for browser convenience
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Fetch a .bin file by URL and return its ArrayBuffer.
|
|
* Intended as the `loader` argument to driver.load().
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* driver.load(() => loadBinFile("/assets/default_test.bin"));
|
|
* ```
|
|
*/
|
|
export async function loadBinFile(url: string): Promise<ArrayBuffer> {
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error(`loadBinFile: HTTP ${res.status} for ${url}`);
|
|
return res.arrayBuffer();
|
|
}
|
|
|
|
/**
|
|
* Build a valid .bin ArrayBuffer for testing and demos.
|
|
* Encodes one section using the real file format.
|
|
*
|
|
* PLAN: expand into a full BinaryWriter for all section types once spec is complete.
|
|
* Currently only "static" section is encoded; others throw.
|
|
*/
|
|
export function buildBinBuffer(content: DisplayContent): ArrayBuffer {
|
|
if (content.type !== "static") {
|
|
throw new Error(`buildBinBuffer: encoding "${content.type}" not yet implemented`);
|
|
}
|
|
|
|
// Section data: width(u16) + height(u16) + pixels[w*h]
|
|
const sectionDataSize = 2 + 2 + content.width * content.height;
|
|
|
|
// File header: magic(4) + version(4) + numSections(2) + reserved(2) = 12
|
|
// Section header: type(1) + size(3) = 4
|
|
const totalSize = 12 + 4 + sectionDataSize;
|
|
|
|
const buf = new ArrayBuffer(totalSize);
|
|
const view = new DataView(buf);
|
|
let off = 0;
|
|
|
|
// File header
|
|
view.setUint32(off, MAGIC, true); off += 4; // magic
|
|
view.setUint32(off, 1, true); off += 4; // version = 1
|
|
view.setUint16(off, 1, true); off += 2; // numSections = 1
|
|
view.setUint16(off, 0, true); off += 2; // reserved = 0
|
|
|
|
// Section header
|
|
view.setUint8(off, 0x01); off += 1; // section type = static
|
|
// uint24 LE for section size
|
|
view.setUint8(off, sectionDataSize & 0xFF);
|
|
view.setUint8(off + 1, (sectionDataSize >> 8) & 0xFF);
|
|
view.setUint8(off + 2, (sectionDataSize >> 16) & 0xFF);
|
|
off += 3;
|
|
|
|
// Section data
|
|
view.setUint16(off, content.width, true); off += 2;
|
|
view.setUint16(off, content.height, true); off += 2;
|
|
new Uint8Array(buf, off).set(content.pixels);
|
|
|
|
return buf;
|
|
}
|
|
|