font.js
6.6 kB · javascript · 211 lines
1import FONT from './font.json' with { type: 'json' };23const FPS = 25;4export const HOLD = 25;5const BLANK = ['000', '000', '000', '000', '000'];67/* GLYPHS */89function glyph(char) {10 const rows = FONT[char]?.rows;11 if (!rows) return BLANK;12 let from = Infinity;13 let to = 0;14 for (const row of rows) {15 for (let c = 0; c < row.length; c++) {16 if (row[c] !== '1') continue;17 from = Math.min(from, c);18 to = Math.max(to, c + 1);19 }20 }21 return from === Infinity ? BLANK : rows.map((row) => row.slice(from, to));22}2324function layout(text) {25 const blocks = [];26 let col = 0;27 let height = 5;28 for (const char of text) {29 const rows = glyph(char);30 height = Math.max(height, rows.length);31 blocks.push({ char, rows, col, offset: 0 });32 col += rows[0].length + 1;33 }34 for (const block of blocks) block.offset = (height - block.rows.length) >> 1;35 return { width: blocks.length ? col - 1 : 0, height, blocks };36}3738const bridge = (name) => globalThis.mrly?.[name];3940/* RASTER */4142const cells = (row) => (typeof row === 'string' ? [...row].map((c) => (c === '1' ? 1 : 0)) : row);4344export function letters(text) {45 const fast = bridge('font_raster');46 if (fast) {47 const raster = JSON.parse(fast(text));48 return { rows: raster.rows, cols: raster.cols, grid: raster.grid.map(cells) };49 }50 const { width, height, blocks } = layout(text);51 const grid = Array.from({ length: width ? height : 0 }, () => Array(width).fill(0));52 for (const block of blocks) {53 block.rows.forEach((row, r) => {54 for (let c = 0; c < row.length; c++) if (row[c] === '1') grid[block.offset + r][block.col + c] = 1;55 });56 }57 return { rows: grid.length, cols: width, grid };58}5960/* STROKES */6162const sequence = (char) => FONT[char]?.path ?? [];6364/* ANIMATION */6566const board = (laid, pad) => (laid.blocks.length ? [laid.height + 2 * pad, laid.width + 2 * pad] : [0, 0]);6768export function animate(text, pad = 1) {69 const fast = bridge('font_animate');70 if (fast) return JSON.parse(fast(text, pad));71 const laid = layout(text);72 const [rows, cols] = board(laid, pad);73 const frames = [[]];74 const current = [];75 for (const block of laid.blocks) {76 for (const [r, c] of sequence(block.char)) {77 current.push((pad + block.offset + r) * cols + (pad + block.col + c));78 frames.push([...current].sort((a, b) => a - b));79 }80 }81 return { rows, cols, fps: FPS, frames };82}8384function stamp(blocks, spots, rows, cols) {85 const active = new Set();86 blocks.forEach((block, i) => {87 const [cx, cy] = spots[i];88 block.rows.forEach((row, r) => {89 for (let c = 0; c < row.length; c++) {90 if (row[c] !== '1') continue;91 const y = cy + r;92 const x = cx + c;93 if (y >= 0 && y < rows && x >= 0 && x < cols) active.add(y * cols + x);94 }95 });96 });97 return [...active].sort((a, b) => a - b);98}99100function beat(frame, total, phases) {101 const len = Math.floor(total / phases);102 for (let p = 1; p < phases; p++) if (frame < len * p) return [p, (frame - len * (p - 1)) / len];103 const done = len * (phases - 1);104 return [phases, (frame - done) / (total - done)];105}106107const lerp = (start, end, p) => start + Math.trunc((end - start) * p);108109function place(idx, n, phases, phase, progress, starts, targets) {110 const slide = (a, b) => [lerp(a[0], b[0], progress), lerp(a[1], b[1], progress)];111 if (phase < phases) {112 if (idx < phase) return slide(starts[phase - 1], starts[phase]);113 if (idx >= n - phase) return slide(starts[n - phase], starts[n - phase - 1]);114 return starts[idx];115 }116 const anchor = idx < phases ? starts[phases - 1] : idx >= n - phases ? starts[n - phases] : starts[idx];117 return slide(anchor, targets[idx]);118}119120const same = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]);121122export function merge(text, pad = 1) {123 const laid = layout(text);124 const [rows, cols] = board(laid, pad);125 const n = laid.blocks.length;126 const phases = n >> 1;127 const starts = laid.blocks.map((b) => [pad + b.col, pad + b.offset]);128 const targets = laid.blocks.map((b) => [Math.floor((cols - b.rows[0].length) / 2), pad + b.offset]);129 if (!phases) return [stamp(laid.blocks, starts, rows, cols)];130 const total = cols >> 1;131 const frames = [];132 for (let i = 0; i <= total; i++) {133 const [phase, progress] = beat(i, total, phases);134 const spots = laid.blocks.map((_, idx) => place(idx, n, phases, phase, progress, starts, targets));135 const frame = stamp(laid.blocks, spots, rows, cols);136 if (!frames.length || !same(frames[frames.length - 1], frame)) frames.push(frame);137 }138 return frames;139}140141export function cycle(text, pad = 1, hold = HOLD) {142 const fast = bridge('font_cycle');143 if (fast) return JSON.parse(fast(text, pad, hold));144 const write = animate(text, pad);145 const folded = merge(text, pad);146 const rest = (frame) => Array.from({ length: hold }, () => frame);147 const frames = [148 ...write.frames,149 ...rest(write.frames[write.frames.length - 1]),150 ...folded,151 ...rest(folded[folded.length - 1]),152 ...[...folded].reverse(),153 ...rest(folded[0]),154 ...[...write.frames].reverse(),155 ...rest(write.frames[0]),156 ];157 return { rows: write.rows, cols: write.cols, fps: write.fps, frames };158}159160/* PLAYBACK */161162export function mark(canvas, anim) {163 canvas.width = anim.cols;164 canvas.height = anim.rows;165 const ctx = canvas.getContext('2d');166 let ink = getComputedStyle(canvas).color;167 let last = [];168 const draw = (frame) => {169 last = frame;170 ctx.clearRect(0, 0, anim.cols, anim.rows);171 ctx.fillStyle = ink;172 for (const i of frame) ctx.fillRect(i % anim.cols, Math.floor(i / anim.cols), 1, 1);173 };174 const shade = matchMedia('(prefers-color-scheme: dark)');175 const repaint = () => {176 ink = getComputedStyle(canvas).color;177 draw(last);178 };179 shade.addEventListener('change', repaint);180 window.addEventListener('theme', repaint);181 const drop = () => {182 shade.removeEventListener('change', repaint);183 window.removeEventListener('theme', repaint);184 };185 const full = anim.frames.reduce((a, b) => (b.length > a.length ? b : a), []);186 const still = matchMedia('(prefers-reduced-motion: reduce)');187 if (still.matches || anim.frames.length < 2) {188 draw(full);189 return drop;190 }191 let at = 0;192 let timer = 0;193 const tick = () => {194 draw(anim.frames[at]);195 at = (at + 1) % anim.frames.length;196 };197 const play = () => {198 if (!timer) timer = setInterval(tick, 1000 / anim.fps);199 };200 const pause = () => {201 clearInterval(timer);202 timer = 0;203 };204 const eye = new IntersectionObserver(([entry]) => (entry.isIntersecting ? play() : pause()));205 eye.observe(canvas);206 return () => {207 pause();208 eye.disconnect();209 drop();210 };211}