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}