Abfahrtsanzeiger Display Basic Library
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.
 
 
 
libmonoformat/ts/src/library.ts

1000 lines
37 KiB

// =============================================================================
// MonoDisplay Library — binary format parser + canvas renderer
//
// Spec: Mono Display File Format Specification (see adjacent .rst)
//
// File layout (little-endian throughout):
//
// [FILE HEADER — 12 bytes]
// 4 bytes magic 0xAF 0x7E 0x2B 0x63
// 4 bytes version uint32 LE; 0=illegal, 1=current
// 2 bytes numSections uint16 LE
// 2 bytes reserved must be 0
//
// [SECTION — repeated numSections times]
// 1 byte sectionType uint8
// 3 bytes sectionSize uint24 LE, INCLUDES these 4 header bytes
// <data> sectionSize-4 bytes of section-specific data
// <pad> to 32-bit boundary (included in sectionSize)
//
// SECTION TYPES:
// 1 ElementsAlways — flags(u16) numElements(u16) elements…
// 2 ElementsTimespan — flags(u16) numElements(u16) start(u64) end(u64) elements…
// 32 CustomFont — actualSize(u24) reserved(u8) fontData…
//
// ELEMENT TYPES (uint16, all fields uint16 unless noted, 32-bit aligned):
// 1 Image2D — type xOff yOff w h reserved [packed 1bpp pixels]
// 2 Animation — type xOff yOff w h nFrames updateInterval reserved [N×frame]
// 3 HorizontalScroll — type xOff yOff w h contentW flags(u8) speed(u8) reserved [pixels]
// 4 VerticalScroll — type xOff yOff w h contentH flags(u8) speed(u8) reserved [pixels]
// 5 Line — type xO yO xT yT lineStyle(u8) flags(u8)
// 16 ClippedText — type xOff yOff w h fontIdx textLen [utf8 text + pad]
// 17 HScrollText — type xOff yOff w h flags(u8) speed(u8) fontIdx textLen [utf8 + pad]
//
// PIXEL DATA: packed 1bpp, size = ceil(w*h/8).
// px_i = y*w + x; byte = data[px_i>>3]; bit = (byte >> (px_i&7)) & 1
// Frames aligned to octet boundary. Excess bits SHOULD be 0, MUST be ignored.
//
// ALIGNMENT: all sections and elements aligned to 32-bit (4-byte) boundaries.
//
// FONT INDEX: 0..32767 = built-in; 0x8000+ = custom font in file (0x8000=first).
// =============================================================================
export * from "./types.js";
import {
SectionType,
ElementType,
type SectionFlags,
type ScrollElementFlags,
type Image2DElement,
type AnimationElement,
type HScrollElement,
type VScrollElement,
type LineElement,
type ClippedTextElement,
type HScrollTextElement,
type DrawElement,
type ElementsAlwaysDescriptor,
type MonoDisplayFileDescriptor,
type MonoFormatPixelImage,
type MonoFormatImage2D,
type MonoFormatAnimation,
type MonoFormatScrollFlags,
type MonoFormatHScroll,
type MonoFormatVScroll,
type MonoFormatLine,
type MonoFormatClippedText,
type MonoFormatHScrollText,
type MonoFormatElement,
type MonoFormatSectionFlags,
type MonoFormatElementsAlways,
type MonoFormatElementsTimespan,
type MonoFormatCustomFont,
type MonoFormatSection,
type MonoFormatFile,
} from "./types.js";
// ---------------------------------------------------------------------------
// Pixel pack/unpack helpers
// ---------------------------------------------------------------------------
/**
* Pack 1-byte-per-pixel array into 1bpp.
* Excess bits in the last byte are set to 0.
* Size = ceil(width * height / 8).
*/
function packPixels(pixels: Uint8Array, width: number, height: number): Uint8Array {
const total = width * height;
const packed = new Uint8Array((total + 7) >> 3);
for (let i = 0; i < total; i++) {
if (pixels[i]) packed[i >> 3] |= 1 << (i & 7);
}
return packed;
}
/**
* Unpack 1bpp data to 1-byte-per-pixel. Excess bits beyond width*height are ignored.
*/
function unpackPixels(packed: Uint8Array, width: number, height: number): Uint8Array {
const total = width * height;
const pixels = new Uint8Array(total);
for (let i = 0; i < total; i++) {
pixels[i] = (packed[i >> 3] >> (i & 7)) & 1;
}
return pixels;
}
/** Byte count of packed 1bpp image */
function packedSize(width: number, height: number): number {
return (width * height + 7) >> 3;
}
/** Padding needed to align `byteCount` to a 4-byte boundary */
function pad32(byteCount: number): number {
return (4 - (byteCount & 3)) & 3;
}
// ---------------------------------------------------------------------------
// BinaryReader — little-endian cursor over an ArrayBuffer
// ---------------------------------------------------------------------------
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; }
readByte(): number { this.#need(1); return this.view.getUint8(this.pos++); }
readUint16(): number { this.#need(2); const v = this.view.getUint16(this.pos, true); this.pos += 2; return v; }
readShort(): number { return this.readUint16(); }
readInt16(): number { this.#need(2); const v = this.view.getInt16(this.pos, true); this.pos += 2; return v; }
readUint24(): number {
this.#need(3);
const v = this.view.getUint8(this.pos) | (this.view.getUint8(this.pos+1) << 8) | (this.view.getUint8(this.pos+2) << 16);
this.pos += 3;
return v;
}
readUint32(): number { this.#need(4); const v = this.view.getUint32(this.pos, true); this.pos += 4; return v; }
readUint64(): bigint {
this.#need(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: number): Uint8Array {
this.#need(n);
const s = new Uint8Array(this.view.buffer, this.pos, n);
this.pos += n;
return s;
}
readUtf8(n: number): string { return new TextDecoder().decode(this.readBytes(n)); }
seek(pos: number): void {
if (pos < 0 || pos > this.view.byteLength) {
throw new RangeError(`seek(${pos}) out of [0, ${this.view.byteLength}]`);
}
this.pos = pos;
}
#need(n: number): void {
if (this.remaining < n) throw new RangeError(
`BinaryReader: need ${n} byte(s) at offset ${this.pos}, ${this.remaining} remain`
);
}
}
// ---------------------------------------------------------------------------
// File format constants
// ---------------------------------------------------------------------------
// Magic 0xAF 0x7E 0x2B 0x63 read as uint32 LE
const MAGIC = 0x632B7EAF;
// ---------------------------------------------------------------------------
// MonoDisplayParser
// ---------------------------------------------------------------------------
export class MonoDisplayParser {
parse(buffer: ArrayBuffer): MonoFormatFile {
const r = new BinaryReader(buffer);
// File header (12 bytes)
const magic = r.readUint32();
if (magic !== MAGIC) throw new Error(`Bad magic 0x${magic.toString(16)} — expected 0x632B7EAF`);
const version = r.readUint32();
if (version === 0 || version > 1) throw new Error(`Unsupported version ${version}`);
const numSections = r.readUint16();
r.readUint16(); // reserved
const sections: MonoFormatSection[] = [];
for (let i = 0; i < numSections; i++) {
const sectionType = r.readByte();
const sectionSize = r.readUint24(); // INCLUDES 4-byte header
const sectionStart = r.offset; // start of section DATA
const dataSize = sectionSize - 4;
switch (sectionType) {
case SectionType.ElementsAlways:
sections.push(this.#parseElementsSection(r, SectionType.ElementsAlways, false));
break;
case SectionType.ElementsTimespan:
sections.push(this.#parseElementsSection(r, SectionType.ElementsTimespan, true));
break;
case SectionType.CustomFont:
sections.push(this.#parseCustomFont(r, dataSize));
break;
default:
// Unknown — skip
break;
}
// Seek past full section data (handles padding, unknown fields, future extensions)
r.seek(sectionStart + dataSize);
}
return { sections };
}
#parseFlags(raw: number): MonoFormatSectionFlags {
return {
drawFront: !!(raw & 0x01),
drawBack: !!(raw & 0x02),
clearBuffer: !!(raw & 0x04),
};
}
#parseElementsSection(
r: BinaryReader,
type: SectionType.ElementsAlways | SectionType.ElementsTimespan,
hasTimestamp: boolean,
): MonoFormatElementsAlways | MonoFormatElementsTimespan {
const flags = this.#parseFlags(r.readUint16());
const numElements = r.readUint16();
let startTimestamp = 0n, endTimestamp = 0n;
if (hasTimestamp) {
startTimestamp = r.readUint64();
endTimestamp = r.readUint64();
}
const elements: MonoFormatElement[] = [];
for (let i = 0; i < numElements; i++) {
const el = this.#parseElement(r);
if (el) elements.push(el);
}
if (hasTimestamp) {
return { sectionType: SectionType.ElementsTimespan, flags, startTimestamp, endTimestamp, elements };
}
return { sectionType: SectionType.ElementsAlways, flags, elements };
}
#parseCustomFont(r: BinaryReader, dataSize: number): MonoFormatCustomFont {
const actualSize = r.readUint24();
r.readByte(); // reserved
const fontData = new Uint8Array(r.readBytes(Math.min(actualSize, dataSize - 4)));
return { sectionType: SectionType.CustomFont, fontData };
}
#parseElement(r: BinaryReader): MonoFormatElement | null {
const elementStart = r.offset;
const type = r.readUint16();
switch (type) {
case ElementType.Image2D: return this.#parseImage2D(r, elementStart);
case ElementType.Animation: return this.#parseAnimation(r, elementStart);
case ElementType.HorizontalScroll: return this.#parseHScroll(r, elementStart);
case ElementType.VerticalScroll: return this.#parseVScroll(r, elementStart);
case ElementType.Line: return this.#parseLine(r);
case ElementType.ClippedText: return this.#parseClippedText(r, elementStart);
case ElementType.HScrollText: return this.#parseHScrollText(r, elementStart);
default:
// Unknown element type — cannot safely skip without knowing size; stop parsing section elements
return null;
}
}
#alignTo4(start: number, current: number): void {
// elements are 32-bit aligned; seek past padding if any
const end = start + (((current - start) + 3) & ~3);
// no-op if already aligned (handled by caller via section seek)
}
#parseImage2D(r: BinaryReader, start: number): MonoFormatImage2D {
const xOffset = r.readUint16();
const yOffset = r.readUint16();
const width = r.readUint16();
const height = r.readUint16();
r.readUint16(); // reserved
// Fixed header: 2(type)+2+2+2+2+2 = 12 bytes
const packed = r.readBytes(packedSize(width, height));
const pixels = unpackPixels(new Uint8Array(packed), width, height);
// Skip padding to 32-bit boundary for the element
const dataRead = 12 + packed.byteLength;
r.seek(start + dataRead + pad32(dataRead));
return { type: ElementType.Image2D, xOffset, yOffset, image: { pixels, width, height } };
}
#parseAnimation(r: BinaryReader, start: number): MonoFormatAnimation {
const xOffset = r.readUint16();
const yOffset = r.readUint16();
const width = r.readUint16();
const height = r.readUint16();
const numFrames = r.readUint16();
const updateInterval = r.readUint16();
r.readUint16(); // reserved
// Fixed header: 2+2+2+2+2+2+2+2 = 16 bytes
const frameBytes = packedSize(width, height);
const frames: MonoFormatPixelImage[] = [];
for (let f = 0; f < numFrames; f++) {
const packed = r.readBytes(frameBytes);
frames.push({ pixels: unpackPixels(new Uint8Array(packed), width, height), width, height });
}
const dataRead = 16 + numFrames * frameBytes;
r.seek(start + dataRead + pad32(dataRead));
return { type: ElementType.Animation, xOffset, yOffset, width, height, updateInterval, frames };
}
#parseScrollFlags(raw: number) {
return {
endless: !!(raw & 0x01),
invertDirection: !!(raw & 0x02),
padStart: !!(raw & 0x04),
padEnd: !!(raw & 0x08),
};
}
#parseHScroll(r: BinaryReader, start: number): MonoFormatHScroll {
const xOffset = r.readUint16();
const yOffset = r.readUint16();
const width = r.readUint16();
const height = r.readUint16();
const contentWidth = r.readUint16();
const flagsByte = r.readByte();
const scrollSpeed = r.readByte();
r.readUint16(); // reserved
// Fixed header: 2+2+2+2+2+2+1+1+2 = 16 bytes
const packed = r.readBytes(packedSize(contentWidth, height));
const pixels = unpackPixels(new Uint8Array(packed), contentWidth, height);
const dataRead = 16 + packed.byteLength;
r.seek(start + dataRead + pad32(dataRead));
return {
type: ElementType.HorizontalScroll, xOffset, yOffset, width, height, contentWidth,
scrollSpeed, flags: this.#parseScrollFlags(flagsByte),
content: { pixels, width: contentWidth, height },
};
}
#parseVScroll(r: BinaryReader, start: number): MonoFormatVScroll {
const xOffset = r.readUint16();
const yOffset = r.readUint16();
const width = r.readUint16();
const height = r.readUint16();
const contentHeight = r.readUint16();
const flagsByte = r.readByte();
const scrollSpeed = r.readByte();
r.readUint16(); // reserved
const packed = r.readBytes(packedSize(width, contentHeight));
const pixels = unpackPixels(new Uint8Array(packed), width, contentHeight);
const dataRead = 16 + packed.byteLength;
r.seek(start + dataRead + pad32(dataRead));
return {
type: ElementType.VerticalScroll, xOffset, yOffset, width, height, contentHeight,
scrollSpeed, flags: this.#parseScrollFlags(flagsByte),
content: { pixels, width, height: contentHeight },
};
}
#parseLine(r: BinaryReader): MonoFormatLine {
const xOrigin = r.readUint16();
const yOrigin = r.readUint16();
const xTarget = r.readUint16();
const yTarget = r.readUint16();
const lineStyle = r.readByte();
const flagsByte = r.readByte();
// 2+2+2+2+2+1+1 = 12 bytes, already 32-bit aligned
return { type: ElementType.Line, xOrigin, yOrigin, xTarget, yTarget, lineStyle, invertPixels: !!(flagsByte & 1) };
}
#parseClippedText(r: BinaryReader, start: number): MonoFormatClippedText {
const xOffset = r.readUint16();
const yOffset = r.readUint16();
const width = r.readUint16();
const height = r.readUint16();
const fontIndex = r.readUint16();
const textLen = r.readUint16();
// Fixed header: 2+2+2+2+2+2+2 = 14 bytes
const text = r.readUtf8(textLen);
const dataRead = 14 + textLen;
r.seek(start + dataRead + pad32(dataRead));
return { type: ElementType.ClippedText, xOffset, yOffset, width, height, fontIndex, text };
}
#parseHScrollText(r: BinaryReader, start: number): MonoFormatHScrollText {
const xOffset = r.readUint16();
const yOffset = r.readUint16();
const width = r.readUint16();
const height = r.readUint16();
const flagsByte = r.readByte();
const scrollSpeed = r.readByte();
const fontIndex = r.readUint16();
const textLen = r.readUint16(); // PLAN: confirm with spec (assumed, not explicit in diagram)
// Fixed header: 2+2+2+2+2+1+1+2+2 = 16 bytes
const text = r.readUtf8(textLen);
const dataRead = 16 + textLen;
r.seek(start + dataRead + pad32(dataRead));
return {
type: ElementType.HScrollText, xOffset, yOffset, width, height,
scrollSpeed, fontIndex, flags: this.#parseScrollFlags(flagsByte), text,
};
}
}
// ---------------------------------------------------------------------------
// MonoDisplayRenderer — tick-based canvas renderer
// ---------------------------------------------------------------------------
/** 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 → NxN canvas pixels.
* Keep at 1 and use CSS to stretch for crisp upscaling.
*/
scale?: number;
/** Default display width for elements without inherent size. Default 160. */
displayWidth?: number;
/** Default display height. Default 80. */
displayHeight?: number;
/**
* Render rate in ticks per second. All animations and scrolls are driven by this.
* Animation updateInterval, scroll speed, etc. are relative to this tick rate.
* Default 25.
*/
fps?: number;
/** Loop animations. Default true. */
loop?: boolean;
/** Error handler. Default: throw. */
onError?: (err: Error) => void;
}
/**
* Renders a MonoFormatFile onto an HTMLCanvasElement.
* Uses setInterval at 1000/fps ms per tick. All element state (animation
* frame counters, scroll positions) is keyed by element index within the file.
*
* PLAN: ClippedText and HScrollText currently render via canvas fillText (stub).
* Replace with U8G2 bitmap font renderer once font loading is implemented.
* PLAN: Line element uses Bresenham stub; lineStyle field is reserved and ignored.
*/
export class MonoDisplayRenderer {
private ctx: CanvasRenderingContext2D;
private opts: Required<MonoDisplayDriverOptions>;
private tickTimer: number | null = null;
private tickCount: number = 0;
// Per-element state: keyed by stable element index
private animState: Map<number, { frame: number; counter: number }> = new Map();
private hScrollPos: Map<number, number> = new Map(); // pixel offset (may be fractional)
private vScrollPos: Map<number, number> = new Map();
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(): void {
if (this.tickTimer !== null) {
clearInterval(this.tickTimer);
this.tickTimer = null;
}
this.tickCount = 0;
this.animState.clear();
this.hScrollPos.clear();
this.vScrollPos.clear();
}
render(file: MonoFormatFile): void {
this.stop();
const { displayWidth: dw, displayHeight: dh, scale: s } = this.opts;
this.ctx.canvas.width = dw * s;
this.ctx.canvas.height = dh * s;
const tick = () => {
this.#renderFile(file);
this.tickCount++;
};
tick(); // immediate first frame
this.tickTimer = setInterval(tick, 1000 / this.opts.fps) as unknown as number;
}
#renderFile(file: MonoFormatFile): void {
const now = BigInt(Math.floor(Date.now() / 1000));
let cleared = false;
let elementId = 0;
for (const section of file.sections) {
if (section.sectionType === SectionType.ElementsTimespan) {
if (now < section.startTimestamp || now >= section.endTimestamp) {
// Count elements so IDs stay stable even for skipped sections
elementId += section.elements.length;
continue;
}
}
if (section.sectionType !== SectionType.ElementsAlways &&
section.sectionType !== SectionType.ElementsTimespan) {
continue;
}
const elSection = section as MonoFormatElementsAlways | MonoFormatElementsTimespan;
if (elSection.flags.clearBuffer && !cleared) {
this.ctx.fillStyle = this.opts.offColor;
this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
cleared = true;
}
for (const el of elSection.elements) {
this.#renderElement(el, elementId++);
}
}
}
#renderElement(el: MonoFormatElement, id: number): void {
switch (el.type) {
case ElementType.Image2D: this.#blitImage(el.image, el.xOffset, el.yOffset); break;
case ElementType.Animation: this.#renderAnimation(el, id); break;
case ElementType.HorizontalScroll: this.#renderHScroll(el, id); break;
case ElementType.VerticalScroll: this.#renderVScroll(el, id); break;
case ElementType.Line: this.#renderLine(el); break;
case ElementType.ClippedText: this.#renderClippedText(el); break;
case ElementType.HScrollText: this.#renderHScrollText(el, id); break;
}
}
/** Blit a pixel image onto the canvas at (ox, oy) in display coordinates */
#blitImage(img: MonoFormatPixelImage, ox: number, oy: number): void {
const { ctx, opts: { onColor, scale: s } } = this;
ctx.fillStyle = onColor;
for (let y = 0; y < img.height; y++) {
for (let x = 0; x < img.width; x++) {
if (img.pixels[y * img.width + x]) {
ctx.fillRect((ox + x) * s, (oy + y) * s, s, s);
}
}
}
}
/** Blit a clipped sub-region of a pixel image. srcX is fractional scroll offset. */
#blitClipped(
img: MonoFormatPixelImage, ox: number, oy: number,
viewW: number, viewH: number, srcX: number, srcY: number,
): void {
const { ctx, opts: { onColor, scale: s } } = this;
ctx.fillStyle = onColor;
for (let y = 0; y < viewH; y++) {
for (let x = 0; x < viewW; x++) {
const sx = Math.floor(srcX) + x;
const sy = Math.floor(srcY) + y;
if (sx < 0 || sx >= img.width || sy < 0 || sy >= img.height) continue;
if (img.pixels[sy * img.width + sx]) {
ctx.fillRect((ox + x) * s, (oy + y) * s, s, s);
}
}
}
}
#renderAnimation(el: MonoFormatAnimation, id: number): void {
if (el.frames.length === 0) return;
let state = this.animState.get(id);
if (!state) { state = { frame: 0, counter: 0 }; this.animState.set(id, state); }
this.#blitImage(el.frames[state.frame], el.xOffset, el.yOffset);
state.counter++;
if (state.counter > el.updateInterval) {
state.counter = 0;
state.frame = (state.frame + 1) % el.frames.length;
if (!this.opts.loop && state.frame === 0) state.frame = el.frames.length - 1;
}
}
#renderHScroll(el: MonoFormatHScroll, id: number): void {
let pos = this.hScrollPos.get(id) ?? 0;
// Draw visible slice
this.#blitClipped(el.content, el.xOffset, el.yOffset, el.width, el.height, pos, 0);
// Advance scroll: (SS+1)/16 pixels per tick
const speed = (el.scrollSpeed + 1) / 16;
pos += el.flags.invertDirection ? -speed : speed;
if (el.flags.endless) {
if (pos >= el.contentWidth) pos -= el.contentWidth;
if (pos < 0) pos += el.contentWidth;
} else {
pos = Math.max(0, Math.min(pos, el.contentWidth - el.width));
}
this.hScrollPos.set(id, pos);
}
#renderVScroll(el: MonoFormatVScroll, id: number): void {
let pos = this.vScrollPos.get(id) ?? 0;
this.#blitClipped(el.content, el.xOffset, el.yOffset, el.width, el.height, 0, pos);
const speed = (el.scrollSpeed + 1) / 16;
pos += el.flags.invertDirection ? -speed : speed;
if (el.flags.endless) {
if (pos >= el.contentHeight) pos -= el.contentHeight;
if (pos < 0) pos += el.contentHeight;
} else {
pos = Math.max(0, Math.min(pos, el.contentHeight - el.height));
}
this.vScrollPos.set(id, pos);
}
#renderLine(el: MonoFormatLine): void {
// Bresenham's line algorithm
const { ctx, opts: { onColor, offColor, scale: s } } = this;
ctx.fillStyle = el.invertPixels ? offColor : onColor;
let [x0, y0, x1, y1] = [el.xOrigin, el.yOrigin, el.xTarget, el.yTarget];
const dx = Math.abs(x1-x0), sx = x0 < x1 ? 1 : -1;
const dy = -Math.abs(y1-y0), sy = y0 < y1 ? 1 : -1;
let err = dx + dy;
while (true) {
ctx.fillRect(x0*s, y0*s, s, s);
if (x0 === x1 && y0 === y1) break;
const e2 = 2 * err;
if (e2 >= dy) { err += dy; x0 += sx; }
if (e2 <= dx) { err += dx; y0 += sy; }
}
}
#renderClippedText(el: MonoFormatClippedText): void {
// PLAN: replace with U8G2 bitmap font renderer using el.fontIndex
const { ctx, opts: { onColor, scale: s } } = this;
ctx.save();
ctx.beginPath();
ctx.rect(el.xOffset * s, el.yOffset * s, el.width * s, el.height * s);
ctx.clip();
ctx.fillStyle = onColor;
const fontSize = Math.max(6, Math.floor(el.height * s * 0.75));
ctx.font = `${fontSize}px monospace`;
ctx.textBaseline = "top";
ctx.fillText(el.text, el.xOffset * s, el.yOffset * s);
ctx.restore();
}
#renderHScrollText(el: MonoFormatHScrollText, id: number): void {
// PLAN: replace with U8G2 bitmap font renderer using el.fontIndex
const { ctx, opts: { onColor, scale: s } } = this;
const fontSize = Math.max(6, Math.floor(el.height * s * 0.75));
ctx.font = `${fontSize}px monospace`;
const textPx = ctx.measureText(el.text).width;
let pos = this.hScrollPos.get(id) ?? el.width * s;
ctx.save();
ctx.beginPath();
ctx.rect(el.xOffset * s, el.yOffset * s, el.width * s, el.height * s);
ctx.clip();
ctx.fillStyle = onColor;
ctx.textBaseline = "top";
ctx.fillText(el.text, el.xOffset * s + pos, el.yOffset * s);
ctx.restore();
const speed = (el.scrollSpeed + 1) / 16 * s;
pos += el.flags.invertDirection ? speed : -speed;
if (el.flags.endless) {
if (pos < -textPx) pos = el.width * s;
if (pos > el.width * s) pos = -textPx;
}
this.hScrollPos.set(id, pos);
}
}
// ---------------------------------------------------------------------------
// MonoDisplayDriver — public API surface
// ---------------------------------------------------------------------------
/**
* Top-level driver. Attach to a canvas by ID, call load() with an async
* factory that returns a .bin ArrayBuffer.
*
* @example
* ```ts
* const driver = new MonoDisplayDriver("canvas_root", { fps: 25 });
* driver.load(() => loadBinFile("display.bin"));
* ```
*/
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(`#${canvasId} is not a <canvas>`);
this.canvas = el as HTMLCanvasElement;
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();
}
async load(loader: () => Promise<ArrayBuffer>): Promise<void> {
try {
const buffer = await loader();
const file = this.parser.parse(buffer);
if (!this.renderer) this.renderer = new MonoDisplayRenderer(this.canvas, this.opts);
this.renderer.render(file);
} catch (err) {
this.opts.onError(err instanceof Error ? err : new Error(String(err)));
}
}
stop(): void { this.renderer?.stop(); }
}
// ---------------------------------------------------------------------------
// MonoDisplayFile — JSON descriptor → binary .bin builder
// ---------------------------------------------------------------------------
/**
* Encodes a MonoDisplayFileDescriptor to a valid .bin ArrayBuffer.
*
* elements_always → Section type 1 (ElementsAlways).
* Pass a DrawElement[] or { flags, elements }.
* Default flags: drawFront=true, drawBack=false, clearBuffer=false.
*
* PLAN: encode ElementsTimespan sections, CustomFont sections.
*/
export class MonoDisplayFile {
private desc: MonoDisplayFileDescriptor;
constructor(descriptor: MonoDisplayFileDescriptor) {
this.desc = descriptor;
}
toBuffer(): ArrayBuffer {
const sections: Uint8Array[] = [];
// Section 1: elements always
const alwaysDesc = this.desc.elements_always;
if (alwaysDesc) {
const isArray = Array.isArray(alwaysDesc);
const elements = isArray ? alwaysDesc as DrawElement[] : (alwaysDesc as ElementsAlwaysDescriptor).elements;
const flags = isArray ? undefined : (alwaysDesc as ElementsAlwaysDescriptor).flags;
sections.push(this.#encodeElementsSection(SectionType.ElementsAlways, flags, elements));
}
// File header (12 bytes)
const hdrBuf = new ArrayBuffer(12);
const hdrView = new DataView(hdrBuf);
hdrView.setUint32(0, MAGIC, true);
hdrView.setUint32(4, 1, true); // version
hdrView.setUint16(8, sections.length, true);
hdrView.setUint16(10, 0, true); // reserved
return this.#concat(new Uint8Array(hdrBuf), ...sections);
}
// --- section encoders ---
#encodeElementsSection(
type: SectionType.ElementsAlways | SectionType.ElementsTimespan,
flags: SectionFlags | undefined,
elements: DrawElement[],
): Uint8Array {
const flagBits =
(flags?.drawFront ?? true ? 0x01 : 0) |
(flags?.drawBack ?? false ? 0x02 : 0) |
(flags?.clearBuffer ?? false ? 0x04 : 0);
const encodedEls = elements.map(el => this.#encodeElement(el));
const sectionDataSize = 4 + encodedEls.reduce((s, e) => s + e.byteLength, 0);
// sectionSize (in header) = 4 (header) + sectionDataSize
const sectionSize = 4 + sectionDataSize;
const hdr = new Uint8Array(4 + 4); // section header (4) + section sub-header (4)
const v = new DataView(hdr.buffer);
v.setUint8(0, type);
v.setUint8(1, sectionSize & 0xFF);
v.setUint8(2, (sectionSize >> 8) & 0xFF);
v.setUint8(3, (sectionSize >> 16) & 0xFF);
v.setUint16(4, flagBits, true); // flags
v.setUint16(6, elements.length, true); // numElements
return this.#concat(hdr, ...encodedEls);
}
// --- element encoders ---
#encodeElement(el: DrawElement): Uint8Array {
switch (el.type) {
case ElementType.Image2D: return this.#encodeImage2D(el);
case ElementType.Animation: return this.#encodeAnimation(el);
case ElementType.HorizontalScroll: return this.#encodeHScroll(el);
case ElementType.VerticalScroll: return this.#encodeVScroll(el);
case ElementType.Line: return this.#encodeLine(el);
case ElementType.ClippedText: return this.#encodeClippedText(el);
case ElementType.HScrollText: return this.#encodeHScrollText(el);
}
}
#encodeImage2D(el: Image2DElement): Uint8Array {
const packed = packPixels(el.pixels, el.width, el.height);
const fixed = 12; // bytes before pixel data
const rawSize = fixed + packed.byteLength;
const total = rawSize + pad32(rawSize);
const out = new Uint8Array(total);
const v = new DataView(out.buffer);
v.setUint16(0, ElementType.Image2D, true);
v.setUint16(2, el.xOffset ?? 0, true);
v.setUint16(4, el.yOffset ?? 0, true);
v.setUint16(6, el.width, true);
v.setUint16(8, el.height, true);
v.setUint16(10, 0, true); // reserved
out.set(packed, 12);
return out;
}
#encodeAnimation(el: AnimationElement): Uint8Array {
const frameBytes = packedSize(el.width, el.height);
const fixed = 16;
const rawSize = fixed + el.frames.length * frameBytes;
const total = rawSize + pad32(rawSize);
const out = new Uint8Array(total);
const v = new DataView(out.buffer);
v.setUint16(0, ElementType.Animation, true);
v.setUint16(2, el.xOffset ?? 0, true);
v.setUint16(4, el.yOffset ?? 0, true);
v.setUint16(6, el.width, true);
v.setUint16(8, el.height, true);
v.setUint16(10, el.frames.length, true);
v.setUint16(12, el.updateInterval ?? 0, true);
v.setUint16(14, 0, true); // reserved
let off = 16;
for (const f of el.frames) {
out.set(packPixels(f.pixels, el.width, el.height), off);
off += frameBytes;
}
return out;
}
#encodeScrollFlags(f: ScrollElementFlags | undefined): number {
return (
(f?.endless ? 0x01 : 0) |
(f?.invertDirection ? 0x02 : 0) |
(f?.padStart ? 0x04 : 0) |
(f?.padEnd ? 0x08 : 0)
);
}
#encodeHScroll(el: HScrollElement): Uint8Array {
const packed = packPixels(el.pixels, el.contentWidth, el.height);
const fixed = 16;
const rawSize = fixed + packed.byteLength;
const total = rawSize + pad32(rawSize);
const out = new Uint8Array(total);
const v = new DataView(out.buffer);
v.setUint16(0, ElementType.HorizontalScroll, true);
v.setUint16(2, el.xOffset ?? 0, true);
v.setUint16(4, el.yOffset ?? 0, true);
v.setUint16(6, el.width, true);
v.setUint16(8, el.height, true);
v.setUint16(10, el.contentWidth, true);
v.setUint8( 12, this.#encodeScrollFlags(el.flags));
v.setUint8( 13, el.scrollSpeed ?? 0);
v.setUint16(14, 0, true); // reserved
out.set(packed, 16);
return out;
}
#encodeVScroll(el: VScrollElement): Uint8Array {
const packed = packPixels(el.pixels, el.width, el.contentHeight);
const fixed = 16;
const rawSize = fixed + packed.byteLength;
const total = rawSize + pad32(rawSize);
const out = new Uint8Array(total);
const v = new DataView(out.buffer);
v.setUint16(0, ElementType.VerticalScroll, true);
v.setUint16(2, el.xOffset ?? 0, true);
v.setUint16(4, el.yOffset ?? 0, true);
v.setUint16(6, el.width, true);
v.setUint16(8, el.height, true);
v.setUint16(10, el.contentHeight, true);
v.setUint8( 12, this.#encodeScrollFlags(el.flags));
v.setUint8( 13, el.scrollSpeed ?? 0);
v.setUint16(14, 0, true);
out.set(packed, 16);
return out;
}
#encodeLine(el: LineElement): Uint8Array {
// 12 bytes, already 32-bit aligned
const out = new Uint8Array(12);
const v = new DataView(out.buffer);
v.setUint16(0, ElementType.Line, true);
v.setUint16(2, el.xOrigin, true);
v.setUint16(4, el.yOrigin, true);
v.setUint16(6, el.xTarget, true);
v.setUint16(8, el.yTarget, true);
v.setUint8(10, el.lineStyle ?? 0);
v.setUint8(11, el.invertPixels ? 1 : 0);
return out;
}
#encodeClippedText(el: ClippedTextElement): Uint8Array {
const enc = new TextEncoder().encode(el.text);
const fixed = 14; // 2+2+2+2+2+2+2
const rawSize = fixed + enc.byteLength;
const total = rawSize + pad32(rawSize);
const out = new Uint8Array(total);
const v = new DataView(out.buffer);
v.setUint16(0, ElementType.ClippedText, true);
v.setUint16(2, el.xOffset ?? 0, true);
v.setUint16(4, el.yOffset ?? 0, true);
v.setUint16(6, el.width, true);
v.setUint16(8, el.height, true);
v.setUint16(10, el.fontIndex ?? 0, true);
v.setUint16(12, enc.byteLength, true);
out.set(enc, 14);
return out;
}
#encodeHScrollText(el: HScrollTextElement): Uint8Array {
const enc = new TextEncoder().encode(el.text);
const fixed = 16; // 2+2+2+2+2+1+1+2+2
const rawSize = fixed + enc.byteLength;
const total = rawSize + pad32(rawSize);
const out = new Uint8Array(total);
const v = new DataView(out.buffer);
v.setUint16(0, ElementType.HScrollText, true);
v.setUint16(2, el.xOffset ?? 0, true);
v.setUint16(4, el.yOffset ?? 0, true);
v.setUint16(6, el.width, true);
v.setUint16(8, el.height, true);
v.setUint8( 10, this.#encodeScrollFlags(el.flags));
v.setUint8( 11, el.scrollSpeed ?? 0);
v.setUint16(12, el.fontIndex ?? 0, true);
v.setUint16(14, enc.byteLength, true); // PLAN: confirm with spec
out.set(enc, 16);
return out;
}
// --- utilities ---
#concat(...parts: Uint8Array[]): Uint8Array {
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(p, off); off += p.byteLength; }
return out;
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
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 single-element-always .bin buffer containing one Image2D element.
* Convenience for tests; for production use MonoDisplayFile directly.
*/
export function buildBinBuffer(el: Image2DElement): ArrayBuffer {
return new MonoDisplayFile({ elements_always: [el] }).toBuffer();
}