phone/src/vesta/render.ts
2026-01-21 16:47:23 -08:00

152 lines
4.8 KiB
TypeScript

import sharp from "sharp"
// Character to vestaboard code mapping
const charToCode: Record<string, number> = {
" ": 0,
A: 1, B: 2, C: 3, D: 4, E: 5, F: 6, G: 7, H: 8, I: 9,
J: 10, K: 11, L: 12, M: 13, N: 14, O: 15, P: 16, Q: 17,
R: 18, S: 19, T: 20, U: 21, V: 22, W: 23, X: 24, Y: 25, Z: 26,
"1": 27, "2": 28, "3": 29, "4": 30, "5": 31, "6": 32, "7": 33, "8": 34, "9": 35, "0": 36,
"!": 37, "@": 38, "#": 39, "$": 40, "(": 41, ")": 42, "-": 44, "+": 46,
"&": 47, "=": 48, ";": 49, ":": 50, "'": 52, '"': 53, "%": 54, ",": 55,
".": 56, "/": 59, "?": 60, "°": 62,
"🟥": 63, "🟧": 64, "🟨": 65, "🟩": 66, "🟦": 67, "🟪": 68, "⬜": 69, "⬛": 70,
}
// Code to display info for rendering
const codeToDisplay: Record<number, { bg: string; fg: string; char?: string }> = {
0: { bg: "#1a1a1a", fg: "#ffffff" }, // space (empty black tile)
63: { bg: "#e63946", fg: "#ffffff" }, // red
64: { bg: "#f4a261", fg: "#1a1a1a" }, // orange
65: { bg: "#e9c46a", fg: "#1a1a1a" }, // yellow
66: { bg: "#2a9d8f", fg: "#ffffff" }, // green
67: { bg: "#0077b6", fg: "#ffffff" }, // blue
68: { bg: "#9b5de5", fg: "#ffffff" }, // purple
69: { bg: "#ffffff", fg: "#1a1a1a" }, // white
70: { bg: "#1a1a1a", fg: "#ffffff" }, // black
}
// Code to character (for letters/numbers/symbols)
const codeToChar: Record<number, string> = {}
for (const [char, code] of Object.entries(charToCode)) {
if (code >= 1 && code <= 62) {
codeToChar[code] = char
}
}
const TILE_SIZE = 24
const GAP = 2
const COLS = 22
const ROWS = 6
// Parse emoji grid string to number array
export const parseEmojiGrid = (grid: string): number[][] => {
const lines = grid.trim().split("\n")
const result: number[][] = []
for (const line of lines) {
const row: number[] = []
const chars = [...line] // Handle multi-byte emoji correctly
for (const char of chars) {
const code = charToCode[char.toUpperCase()]
if (code !== undefined) {
row.push(code)
}
}
// Pad or trim to 22 columns
while (row.length < COLS) row.push(0)
if (row.length > COLS) row.length = COLS
result.push(row)
}
// Pad or trim to 6 rows
while (result.length < ROWS) result.push(Array(COLS).fill(0))
if (result.length > ROWS) result.length = ROWS
return result
}
// Convert number grid to emoji string
export const gridToEmoji = (grid: number[][]): string => {
const emojiMap: Record<number, string> = {
0: " ",
63: "🟥", 64: "🟧", 65: "🟨", 66: "🟩", 67: "🟦", 68: "🟪", 69: "⬜", 70: "⬛",
}
return grid
.map((row) =>
row
.map((code) => {
if (emojiMap[code] !== undefined) return emojiMap[code]
if (codeToChar[code]) return codeToChar[code]
return " "
})
.join("")
)
.join("\n")
}
// Render grid to SVG string
const gridToSvg = (grid: number[][]): string => {
const width = COLS * TILE_SIZE + (COLS - 1) * GAP
const height = ROWS * TILE_SIZE + (ROWS - 1) * GAP
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`
svg += `<rect width="${width}" height="${height}" fill="#0d0d0d"/>` // background
for (let row = 0; row < ROWS; row++) {
for (let col = 0; col < COLS; col++) {
const code = grid[row]![col]!
const x = col * (TILE_SIZE + GAP)
const y = row * (TILE_SIZE + GAP)
// Determine tile appearance
let bg = "#1a1a1a"
let fg = "#ffffff"
let char: string | undefined
const display = codeToDisplay[code]
if (display) {
bg = display.bg
fg = display.fg
} else if (codeToChar[code]) {
char = codeToChar[code]
bg = "#1a1a1a"
fg = "#ffffff"
}
// Draw tile
svg += `<rect x="${x}" y="${y}" width="${TILE_SIZE}" height="${TILE_SIZE}" rx="2" fill="${bg}"/>`
// Draw character if present
if (char) {
const fontSize = 14
const textX = x + TILE_SIZE / 2
const textY = y + TILE_SIZE / 2 + fontSize * 0.35
// Escape XML special characters
const escaped = char.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
svg += `<text x="${textX}" y="${textY}" font-family="Arial, sans-serif" font-size="${fontSize}" font-weight="bold" fill="${fg}" text-anchor="middle">${escaped}</text>`
}
}
}
svg += "</svg>"
return svg
}
// Render emoji grid string to PNG buffer
export const renderToPng = async (emojiGrid: string): Promise<Buffer> => {
const grid = parseEmojiGrid(emojiGrid)
const svg = gridToSvg(grid)
return sharp(Buffer.from(svg)).png().toBuffer()
}
// Render number grid to PNG buffer
export const renderGridToPng = async (grid: number[][]): Promise<Buffer> => {
const svg = gridToSvg(grid)
return sharp(Buffer.from(svg)).png().toBuffer()
}