|
|
|
@ -86,6 +86,176 @@ export interface ScrollText { |
|
|
|
|
|
|
|
|
|
|
|
export type DisplayContent = StaticFrame | Animation | TextDisplay | ScrollText; |
|
|
|
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 */ |
|
|
|
/** Optional constructor arguments for MonoDisplayDriver */ |
|
|
|
export interface MonoDisplayDriverOptions { |
|
|
|
export interface MonoDisplayDriverOptions { |
|
|
|
/** CSS colour for "on" pixels; default "#ffffff" */ |
|
|
|
/** CSS colour for "on" pixels; default "#ffffff" */ |
|
|
|
|