import { ElementType, SectionType, type MonoFormatAnimation, type MonoFormatClippedText, type MonoFormatCurrentTime, type MonoFormatElement, type MonoFormatElementsAlways, type MonoFormatElementsTimespan, type MonoFormatFile, type MonoFormatHScroll, type MonoFormatHScrollText, type MonoFormatLine, type MonoFormatPixelImage, type MonoFormatVScroll } from "./types.js"; import { type MonoDisplayDriverOptions } from "./driver"; import { rasterizeText, MonoDisplayFont, u8g2_font_5x7_tf, u8g2_font_HelvetiPixel_tr, u8g2_font_spleen12x24_me, u8g2_font_smolfont_tf, u8g2_font_micropixel_tf, u8g2_font_micropixel_tr, u8g2_font_NokiaSmallPlain_tf, u8g2_font_5x7_mf } from "./font"; /** * 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; private tickTimer: number | null = null; private tickCount: number = 0; // Per-element state: keyed by stable element index private animState: Map = new Map(); private hScrollPos: Map = new Map(); // pixel offset (may be fractional) private vScrollPos: Map = new Map(); private customFonts: MonoDisplayFont[] = []; private textCache: Map = new Map(); public static readonly builtinFonts: Record = { "NokiaSmallPlain_tf": u8g2_font_NokiaSmallPlain_tf, "5x7_mf": u8g2_font_5x7_mf, // "micropixel_tf": u8g2_font_micropixel_tf, // "micropixel_tr": u8g2_font_micropixel_tr, // "smolfont_tf": u8g2_font_smolfont_tf, // "spleen12x24_me": u8g2_font_spleen12x24_me, // "spleen12x24_me": u8g2_font_spleen12x24_me, // "5x7_tf": u8g2_font_5x7_tf, }; constructor(canvas: HTMLCanvasElement, opts: Required) { 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(); this.textCache.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; // Extract custom fonts from file sections (0x8000-indexed) this.customFonts = []; for (const section of file.sections) { if (section.sectionType === SectionType.CustomFont) { this.customFonts.push(section.fontData); } } 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(this.opts.now().getTime() / 1000)); let cleared = false; let elementId = 0; // default off this.ctx.fillStyle = this.opts.offColor; this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height); 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; case ElementType.CurrentTime: this.#renderCurrentTime(el); 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; } } } #getTextImage(text: string, height: number, fontIndex: number): MonoFormatPixelImage { const key = `${text}|${fontIndex}|${height}`; const cached = this.textCache.get(key); if (cached) return cached; fontIndex = Math.min(fontIndex, Object.keys(MonoDisplayRenderer.builtinFonts).length - 1); const builtinFont: MonoDisplayFont = Object.values(MonoDisplayRenderer.builtinFonts).at(fontIndex) || u8g2_font_NokiaSmallPlain_tf; const customFont: MonoDisplayFont | undefined = this.customFonts[fontIndex - 0x8000]; const selectedFont: MonoDisplayFont = fontIndex >= 0x8000 ? (customFont || builtinFont) : builtinFont; const img = rasterizeText(text, height, selectedFont); this.textCache.set(key, img); return img; } #renderClippedText(el: MonoFormatClippedText): void { const img = this.#getTextImage(el.text, el.height, el.fontIndex); this.#blitClipped(img, el.xOffset, el.yOffset, el.width, el.height, 0, 0); } #renderHScrollText(el: MonoFormatHScrollText, id: number): void { const img = this.#getTextImage(el.text, el.height, el.fontIndex); let pos = this.hScrollPos.get(id) ?? 0; this.#blitClipped(img, el.xOffset, el.yOffset, el.width, el.height, pos, 0); const speed = (el.scrollSpeed + 1) / 16; pos += el.flags.invertDirection ? -speed : speed; if (el.flags.endless) { if (pos >= img.width) pos -= img.width; if (pos < 0) pos += img.width; } else { pos = Math.max(0, Math.min(pos, img.width - el.width)); } this.hScrollPos.set(id, pos); } #renderCurrentTime(el: MonoFormatCurrentTime): void { const d = new Date(this.opts.now().getTime() + el.utcOffsetMinutes * 60_000); const { clock12h, showHours, showMinutes, showSeconds } = el.flags; const parts: string[] = []; if (showHours) { const h = d.getUTCHours(); parts.push(String(clock12h ? (h % 12 || 12) : h).padStart(2, '0')); } if (showMinutes) parts.push(String(d.getUTCMinutes()).padStart(2, '0')); if (showSeconds) parts.push(String(d.getUTCSeconds()).padStart(2, '0')); const text = parts.join(':'); if (!text) return; const img = this.#getTextImage(text, el.height, el.fontIndex); this.#blitClipped(img, el.xOffset, el.yOffset, el.width, el.height, 0, 0); } }