Перейти к содержимому

Типографика

ASCII-текст

Блочная ASCII-типографика с волной, сканированием, глитчем, печатью, пульсацией и статичным рендером.


ASCII-текст

AsciiText превращает поддерживаемые символы в блочную моноширинную сетку и рисует её с помощью Unicode-символов. Компонент поддерживает шесть режимов отображения, настраиваемый символический ramp, цвет и свечение. Неизвестные символы пропускаются; доступное имя изображения берётся из пропса text.

Интерактивное демо

Изменяйте текст и все параметры в панели управления. Исходник компонента целиком доступен во вкладке кода.

Пропсы

Текст
Анимация
Скорость
1.000
Размер шрифта
14
Автоподгонка
Межбуквенный интервал
2
Цвет текста
Свечение
Градиент символов
<script module lang="ts">
  export type AsciiAnimation =
    | "none"
    | "wave"
    | "glitch"
    | "typewriter"
    | "pulse"
    | "scan";

  const GLYPH_HEIGHT = 6;
  const DEFAULT_RAMP = "░▒▓█";

  // Блочный шрифт 8x6. Каждый глиф ровно 8 колонок шириной, чтобы сетка
  // оставалась прямоугольной и поколоночные эффекты совпадали между буквами.
  const FONT: Record<string, string[]> = {
    A: ["  ████  ", " ██  ██ ", "████████", "██    ██", "██    ██", "██    ██"],
    B: ["███████ ", "██    ██", "███████ ", "██    ██", "██    ██", "███████ "],
    C: [" ███████", "██      ", "██      ", "██      ", "██      ", " ███████"],
    D: ["███████ ", "██    ██", "██    ██", "██    ██", "██    ██", "███████ "],
    E: ["████████", "██      ", "██████  ", "██      ", "██      ", "████████"],
    F: ["████████", "██      ", "██████  ", "██      ", "██      ", "██      "],
    G: [" ███████", "██      ", "██  ████", "██    ██", "██    ██", " ███████"],
    H: ["██    ██", "██    ██", "████████", "██    ██", "██    ██", "██    ██"],
    I: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "████████"],
    J: ["████████", "      ██", "      ██", "      ██", "██    ██", " ███████"],
    K: ["██    ██", "██  ██  ", "████    ", "██  ██  ", "██    ██", "██    ██"],
    L: ["██      ", "██      ", "██      ", "██      ", "██      ", "████████"],
    M: ["██    ██", "████████", "██ ██ ██", "██    ██", "██    ██", "██    ██"],
    N: ["██    ██", "███   ██", "██ ██ ██", "██   ███", "██    ██", "██    ██"],
    O: [" ███████", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    P: ["███████ ", "██    ██", "███████ ", "██      ", "██      ", "██      "],
    Q: [" ██████ ", "██    ██", "██    ██", "██ ██ ██", "██   ███", " ███████"],
    R: ["███████ ", "██    ██", "███████ ", "██   ██ ", "██    ██", "██    ██"],
    S: [" ███████", "██      ", " ██████ ", "      ██", "      ██", "███████ "],
    T: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "   ██   "],
    U: ["██    ██", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    V: ["██    ██", "██    ██", "██    ██", " ██  ██ ", "  ████  ", "   ██   "],
    W: ["██    ██", "██    ██", "██    ██", "██ ██ ██", "████████", "██    ██"],
    X: ["██    ██", " ██  ██ ", "  ████  ", "  ████  ", " ██  ██ ", "██    ██"],
    Y: ["██    ██", " ██  ██ ", "  ████  ", "   ██   ", "   ██   ", "   ██   "],
    Z: ["████████", "      ██", "    ██  ", "  ██    ", "██      ", "████████"],
    "0": [" ██████ ", "██    ██", "██    ██", "██    ██", "██    ██", " ██████ "],
    "1": ["   ██   ", "  ███   ", "   ██   ", "   ██   ", "   ██   ", " ██████ "],
    "2": [" ██████ ", "██    ██", "     ██ ", "   ██   ", " ██     ", "████████"],
    "3": [" ██████ ", "██    ██", "    ███ ", "      ██", "██    ██", " ██████ "],
    "4": ["██    ██", "██    ██", "████████", "      ██", "      ██", "      ██"],
    "5": ["████████", "██      ", "███████ ", "      ██", "██    ██", " ██████ "],
    "6": [" ██████ ", "██      ", "███████ ", "██    ██", "██    ██", " ██████ "],
    "7": ["████████", "      ██", "     ██ ", "    ██  ", "   ██   ", "   ██   "],
    "8": [" ██████ ", "██    ██", " ██████ ", "██    ██", "██    ██", " ██████ "],
    "9": [" ██████ ", "██    ██", " ███████", "      ██", "      ██", " ██████ "],
    ".": ["        ", "        ", "        ", "        ", "   ██   ", "   ██   "],
    ",": ["        ", "        ", "        ", "   ██   ", "   ██   ", "  ██    "],
    "!": ["   ██   ", "   ██   ", "   ██   ", "   ██   ", "        ", "   ██   "],
    "?": [" ██████ ", "██    ██", "     ██ ", "   ██   ", "        ", "   ██   "],
    "-": ["        ", "        ", "████████", "        ", "        ", "        "],
    "+": ["        ", "   ██   ", "████████", "   ██   ", "        ", "        "],
    "=": ["        ", "████████", "        ", "████████", "        ", "        "],
    ":": ["        ", "   ██   ", "        ", "        ", "   ██   ", "        "],
    "'": ["   ██   ", "   ██   ", "        ", "        ", "        ", "        "],
    "/": ["      ██", "     ██ ", "    ██  ", "   ██   ", "  ██    ", " ██     "],
    "\\": [
      " ██     ",
      "  ██    ",
      "   ██   ",
      "    ██  ",
      "     ██ ",
      "      ██",
    ],
    "*": ["        ", "██ ██ ██", " ██████ ", "██ ██ ██", "        ", "        "],
    "<": ["     ██ ", "   ██   ", " ██     ", "   ██   ", "     ██ ", "        "],
    ">": [" ██     ", "   ██   ", "     ██ ", "   ██   ", " ██     ", "        "],
    "(": ["    ██  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "    ██  "],
    ")": ["  ██    ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ██    "],
    "[": ["  ████  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "  ████  "],
    "]": ["  ████  ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ████  "],
    "#": [" ██  ██ ", "████████", " ██  ██ ", "████████", " ██  ██ ", "        "],
    "@": [" ██████ ", "██    ██", "██ ██ ██", "██ ████ ", "██      ", " ██████ "],
    _: ["        ", "        ", "        ", "        ", "        ", "████████"],
    " ": ["        ", "        ", "        ", "        ", "        ", "        "],
  };

  /**
   * Раскладывает текст в прямоугольную сетку on/off.
   * Неизвестные символы пропускаются, а не подменяются.
   */
  function buildGrid(text: string, letterSpacing: number): boolean[][] {
    const glyphs = text
      .toUpperCase()
      .split("")
      .map((ch) => FONT[ch])
      .filter((g): g is string[] => Boolean(g));

    if (glyphs.length === 0) return [];

    const gap = " ".repeat(Math.max(0, letterSpacing));
    const rows: boolean[][] = [];

    for (let row = 0; row < GLYPH_HEIGHT; row++) {
      const line = glyphs.map((g) => g[row]).join(gap);
      rows.push(line.split("").map((ch) => ch !== " "));
    }

    return rows;
  }

  /**
   * Чистая функция: рисует один кадр анимации в виде многострочной строки.
   * @param t время в секундах, уже умноженное на speed
   */
  function renderFrame(
    grid: boolean[][],
    mode: AsciiAnimation,
    t: number,
    ramp: string,
  ): string {
    const last = ramp.length - 1;
    const lit = ramp[last] ?? "█";
    const rows = grid.length;
    const width = grid[0]?.length ?? 0;

    const out: string[] = [];

    for (let y = 0; y < rows; y++) {
      let line = "";
      for (let x = 0; x < width; x++) {
        if (!grid[y][x]) {
          line += " ";
          continue;
        }

        switch (mode) {
          case "wave": {
            // Бегущая синусоида выбирает плотность символа из ramp.
            const v = (Math.sin(x * 0.18 - t * 3 + y * 0.25) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "pulse": {
            const v = (Math.sin(t * 2.2) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "scan": {
            // Яркая полоса движется сверху вниз по глифам.
            const band = ((t * 0.6) % 1) * rows;
            const d = Math.abs(y - band);
            const v = Math.max(0, 1 - d / 2);
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "glitch": {
            // Детерминированный хэш, чтобы шум не перемешивался каждый кадр.
            const seed =
              Math.sin(x * 12.9898 + y * 78.233 + Math.floor(t * 8)) *
              43758.5453;
            const n = seed - Math.floor(seed);

            // «Сбойные» ячейки должны быть строго темнее самой яркой ступени,
            // иначе они неотличимы от обычных и эффект не виден.
            if (n > 0.96) {
              line += " ";
            } else if (n > 0.82) {
              const dim = Math.floor(((n - 0.82) / 0.14) * last);
              line +=
                ramp[Math.min(Math.max(last - 1, 0), Math.max(0, dim))] ?? lit;
            } else {
              line += lit;
            }
            break;
          }
          case "typewriter": {
            const revealed = ((t * 12) % (width + 12)) | 0;
            line += x <= revealed ? lit : " ";
            break;
          }
          default:
            line += lit;
        }
      }
      out.push(line);
    }

    return out.join("\n");
  }
</script>

<script lang="ts">
  import type { HTMLAttributes } from "svelte/elements";

  type Props = Omit<HTMLAttributes<HTMLDivElement>, "children" | "color"> & {
    /** Текст для отрисовки. Неподдерживаемые символы пропускаются. */
    text?: string;
    /** Тип анимации глифов. */
    animation?: AsciiAnimation;
    /** Множитель скорости анимации. */
    speed?: number;
    /** Размер глифов в px. Игнорируется при autoFit. */
    fontSize?: number;
    /** Масштабировать глифы так, чтобы строка занимала всю ширину контейнера. */
    autoFit?: boolean;
    /** Пустые колонки между буквами. */
    letterSpacing?: number;
    /** Любой CSS-цвет. По умолчанию наследуется цвет текста. */
    color?: string;
    /** Мягкое свечение за глифами. */
    glow?: boolean;
    /** Символы для «зажжённой» ячейки: от тёмного к светлому. */
    ramp?: string;
  };

  let {
    text = "OPENSOPHY",
    animation = "wave",
    speed = 1,
    fontSize = 14,
    autoFit = true,
    letterSpacing = 2,
    color,
    glow = false,
    ramp = DEFAULT_RAMP,
    class: className,
    ...rest
  }: Props = $props();

  let frame = $state("");
  let containerWidth = $state(0);

  const grid = $derived(buildGrid(text, letterSpacing));
  // Примитивы: эффект перезапускается только при смене формы сетки.
  const rowCount = $derived(grid.length);
  const cols = $derived(grid[0]?.length ?? 0);

  // Моноширинная ячейка ≈ 0.6em шириной.
  const fitSize = $derived(
    autoFit && cols > 0 && containerWidth > 0
      ? Math.max(4, containerWidth / (cols * 0.62))
      : null,
  );
  const size = $derived(autoFit ? (fitSize ?? fontSize) : fontSize);

  $effect(() => {
    // Явные зависимости: форма сетки и тип анимации.
    // speed, ramp и сама сетка читаются внутри rAF-колбэка и не отслеживаются,
    // поэтому смена speed на лету не перезапускает анимацию.
    const mode = animation;
    if (rowCount === 0 || cols === 0) {
      frame = "";
      return;
    }

    const reduceMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    ).matches;

    let raf = 0;
    const start = performance.now();

    const render = (now: number) => {
      const t = reduceMotion ? 0 : ((now - start) / 1000) * speed;
      frame = renderFrame(grid, mode, t, ramp);

      if (mode !== "none" && !reduceMotion) {
        raf = requestAnimationFrame(render);
      }
    };

    raf = requestAnimationFrame(render);
    return () => cancelAnimationFrame(raf);
  });
</script>

<div
  bind:clientWidth={containerWidth}
  class={["w-full overflow-hidden", className]}
  role="img"
  aria-label={text}
  {...rest}
>
  <pre
    aria-hidden="true"
    class="m-0 w-full text-center font-mono leading-none whitespace-pre select-none"
    style:font-size="{size}px"
    style:color
    style:text-shadow={glow ? "0 0 0.5em currentColor" : undefined}>{frame}</pre>
</div>
<script module lang="ts">
  export type AsciiAnimation =
    | "none"
    | "wave"
    | "glitch"
    | "typewriter"
    | "pulse"
    | "scan";

  const GLYPH_HEIGHT = 6;
  const DEFAULT_RAMP = "░▒▓█";

  // Блочный шрифт 8x6. Каждый глиф ровно 8 колонок шириной, чтобы сетка
  // оставалась прямоугольной и поколоночные эффекты совпадали между буквами.
  const FONT: Record<string, string[]> = {
    A: ["  ████  ", " ██  ██ ", "████████", "██    ██", "██    ██", "██    ██"],
    B: ["███████ ", "██    ██", "███████ ", "██    ██", "██    ██", "███████ "],
    C: [" ███████", "██      ", "██      ", "██      ", "██      ", " ███████"],
    D: ["███████ ", "██    ██", "██    ██", "██    ██", "██    ██", "███████ "],
    E: ["████████", "██      ", "██████  ", "██      ", "██      ", "████████"],
    F: ["████████", "██      ", "██████  ", "██      ", "██      ", "██      "],
    G: [" ███████", "██      ", "██  ████", "██    ██", "██    ██", " ███████"],
    H: ["██    ██", "██    ██", "████████", "██    ██", "██    ██", "██    ██"],
    I: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "████████"],
    J: ["████████", "      ██", "      ██", "      ██", "██    ██", " ███████"],
    K: ["██    ██", "██  ██  ", "████    ", "██  ██  ", "██    ██", "██    ██"],
    L: ["██      ", "██      ", "██      ", "██      ", "██      ", "████████"],
    M: ["██    ██", "████████", "██ ██ ██", "██    ██", "██    ██", "██    ██"],
    N: ["██    ██", "███   ██", "██ ██ ██", "██   ███", "██    ██", "██    ██"],
    O: [" ███████", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    P: ["███████ ", "██    ██", "███████ ", "██      ", "██      ", "██      "],
    Q: [" ██████ ", "██    ██", "██    ██", "██ ██ ██", "██   ███", " ███████"],
    R: ["███████ ", "██    ██", "███████ ", "██   ██ ", "██    ██", "██    ██"],
    S: [" ███████", "██      ", " ██████ ", "      ██", "      ██", "███████ "],
    T: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "   ██   "],
    U: ["██    ██", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    V: ["██    ██", "██    ██", "██    ██", " ██  ██ ", "  ████  ", "   ██   "],
    W: ["██    ██", "██    ██", "██    ██", "██ ██ ██", "████████", "██    ██"],
    X: ["██    ██", " ██  ██ ", "  ████  ", "  ████  ", " ██  ██ ", "██    ██"],
    Y: ["██    ██", " ██  ██ ", "  ████  ", "   ██   ", "   ██   ", "   ██   "],
    Z: ["████████", "      ██", "    ██  ", "  ██    ", "██      ", "████████"],
    "0": [" ██████ ", "██    ██", "██    ██", "██    ██", "██    ██", " ██████ "],
    "1": ["   ██   ", "  ███   ", "   ██   ", "   ██   ", "   ██   ", " ██████ "],
    "2": [" ██████ ", "██    ██", "     ██ ", "   ██   ", " ██     ", "████████"],
    "3": [" ██████ ", "██    ██", "    ███ ", "      ██", "██    ██", " ██████ "],
    "4": ["██    ██", "██    ██", "████████", "      ██", "      ██", "      ██"],
    "5": ["████████", "██      ", "███████ ", "      ██", "██    ██", " ██████ "],
    "6": [" ██████ ", "██      ", "███████ ", "██    ██", "██    ██", " ██████ "],
    "7": ["████████", "      ██", "     ██ ", "    ██  ", "   ██   ", "   ██   "],
    "8": [" ██████ ", "██    ██", " ██████ ", "██    ██", "██    ██", " ██████ "],
    "9": [" ██████ ", "██    ██", " ███████", "      ██", "      ██", " ██████ "],
    ".": ["        ", "        ", "        ", "        ", "   ██   ", "   ██   "],
    ",": ["        ", "        ", "        ", "   ██   ", "   ██   ", "  ██    "],
    "!": ["   ██   ", "   ██   ", "   ██   ", "   ██   ", "        ", "   ██   "],
    "?": [" ██████ ", "██    ██", "     ██ ", "   ██   ", "        ", "   ██   "],
    "-": ["        ", "        ", "████████", "        ", "        ", "        "],
    "+": ["        ", "   ██   ", "████████", "   ██   ", "        ", "        "],
    "=": ["        ", "████████", "        ", "████████", "        ", "        "],
    ":": ["        ", "   ██   ", "        ", "        ", "   ██   ", "        "],
    "'": ["   ██   ", "   ██   ", "        ", "        ", "        ", "        "],
    "/": ["      ██", "     ██ ", "    ██  ", "   ██   ", "  ██    ", " ██     "],
    "\\": [
      " ██     ",
      "  ██    ",
      "   ██   ",
      "    ██  ",
      "     ██ ",
      "      ██",
    ],
    "*": ["        ", "██ ██ ██", " ██████ ", "██ ██ ██", "        ", "        "],
    "<": ["     ██ ", "   ██   ", " ██     ", "   ██   ", "     ██ ", "        "],
    ">": [" ██     ", "   ██   ", "     ██ ", "   ██   ", " ██     ", "        "],
    "(": ["    ██  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "    ██  "],
    ")": ["  ██    ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ██    "],
    "[": ["  ████  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "  ████  "],
    "]": ["  ████  ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ████  "],
    "#": [" ██  ██ ", "████████", " ██  ██ ", "████████", " ██  ██ ", "        "],
    "@": [" ██████ ", "██    ██", "██ ██ ██", "██ ████ ", "██      ", " ██████ "],
    _: ["        ", "        ", "        ", "        ", "        ", "████████"],
    " ": ["        ", "        ", "        ", "        ", "        ", "        "],
  };

  /**
   * Раскладывает текст в прямоугольную сетку on/off.
   * Неизвестные символы пропускаются, а не подменяются.
   */
  function buildGrid(text: string, letterSpacing: number): boolean[][] {
    const glyphs = text
      .toUpperCase()
      .split("")
      .map((ch) => FONT[ch])
      .filter((g): g is string[] => Boolean(g));

    if (glyphs.length === 0) return [];

    const gap = " ".repeat(Math.max(0, letterSpacing));
    const rows: boolean[][] = [];

    for (let row = 0; row < GLYPH_HEIGHT; row++) {
      const line = glyphs.map((g) => g[row]).join(gap);
      rows.push(line.split("").map((ch) => ch !== " "));
    }

    return rows;
  }

  /**
   * Чистая функция: рисует один кадр анимации в виде многострочной строки.
   * @param t время в секундах, уже умноженное на speed
   */
  function renderFrame(
    grid: boolean[][],
    mode: AsciiAnimation,
    t: number,
    ramp: string,
  ): string {
    const last = ramp.length - 1;
    const lit = ramp[last] ?? "█";
    const rows = grid.length;
    const width = grid[0]?.length ?? 0;

    const out: string[] = [];

    for (let y = 0; y < rows; y++) {
      let line = "";
      for (let x = 0; x < width; x++) {
        if (!grid[y][x]) {
          line += " ";
          continue;
        }

        switch (mode) {
          case "wave": {
            // Бегущая синусоида выбирает плотность символа из ramp.
            const v = (Math.sin(x * 0.18 - t * 3 + y * 0.25) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "pulse": {
            const v = (Math.sin(t * 2.2) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "scan": {
            // Яркая полоса движется сверху вниз по глифам.
            const band = ((t * 0.6) % 1) * rows;
            const d = Math.abs(y - band);
            const v = Math.max(0, 1 - d / 2);
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "glitch": {
            // Детерминированный хэш, чтобы шум не перемешивался каждый кадр.
            const seed =
              Math.sin(x * 12.9898 + y * 78.233 + Math.floor(t * 8)) *
              43758.5453;
            const n = seed - Math.floor(seed);

            // «Сбойные» ячейки должны быть строго темнее самой яркой ступени,
            // иначе они неотличимы от обычных и эффект не виден.
            if (n > 0.96) {
              line += " ";
            } else if (n > 0.82) {
              const dim = Math.floor(((n - 0.82) / 0.14) * last);
              line +=
                ramp[Math.min(Math.max(last - 1, 0), Math.max(0, dim))] ?? lit;
            } else {
              line += lit;
            }
            break;
          }
          case "typewriter": {
            const revealed = ((t * 12) % (width + 12)) | 0;
            line += x <= revealed ? lit : " ";
            break;
          }
          default:
            line += lit;
        }
      }
      out.push(line);
    }

    return out.join("\n");
  }
</script>

<script lang="ts">
  import type { HTMLAttributes } from "svelte/elements";

  type Props = Omit<HTMLAttributes<HTMLDivElement>, "children" | "color"> & {
    /** Текст для отрисовки. Неподдерживаемые символы пропускаются. */
    text?: string;
    /** Тип анимации глифов. */
    animation?: AsciiAnimation;
    /** Множитель скорости анимации. */
    speed?: number;
    /** Размер глифов в px. Игнорируется при autoFit. */
    fontSize?: number;
    /** Масштабировать глифы так, чтобы строка занимала всю ширину контейнера. */
    autoFit?: boolean;
    /** Пустые колонки между буквами. */
    letterSpacing?: number;
    /** Любой CSS-цвет. По умолчанию наследуется цвет текста. */
    color?: string;
    /** Мягкое свечение за глифами. */
    glow?: boolean;
    /** Символы для «зажжённой» ячейки: от тёмного к светлому. */
    ramp?: string;
  };

  let {
    text = "OPENSOPHY",
    animation = "wave",
    speed = 1,
    fontSize = 14,
    autoFit = true,
    letterSpacing = 2,
    color,
    glow = false,
    ramp = DEFAULT_RAMP,
    class: className,
    ...rest
  }: Props = $props();

  let frame = $state("");
  let containerWidth = $state(0);

  const grid = $derived(buildGrid(text, letterSpacing));
  // Примитивы: эффект перезапускается только при смене формы сетки.
  const rowCount = $derived(grid.length);
  const cols = $derived(grid[0]?.length ?? 0);

  // Моноширинная ячейка ≈ 0.6em шириной.
  const fitSize = $derived(
    autoFit && cols > 0 && containerWidth > 0
      ? Math.max(4, containerWidth / (cols * 0.62))
      : null,
  );
  const size = $derived(autoFit ? (fitSize ?? fontSize) : fontSize);

  $effect(() => {
    // Явные зависимости: форма сетки и тип анимации.
    // speed, ramp и сама сетка читаются внутри rAF-колбэка и не отслеживаются,
    // поэтому смена speed на лету не перезапускает анимацию.
    const mode = animation;
    if (rowCount === 0 || cols === 0) {
      frame = "";
      return;
    }

    const reduceMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    ).matches;

    let raf = 0;
    const start = performance.now();

    const render = (now: number) => {
      const t = reduceMotion ? 0 : ((now - start) / 1000) * speed;
      frame = renderFrame(grid, mode, t, ramp);

      if (mode !== "none" && !reduceMotion) {
        raf = requestAnimationFrame(render);
      }
    };

    raf = requestAnimationFrame(render);
    return () => cancelAnimationFrame(raf);
  });
</script>

<div
  bind:clientWidth={containerWidth}
  class={["w-full overflow-hidden", className]}
  role="img"
  aria-label={text}
  {...rest}
>
  <pre
    aria-hidden="true"
    class="m-0 w-full text-center font-mono leading-none whitespace-pre select-none"
    style:font-size="{size}px"
    style:color
    style:text-shadow={glow ? "0 0 0.5em currentColor" : undefined}>{frame}</pre>
</div>

Варианты

Волна — wave

Плотность символов меняется по синусоидальной волне, которая проходит по сетке.

<script module lang="ts">
  export type AsciiAnimation =
    | "none"
    | "wave"
    | "glitch"
    | "typewriter"
    | "pulse"
    | "scan";

  const GLYPH_HEIGHT = 6;
  const DEFAULT_RAMP = "░▒▓█";

  // Блочный шрифт 8x6. Каждый глиф ровно 8 колонок шириной, чтобы сетка
  // оставалась прямоугольной и поколоночные эффекты совпадали между буквами.
  const FONT: Record<string, string[]> = {
    A: ["  ████  ", " ██  ██ ", "████████", "██    ██", "██    ██", "██    ██"],
    B: ["███████ ", "██    ██", "███████ ", "██    ██", "██    ██", "███████ "],
    C: [" ███████", "██      ", "██      ", "██      ", "██      ", " ███████"],
    D: ["███████ ", "██    ██", "██    ██", "██    ██", "██    ██", "███████ "],
    E: ["████████", "██      ", "██████  ", "██      ", "██      ", "████████"],
    F: ["████████", "██      ", "██████  ", "██      ", "██      ", "██      "],
    G: [" ███████", "██      ", "██  ████", "██    ██", "██    ██", " ███████"],
    H: ["██    ██", "██    ██", "████████", "██    ██", "██    ██", "██    ██"],
    I: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "████████"],
    J: ["████████", "      ██", "      ██", "      ██", "██    ██", " ███████"],
    K: ["██    ██", "██  ██  ", "████    ", "██  ██  ", "██    ██", "██    ██"],
    L: ["██      ", "██      ", "██      ", "██      ", "██      ", "████████"],
    M: ["██    ██", "████████", "██ ██ ██", "██    ██", "██    ██", "██    ██"],
    N: ["██    ██", "███   ██", "██ ██ ██", "██   ███", "██    ██", "██    ██"],
    O: [" ███████", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    P: ["███████ ", "██    ██", "███████ ", "██      ", "██      ", "██      "],
    Q: [" ██████ ", "██    ██", "██    ██", "██ ██ ██", "██   ███", " ███████"],
    R: ["███████ ", "██    ██", "███████ ", "██   ██ ", "██    ██", "██    ██"],
    S: [" ███████", "██      ", " ██████ ", "      ██", "      ██", "███████ "],
    T: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "   ██   "],
    U: ["██    ██", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    V: ["██    ██", "██    ██", "██    ██", " ██  ██ ", "  ████  ", "   ██   "],
    W: ["██    ██", "██    ██", "██    ██", "██ ██ ██", "████████", "██    ██"],
    X: ["██    ██", " ██  ██ ", "  ████  ", "  ████  ", " ██  ██ ", "██    ██"],
    Y: ["██    ██", " ██  ██ ", "  ████  ", "   ██   ", "   ██   ", "   ██   "],
    Z: ["████████", "      ██", "    ██  ", "  ██    ", "██      ", "████████"],
    "0": [" ██████ ", "██    ██", "██    ██", "██    ██", "██    ██", " ██████ "],
    "1": ["   ██   ", "  ███   ", "   ██   ", "   ██   ", "   ██   ", " ██████ "],
    "2": [" ██████ ", "██    ██", "     ██ ", "   ██   ", " ██     ", "████████"],
    "3": [" ██████ ", "██    ██", "    ███ ", "      ██", "██    ██", " ██████ "],
    "4": ["██    ██", "██    ██", "████████", "      ██", "      ██", "      ██"],
    "5": ["████████", "██      ", "███████ ", "      ██", "██    ██", " ██████ "],
    "6": [" ██████ ", "██      ", "███████ ", "██    ██", "██    ██", " ██████ "],
    "7": ["████████", "      ██", "     ██ ", "    ██  ", "   ██   ", "   ██   "],
    "8": [" ██████ ", "██    ██", " ██████ ", "██    ██", "██    ██", " ██████ "],
    "9": [" ██████ ", "██    ██", " ███████", "      ██", "      ██", " ██████ "],
    ".": ["        ", "        ", "        ", "        ", "   ██   ", "   ██   "],
    ",": ["        ", "        ", "        ", "   ██   ", "   ██   ", "  ██    "],
    "!": ["   ██   ", "   ██   ", "   ██   ", "   ██   ", "        ", "   ██   "],
    "?": [" ██████ ", "██    ██", "     ██ ", "   ██   ", "        ", "   ██   "],
    "-": ["        ", "        ", "████████", "        ", "        ", "        "],
    "+": ["        ", "   ██   ", "████████", "   ██   ", "        ", "        "],
    "=": ["        ", "████████", "        ", "████████", "        ", "        "],
    ":": ["        ", "   ██   ", "        ", "        ", "   ██   ", "        "],
    "'": ["   ██   ", "   ██   ", "        ", "        ", "        ", "        "],
    "/": ["      ██", "     ██ ", "    ██  ", "   ██   ", "  ██    ", " ██     "],
    "\\": [
      " ██     ",
      "  ██    ",
      "   ██   ",
      "    ██  ",
      "     ██ ",
      "      ██",
    ],
    "*": ["        ", "██ ██ ██", " ██████ ", "██ ██ ██", "        ", "        "],
    "<": ["     ██ ", "   ██   ", " ██     ", "   ██   ", "     ██ ", "        "],
    ">": [" ██     ", "   ██   ", "     ██ ", "   ██   ", " ██     ", "        "],
    "(": ["    ██  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "    ██  "],
    ")": ["  ██    ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ██    "],
    "[": ["  ████  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "  ████  "],
    "]": ["  ████  ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ████  "],
    "#": [" ██  ██ ", "████████", " ██  ██ ", "████████", " ██  ██ ", "        "],
    "@": [" ██████ ", "██    ██", "██ ██ ██", "██ ████ ", "██      ", " ██████ "],
    _: ["        ", "        ", "        ", "        ", "        ", "████████"],
    " ": ["        ", "        ", "        ", "        ", "        ", "        "],
  };

  /**
   * Раскладывает текст в прямоугольную сетку on/off.
   * Неизвестные символы пропускаются, а не подменяются.
   */
  function buildGrid(text: string, letterSpacing: number): boolean[][] {
    const glyphs = text
      .toUpperCase()
      .split("")
      .map((ch) => FONT[ch])
      .filter((g): g is string[] => Boolean(g));

    if (glyphs.length === 0) return [];

    const gap = " ".repeat(Math.max(0, letterSpacing));
    const rows: boolean[][] = [];

    for (let row = 0; row < GLYPH_HEIGHT; row++) {
      const line = glyphs.map((g) => g[row]).join(gap);
      rows.push(line.split("").map((ch) => ch !== " "));
    }

    return rows;
  }

  /**
   * Чистая функция: рисует один кадр анимации в виде многострочной строки.
   * @param t время в секундах, уже умноженное на speed
   */
  function renderFrame(
    grid: boolean[][],
    mode: AsciiAnimation,
    t: number,
    ramp: string,
  ): string {
    const last = ramp.length - 1;
    const lit = ramp[last] ?? "█";
    const rows = grid.length;
    const width = grid[0]?.length ?? 0;

    const out: string[] = [];

    for (let y = 0; y < rows; y++) {
      let line = "";
      for (let x = 0; x < width; x++) {
        if (!grid[y][x]) {
          line += " ";
          continue;
        }

        switch (mode) {
          case "wave": {
            // Бегущая синусоида выбирает плотность символа из ramp.
            const v = (Math.sin(x * 0.18 - t * 3 + y * 0.25) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "pulse": {
            const v = (Math.sin(t * 2.2) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "scan": {
            // Яркая полоса движется сверху вниз по глифам.
            const band = ((t * 0.6) % 1) * rows;
            const d = Math.abs(y - band);
            const v = Math.max(0, 1 - d / 2);
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "glitch": {
            // Детерминированный хэш, чтобы шум не перемешивался каждый кадр.
            const seed =
              Math.sin(x * 12.9898 + y * 78.233 + Math.floor(t * 8)) *
              43758.5453;
            const n = seed - Math.floor(seed);

            // «Сбойные» ячейки должны быть строго темнее самой яркой ступени,
            // иначе они неотличимы от обычных и эффект не виден.
            if (n > 0.96) {
              line += " ";
            } else if (n > 0.82) {
              const dim = Math.floor(((n - 0.82) / 0.14) * last);
              line +=
                ramp[Math.min(Math.max(last - 1, 0), Math.max(0, dim))] ?? lit;
            } else {
              line += lit;
            }
            break;
          }
          case "typewriter": {
            const revealed = ((t * 12) % (width + 12)) | 0;
            line += x <= revealed ? lit : " ";
            break;
          }
          default:
            line += lit;
        }
      }
      out.push(line);
    }

    return out.join("\n");
  }
</script>

<script lang="ts">
  import type { HTMLAttributes } from "svelte/elements";

  type Props = Omit<HTMLAttributes<HTMLDivElement>, "children" | "color"> & {
    /** Текст для отрисовки. Неподдерживаемые символы пропускаются. */
    text?: string;
    /** Тип анимации глифов. */
    animation?: AsciiAnimation;
    /** Множитель скорости анимации. */
    speed?: number;
    /** Размер глифов в px. Игнорируется при autoFit. */
    fontSize?: number;
    /** Масштабировать глифы так, чтобы строка занимала всю ширину контейнера. */
    autoFit?: boolean;
    /** Пустые колонки между буквами. */
    letterSpacing?: number;
    /** Любой CSS-цвет. По умолчанию наследуется цвет текста. */
    color?: string;
    /** Мягкое свечение за глифами. */
    glow?: boolean;
    /** Символы для «зажжённой» ячейки: от тёмного к светлому. */
    ramp?: string;
  };

  let {
    text = "OPENSOPHY",
    animation = "wave",
    speed = 1,
    fontSize = 14,
    autoFit = true,
    letterSpacing = 2,
    color,
    glow = false,
    ramp = DEFAULT_RAMP,
    class: className,
    ...rest
  }: Props = $props();

  let frame = $state("");
  let containerWidth = $state(0);

  const grid = $derived(buildGrid(text, letterSpacing));
  // Примитивы: эффект перезапускается только при смене формы сетки.
  const rowCount = $derived(grid.length);
  const cols = $derived(grid[0]?.length ?? 0);

  // Моноширинная ячейка ≈ 0.6em шириной.
  const fitSize = $derived(
    autoFit && cols > 0 && containerWidth > 0
      ? Math.max(4, containerWidth / (cols * 0.62))
      : null,
  );
  const size = $derived(autoFit ? (fitSize ?? fontSize) : fontSize);

  $effect(() => {
    // Явные зависимости: форма сетки и тип анимации.
    // speed, ramp и сама сетка читаются внутри rAF-колбэка и не отслеживаются,
    // поэтому смена speed на лету не перезапускает анимацию.
    const mode = animation;
    if (rowCount === 0 || cols === 0) {
      frame = "";
      return;
    }

    const reduceMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    ).matches;

    let raf = 0;
    const start = performance.now();

    const render = (now: number) => {
      const t = reduceMotion ? 0 : ((now - start) / 1000) * speed;
      frame = renderFrame(grid, mode, t, ramp);

      if (mode !== "none" && !reduceMotion) {
        raf = requestAnimationFrame(render);
      }
    };

    raf = requestAnimationFrame(render);
    return () => cancelAnimationFrame(raf);
  });
</script>

<div
  bind:clientWidth={containerWidth}
  class={["w-full overflow-hidden", className]}
  role="img"
  aria-label={text}
  {...rest}
>
  <pre
    aria-hidden="true"
    class="m-0 w-full text-center font-mono leading-none whitespace-pre select-none"
    style:font-size="{size}px"
    style:color
    style:text-shadow={glow ? "0 0 0.5em currentColor" : undefined}>{frame}</pre>
</div>
<script module lang="ts">
  export type AsciiAnimation =
    | "none"
    | "wave"
    | "glitch"
    | "typewriter"
    | "pulse"
    | "scan";

  const GLYPH_HEIGHT = 6;
  const DEFAULT_RAMP = "░▒▓█";

  // Блочный шрифт 8x6. Каждый глиф ровно 8 колонок шириной, чтобы сетка
  // оставалась прямоугольной и поколоночные эффекты совпадали между буквами.
  const FONT: Record<string, string[]> = {
    A: ["  ████  ", " ██  ██ ", "████████", "██    ██", "██    ██", "██    ██"],
    B: ["███████ ", "██    ██", "███████ ", "██    ██", "██    ██", "███████ "],
    C: [" ███████", "██      ", "██      ", "██      ", "██      ", " ███████"],
    D: ["███████ ", "██    ██", "██    ██", "██    ██", "██    ██", "███████ "],
    E: ["████████", "██      ", "██████  ", "██      ", "██      ", "████████"],
    F: ["████████", "██      ", "██████  ", "██      ", "██      ", "██      "],
    G: [" ███████", "██      ", "██  ████", "██    ██", "██    ██", " ███████"],
    H: ["██    ██", "██    ██", "████████", "██    ██", "██    ██", "██    ██"],
    I: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "████████"],
    J: ["████████", "      ██", "      ██", "      ██", "██    ██", " ███████"],
    K: ["██    ██", "██  ██  ", "████    ", "██  ██  ", "██    ██", "██    ██"],
    L: ["██      ", "██      ", "██      ", "██      ", "██      ", "████████"],
    M: ["██    ██", "████████", "██ ██ ██", "██    ██", "██    ██", "██    ██"],
    N: ["██    ██", "███   ██", "██ ██ ██", "██   ███", "██    ██", "██    ██"],
    O: [" ███████", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    P: ["███████ ", "██    ██", "███████ ", "██      ", "██      ", "██      "],
    Q: [" ██████ ", "██    ██", "██    ██", "██ ██ ██", "██   ███", " ███████"],
    R: ["███████ ", "██    ██", "███████ ", "██   ██ ", "██    ██", "██    ██"],
    S: [" ███████", "██      ", " ██████ ", "      ██", "      ██", "███████ "],
    T: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "   ██   "],
    U: ["██    ██", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    V: ["██    ██", "██    ██", "██    ██", " ██  ██ ", "  ████  ", "   ██   "],
    W: ["██    ██", "██    ██", "██    ██", "██ ██ ██", "████████", "██    ██"],
    X: ["██    ██", " ██  ██ ", "  ████  ", "  ████  ", " ██  ██ ", "██    ██"],
    Y: ["██    ██", " ██  ██ ", "  ████  ", "   ██   ", "   ██   ", "   ██   "],
    Z: ["████████", "      ██", "    ██  ", "  ██    ", "██      ", "████████"],
    "0": [" ██████ ", "██    ██", "██    ██", "██    ██", "██    ██", " ██████ "],
    "1": ["   ██   ", "  ███   ", "   ██   ", "   ██   ", "   ██   ", " ██████ "],
    "2": [" ██████ ", "██    ██", "     ██ ", "   ██   ", " ██     ", "████████"],
    "3": [" ██████ ", "██    ██", "    ███ ", "      ██", "██    ██", " ██████ "],
    "4": ["██    ██", "██    ██", "████████", "      ██", "      ██", "      ██"],
    "5": ["████████", "██      ", "███████ ", "      ██", "██    ██", " ██████ "],
    "6": [" ██████ ", "██      ", "███████ ", "██    ██", "██    ██", " ██████ "],
    "7": ["████████", "      ██", "     ██ ", "    ██  ", "   ██   ", "   ██   "],
    "8": [" ██████ ", "██    ██", " ██████ ", "██    ██", "██    ██", " ██████ "],
    "9": [" ██████ ", "██    ██", " ███████", "      ██", "      ██", " ██████ "],
    ".": ["        ", "        ", "        ", "        ", "   ██   ", "   ██   "],
    ",": ["        ", "        ", "        ", "   ██   ", "   ██   ", "  ██    "],
    "!": ["   ██   ", "   ██   ", "   ██   ", "   ██   ", "        ", "   ██   "],
    "?": [" ██████ ", "██    ██", "     ██ ", "   ██   ", "        ", "   ██   "],
    "-": ["        ", "        ", "████████", "        ", "        ", "        "],
    "+": ["        ", "   ██   ", "████████", "   ██   ", "        ", "        "],
    "=": ["        ", "████████", "        ", "████████", "        ", "        "],
    ":": ["        ", "   ██   ", "        ", "        ", "   ██   ", "        "],
    "'": ["   ██   ", "   ██   ", "        ", "        ", "        ", "        "],
    "/": ["      ██", "     ██ ", "    ██  ", "   ██   ", "  ██    ", " ██     "],
    "\\": [
      " ██     ",
      "  ██    ",
      "   ██   ",
      "    ██  ",
      "     ██ ",
      "      ██",
    ],
    "*": ["        ", "██ ██ ██", " ██████ ", "██ ██ ██", "        ", "        "],
    "<": ["     ██ ", "   ██   ", " ██     ", "   ██   ", "     ██ ", "        "],
    ">": [" ██     ", "   ██   ", "     ██ ", "   ██   ", " ██     ", "        "],
    "(": ["    ██  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "    ██  "],
    ")": ["  ██    ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ██    "],
    "[": ["  ████  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "  ████  "],
    "]": ["  ████  ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ████  "],
    "#": [" ██  ██ ", "████████", " ██  ██ ", "████████", " ██  ██ ", "        "],
    "@": [" ██████ ", "██    ██", "██ ██ ██", "██ ████ ", "██      ", " ██████ "],
    _: ["        ", "        ", "        ", "        ", "        ", "████████"],
    " ": ["        ", "        ", "        ", "        ", "        ", "        "],
  };

  /**
   * Раскладывает текст в прямоугольную сетку on/off.
   * Неизвестные символы пропускаются, а не подменяются.
   */
  function buildGrid(text: string, letterSpacing: number): boolean[][] {
    const glyphs = text
      .toUpperCase()
      .split("")
      .map((ch) => FONT[ch])
      .filter((g): g is string[] => Boolean(g));

    if (glyphs.length === 0) return [];

    const gap = " ".repeat(Math.max(0, letterSpacing));
    const rows: boolean[][] = [];

    for (let row = 0; row < GLYPH_HEIGHT; row++) {
      const line = glyphs.map((g) => g[row]).join(gap);
      rows.push(line.split("").map((ch) => ch !== " "));
    }

    return rows;
  }

  /**
   * Чистая функция: рисует один кадр анимации в виде многострочной строки.
   * @param t время в секундах, уже умноженное на speed
   */
  function renderFrame(
    grid: boolean[][],
    mode: AsciiAnimation,
    t: number,
    ramp: string,
  ): string {
    const last = ramp.length - 1;
    const lit = ramp[last] ?? "█";
    const rows = grid.length;
    const width = grid[0]?.length ?? 0;

    const out: string[] = [];

    for (let y = 0; y < rows; y++) {
      let line = "";
      for (let x = 0; x < width; x++) {
        if (!grid[y][x]) {
          line += " ";
          continue;
        }

        switch (mode) {
          case "wave": {
            // Бегущая синусоида выбирает плотность символа из ramp.
            const v = (Math.sin(x * 0.18 - t * 3 + y * 0.25) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "pulse": {
            const v = (Math.sin(t * 2.2) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "scan": {
            // Яркая полоса движется сверху вниз по глифам.
            const band = ((t * 0.6) % 1) * rows;
            const d = Math.abs(y - band);
            const v = Math.max(0, 1 - d / 2);
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "glitch": {
            // Детерминированный хэш, чтобы шум не перемешивался каждый кадр.
            const seed =
              Math.sin(x * 12.9898 + y * 78.233 + Math.floor(t * 8)) *
              43758.5453;
            const n = seed - Math.floor(seed);

            // «Сбойные» ячейки должны быть строго темнее самой яркой ступени,
            // иначе они неотличимы от обычных и эффект не виден.
            if (n > 0.96) {
              line += " ";
            } else if (n > 0.82) {
              const dim = Math.floor(((n - 0.82) / 0.14) * last);
              line +=
                ramp[Math.min(Math.max(last - 1, 0), Math.max(0, dim))] ?? lit;
            } else {
              line += lit;
            }
            break;
          }
          case "typewriter": {
            const revealed = ((t * 12) % (width + 12)) | 0;
            line += x <= revealed ? lit : " ";
            break;
          }
          default:
            line += lit;
        }
      }
      out.push(line);
    }

    return out.join("\n");
  }
</script>

<script lang="ts">
  import type { HTMLAttributes } from "svelte/elements";

  type Props = Omit<HTMLAttributes<HTMLDivElement>, "children" | "color"> & {
    /** Текст для отрисовки. Неподдерживаемые символы пропускаются. */
    text?: string;
    /** Тип анимации глифов. */
    animation?: AsciiAnimation;
    /** Множитель скорости анимации. */
    speed?: number;
    /** Размер глифов в px. Игнорируется при autoFit. */
    fontSize?: number;
    /** Масштабировать глифы так, чтобы строка занимала всю ширину контейнера. */
    autoFit?: boolean;
    /** Пустые колонки между буквами. */
    letterSpacing?: number;
    /** Любой CSS-цвет. По умолчанию наследуется цвет текста. */
    color?: string;
    /** Мягкое свечение за глифами. */
    glow?: boolean;
    /** Символы для «зажжённой» ячейки: от тёмного к светлому. */
    ramp?: string;
  };

  let {
    text = "OPENSOPHY",
    animation = "wave",
    speed = 1,
    fontSize = 14,
    autoFit = true,
    letterSpacing = 2,
    color,
    glow = false,
    ramp = DEFAULT_RAMP,
    class: className,
    ...rest
  }: Props = $props();

  let frame = $state("");
  let containerWidth = $state(0);

  const grid = $derived(buildGrid(text, letterSpacing));
  // Примитивы: эффект перезапускается только при смене формы сетки.
  const rowCount = $derived(grid.length);
  const cols = $derived(grid[0]?.length ?? 0);

  // Моноширинная ячейка ≈ 0.6em шириной.
  const fitSize = $derived(
    autoFit && cols > 0 && containerWidth > 0
      ? Math.max(4, containerWidth / (cols * 0.62))
      : null,
  );
  const size = $derived(autoFit ? (fitSize ?? fontSize) : fontSize);

  $effect(() => {
    // Явные зависимости: форма сетки и тип анимации.
    // speed, ramp и сама сетка читаются внутри rAF-колбэка и не отслеживаются,
    // поэтому смена speed на лету не перезапускает анимацию.
    const mode = animation;
    if (rowCount === 0 || cols === 0) {
      frame = "";
      return;
    }

    const reduceMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    ).matches;

    let raf = 0;
    const start = performance.now();

    const render = (now: number) => {
      const t = reduceMotion ? 0 : ((now - start) / 1000) * speed;
      frame = renderFrame(grid, mode, t, ramp);

      if (mode !== "none" && !reduceMotion) {
        raf = requestAnimationFrame(render);
      }
    };

    raf = requestAnimationFrame(render);
    return () => cancelAnimationFrame(raf);
  });
</script>

<div
  bind:clientWidth={containerWidth}
  class={["w-full overflow-hidden", className]}
  role="img"
  aria-label={text}
  {...rest}
>
  <pre
    aria-hidden="true"
    class="m-0 w-full text-center font-mono leading-none whitespace-pre select-none"
    style:font-size="{size}px"
    style:color
    style:text-shadow={glow ? "0 0 0.5em currentColor" : undefined}>{frame}</pre>
</div>

Сканирование — scan

Яркая полоса проходит сверху вниз по строкам глифов.

<script module lang="ts">
  export type AsciiAnimation =
    | "none"
    | "wave"
    | "glitch"
    | "typewriter"
    | "pulse"
    | "scan";

  const GLYPH_HEIGHT = 6;
  const DEFAULT_RAMP = "░▒▓█";

  // Блочный шрифт 8x6. Каждый глиф ровно 8 колонок шириной, чтобы сетка
  // оставалась прямоугольной и поколоночные эффекты совпадали между буквами.
  const FONT: Record<string, string[]> = {
    A: ["  ████  ", " ██  ██ ", "████████", "██    ██", "██    ██", "██    ██"],
    B: ["███████ ", "██    ██", "███████ ", "██    ██", "██    ██", "███████ "],
    C: [" ███████", "██      ", "██      ", "██      ", "██      ", " ███████"],
    D: ["███████ ", "██    ██", "██    ██", "██    ██", "██    ██", "███████ "],
    E: ["████████", "██      ", "██████  ", "██      ", "██      ", "████████"],
    F: ["████████", "██      ", "██████  ", "██      ", "██      ", "██      "],
    G: [" ███████", "██      ", "██  ████", "██    ██", "██    ██", " ███████"],
    H: ["██    ██", "██    ██", "████████", "██    ██", "██    ██", "██    ██"],
    I: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "████████"],
    J: ["████████", "      ██", "      ██", "      ██", "██    ██", " ███████"],
    K: ["██    ██", "██  ██  ", "████    ", "██  ██  ", "██    ██", "██    ██"],
    L: ["██      ", "██      ", "██      ", "██      ", "██      ", "████████"],
    M: ["██    ██", "████████", "██ ██ ██", "██    ██", "██    ██", "██    ██"],
    N: ["██    ██", "███   ██", "██ ██ ██", "██   ███", "██    ██", "██    ██"],
    O: [" ███████", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    P: ["███████ ", "██    ██", "███████ ", "██      ", "██      ", "██      "],
    Q: [" ██████ ", "██    ██", "██    ██", "██ ██ ██", "██   ███", " ███████"],
    R: ["███████ ", "██    ██", "███████ ", "██   ██ ", "██    ██", "██    ██"],
    S: [" ███████", "██      ", " ██████ ", "      ██", "      ██", "███████ "],
    T: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "   ██   "],
    U: ["██    ██", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    V: ["██    ██", "██    ██", "██    ██", " ██  ██ ", "  ████  ", "   ██   "],
    W: ["██    ██", "██    ██", "██    ██", "██ ██ ██", "████████", "██    ██"],
    X: ["██    ██", " ██  ██ ", "  ████  ", "  ████  ", " ██  ██ ", "██    ██"],
    Y: ["██    ██", " ██  ██ ", "  ████  ", "   ██   ", "   ██   ", "   ██   "],
    Z: ["████████", "      ██", "    ██  ", "  ██    ", "██      ", "████████"],
    "0": [" ██████ ", "██    ██", "██    ██", "██    ██", "██    ██", " ██████ "],
    "1": ["   ██   ", "  ███   ", "   ██   ", "   ██   ", "   ██   ", " ██████ "],
    "2": [" ██████ ", "██    ██", "     ██ ", "   ██   ", " ██     ", "████████"],
    "3": [" ██████ ", "██    ██", "    ███ ", "      ██", "██    ██", " ██████ "],
    "4": ["██    ██", "██    ██", "████████", "      ██", "      ██", "      ██"],
    "5": ["████████", "██      ", "███████ ", "      ██", "██    ██", " ██████ "],
    "6": [" ██████ ", "██      ", "███████ ", "██    ██", "██    ██", " ██████ "],
    "7": ["████████", "      ██", "     ██ ", "    ██  ", "   ██   ", "   ██   "],
    "8": [" ██████ ", "██    ██", " ██████ ", "██    ██", "██    ██", " ██████ "],
    "9": [" ██████ ", "██    ██", " ███████", "      ██", "      ██", " ██████ "],
    ".": ["        ", "        ", "        ", "        ", "   ██   ", "   ██   "],
    ",": ["        ", "        ", "        ", "   ██   ", "   ██   ", "  ██    "],
    "!": ["   ██   ", "   ██   ", "   ██   ", "   ██   ", "        ", "   ██   "],
    "?": [" ██████ ", "██    ██", "     ██ ", "   ██   ", "        ", "   ██   "],
    "-": ["        ", "        ", "████████", "        ", "        ", "        "],
    "+": ["        ", "   ██   ", "████████", "   ██   ", "        ", "        "],
    "=": ["        ", "████████", "        ", "████████", "        ", "        "],
    ":": ["        ", "   ██   ", "        ", "        ", "   ██   ", "        "],
    "'": ["   ██   ", "   ██   ", "        ", "        ", "        ", "        "],
    "/": ["      ██", "     ██ ", "    ██  ", "   ██   ", "  ██    ", " ██     "],
    "\\": [
      " ██     ",
      "  ██    ",
      "   ██   ",
      "    ██  ",
      "     ██ ",
      "      ██",
    ],
    "*": ["        ", "██ ██ ██", " ██████ ", "██ ██ ██", "        ", "        "],
    "<": ["     ██ ", "   ██   ", " ██     ", "   ██   ", "     ██ ", "        "],
    ">": [" ██     ", "   ██   ", "     ██ ", "   ██   ", " ██     ", "        "],
    "(": ["    ██  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "    ██  "],
    ")": ["  ██    ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ██    "],
    "[": ["  ████  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "  ████  "],
    "]": ["  ████  ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ████  "],
    "#": [" ██  ██ ", "████████", " ██  ██ ", "████████", " ██  ██ ", "        "],
    "@": [" ██████ ", "██    ██", "██ ██ ██", "██ ████ ", "██      ", " ██████ "],
    _: ["        ", "        ", "        ", "        ", "        ", "████████"],
    " ": ["        ", "        ", "        ", "        ", "        ", "        "],
  };

  /**
   * Раскладывает текст в прямоугольную сетку on/off.
   * Неизвестные символы пропускаются, а не подменяются.
   */
  function buildGrid(text: string, letterSpacing: number): boolean[][] {
    const glyphs = text
      .toUpperCase()
      .split("")
      .map((ch) => FONT[ch])
      .filter((g): g is string[] => Boolean(g));

    if (glyphs.length === 0) return [];

    const gap = " ".repeat(Math.max(0, letterSpacing));
    const rows: boolean[][] = [];

    for (let row = 0; row < GLYPH_HEIGHT; row++) {
      const line = glyphs.map((g) => g[row]).join(gap);
      rows.push(line.split("").map((ch) => ch !== " "));
    }

    return rows;
  }

  /**
   * Чистая функция: рисует один кадр анимации в виде многострочной строки.
   * @param t время в секундах, уже умноженное на speed
   */
  function renderFrame(
    grid: boolean[][],
    mode: AsciiAnimation,
    t: number,
    ramp: string,
  ): string {
    const last = ramp.length - 1;
    const lit = ramp[last] ?? "█";
    const rows = grid.length;
    const width = grid[0]?.length ?? 0;

    const out: string[] = [];

    for (let y = 0; y < rows; y++) {
      let line = "";
      for (let x = 0; x < width; x++) {
        if (!grid[y][x]) {
          line += " ";
          continue;
        }

        switch (mode) {
          case "wave": {
            // Бегущая синусоида выбирает плотность символа из ramp.
            const v = (Math.sin(x * 0.18 - t * 3 + y * 0.25) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "pulse": {
            const v = (Math.sin(t * 2.2) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "scan": {
            // Яркая полоса движется сверху вниз по глифам.
            const band = ((t * 0.6) % 1) * rows;
            const d = Math.abs(y - band);
            const v = Math.max(0, 1 - d / 2);
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "glitch": {
            // Детерминированный хэш, чтобы шум не перемешивался каждый кадр.
            const seed =
              Math.sin(x * 12.9898 + y * 78.233 + Math.floor(t * 8)) *
              43758.5453;
            const n = seed - Math.floor(seed);

            // «Сбойные» ячейки должны быть строго темнее самой яркой ступени,
            // иначе они неотличимы от обычных и эффект не виден.
            if (n > 0.96) {
              line += " ";
            } else if (n > 0.82) {
              const dim = Math.floor(((n - 0.82) / 0.14) * last);
              line +=
                ramp[Math.min(Math.max(last - 1, 0), Math.max(0, dim))] ?? lit;
            } else {
              line += lit;
            }
            break;
          }
          case "typewriter": {
            const revealed = ((t * 12) % (width + 12)) | 0;
            line += x <= revealed ? lit : " ";
            break;
          }
          default:
            line += lit;
        }
      }
      out.push(line);
    }

    return out.join("\n");
  }
</script>

<script lang="ts">
  import type { HTMLAttributes } from "svelte/elements";

  type Props = Omit<HTMLAttributes<HTMLDivElement>, "children" | "color"> & {
    /** Текст для отрисовки. Неподдерживаемые символы пропускаются. */
    text?: string;
    /** Тип анимации глифов. */
    animation?: AsciiAnimation;
    /** Множитель скорости анимации. */
    speed?: number;
    /** Размер глифов в px. Игнорируется при autoFit. */
    fontSize?: number;
    /** Масштабировать глифы так, чтобы строка занимала всю ширину контейнера. */
    autoFit?: boolean;
    /** Пустые колонки между буквами. */
    letterSpacing?: number;
    /** Любой CSS-цвет. По умолчанию наследуется цвет текста. */
    color?: string;
    /** Мягкое свечение за глифами. */
    glow?: boolean;
    /** Символы для «зажжённой» ячейки: от тёмного к светлому. */
    ramp?: string;
  };

  let {
    text = "OPENSOPHY",
    animation = "wave",
    speed = 1,
    fontSize = 14,
    autoFit = true,
    letterSpacing = 2,
    color,
    glow = false,
    ramp = DEFAULT_RAMP,
    class: className,
    ...rest
  }: Props = $props();

  let frame = $state("");
  let containerWidth = $state(0);

  const grid = $derived(buildGrid(text, letterSpacing));
  // Примитивы: эффект перезапускается только при смене формы сетки.
  const rowCount = $derived(grid.length);
  const cols = $derived(grid[0]?.length ?? 0);

  // Моноширинная ячейка ≈ 0.6em шириной.
  const fitSize = $derived(
    autoFit && cols > 0 && containerWidth > 0
      ? Math.max(4, containerWidth / (cols * 0.62))
      : null,
  );
  const size = $derived(autoFit ? (fitSize ?? fontSize) : fontSize);

  $effect(() => {
    // Явные зависимости: форма сетки и тип анимации.
    // speed, ramp и сама сетка читаются внутри rAF-колбэка и не отслеживаются,
    // поэтому смена speed на лету не перезапускает анимацию.
    const mode = animation;
    if (rowCount === 0 || cols === 0) {
      frame = "";
      return;
    }

    const reduceMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    ).matches;

    let raf = 0;
    const start = performance.now();

    const render = (now: number) => {
      const t = reduceMotion ? 0 : ((now - start) / 1000) * speed;
      frame = renderFrame(grid, mode, t, ramp);

      if (mode !== "none" && !reduceMotion) {
        raf = requestAnimationFrame(render);
      }
    };

    raf = requestAnimationFrame(render);
    return () => cancelAnimationFrame(raf);
  });
</script>

<div
  bind:clientWidth={containerWidth}
  class={["w-full overflow-hidden", className]}
  role="img"
  aria-label={text}
  {...rest}
>
  <pre
    aria-hidden="true"
    class="m-0 w-full text-center font-mono leading-none whitespace-pre select-none"
    style:font-size="{size}px"
    style:color
    style:text-shadow={glow ? "0 0 0.5em currentColor" : undefined}>{frame}</pre>
</div>
<script module lang="ts">
  export type AsciiAnimation =
    | "none"
    | "wave"
    | "glitch"
    | "typewriter"
    | "pulse"
    | "scan";

  const GLYPH_HEIGHT = 6;
  const DEFAULT_RAMP = "░▒▓█";

  // Блочный шрифт 8x6. Каждый глиф ровно 8 колонок шириной, чтобы сетка
  // оставалась прямоугольной и поколоночные эффекты совпадали между буквами.
  const FONT: Record<string, string[]> = {
    A: ["  ████  ", " ██  ██ ", "████████", "██    ██", "██    ██", "██    ██"],
    B: ["███████ ", "██    ██", "███████ ", "██    ██", "██    ██", "███████ "],
    C: [" ███████", "██      ", "██      ", "██      ", "██      ", " ███████"],
    D: ["███████ ", "██    ██", "██    ██", "██    ██", "██    ██", "███████ "],
    E: ["████████", "██      ", "██████  ", "██      ", "██      ", "████████"],
    F: ["████████", "██      ", "██████  ", "██      ", "██      ", "██      "],
    G: [" ███████", "██      ", "██  ████", "██    ██", "██    ██", " ███████"],
    H: ["██    ██", "██    ██", "████████", "██    ██", "██    ██", "██    ██"],
    I: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "████████"],
    J: ["████████", "      ██", "      ██", "      ██", "██    ██", " ███████"],
    K: ["██    ██", "██  ██  ", "████    ", "██  ██  ", "██    ██", "██    ██"],
    L: ["██      ", "██      ", "██      ", "██      ", "██      ", "████████"],
    M: ["██    ██", "████████", "██ ██ ██", "██    ██", "██    ██", "██    ██"],
    N: ["██    ██", "███   ██", "██ ██ ██", "██   ███", "██    ██", "██    ██"],
    O: [" ███████", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    P: ["███████ ", "██    ██", "███████ ", "██      ", "██      ", "██      "],
    Q: [" ██████ ", "██    ██", "██    ██", "██ ██ ██", "██   ███", " ███████"],
    R: ["███████ ", "██    ██", "███████ ", "██   ██ ", "██    ██", "██    ██"],
    S: [" ███████", "██      ", " ██████ ", "      ██", "      ██", "███████ "],
    T: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "   ██   "],
    U: ["██    ██", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    V: ["██    ██", "██    ██", "██    ██", " ██  ██ ", "  ████  ", "   ██   "],
    W: ["██    ██", "██    ██", "██    ██", "██ ██ ██", "████████", "██    ██"],
    X: ["██    ██", " ██  ██ ", "  ████  ", "  ████  ", " ██  ██ ", "██    ██"],
    Y: ["██    ██", " ██  ██ ", "  ████  ", "   ██   ", "   ██   ", "   ██   "],
    Z: ["████████", "      ██", "    ██  ", "  ██    ", "██      ", "████████"],
    "0": [" ██████ ", "██    ██", "██    ██", "██    ██", "██    ██", " ██████ "],
    "1": ["   ██   ", "  ███   ", "   ██   ", "   ██   ", "   ██   ", " ██████ "],
    "2": [" ██████ ", "██    ██", "     ██ ", "   ██   ", " ██     ", "████████"],
    "3": [" ██████ ", "██    ██", "    ███ ", "      ██", "██    ██", " ██████ "],
    "4": ["██    ██", "██    ██", "████████", "      ██", "      ██", "      ██"],
    "5": ["████████", "██      ", "███████ ", "      ██", "██    ██", " ██████ "],
    "6": [" ██████ ", "██      ", "███████ ", "██    ██", "██    ██", " ██████ "],
    "7": ["████████", "      ██", "     ██ ", "    ██  ", "   ██   ", "   ██   "],
    "8": [" ██████ ", "██    ██", " ██████ ", "██    ██", "██    ██", " ██████ "],
    "9": [" ██████ ", "██    ██", " ███████", "      ██", "      ██", " ██████ "],
    ".": ["        ", "        ", "        ", "        ", "   ██   ", "   ██   "],
    ",": ["        ", "        ", "        ", "   ██   ", "   ██   ", "  ██    "],
    "!": ["   ██   ", "   ██   ", "   ██   ", "   ██   ", "        ", "   ██   "],
    "?": [" ██████ ", "██    ██", "     ██ ", "   ██   ", "        ", "   ██   "],
    "-": ["        ", "        ", "████████", "        ", "        ", "        "],
    "+": ["        ", "   ██   ", "████████", "   ██   ", "        ", "        "],
    "=": ["        ", "████████", "        ", "████████", "        ", "        "],
    ":": ["        ", "   ██   ", "        ", "        ", "   ██   ", "        "],
    "'": ["   ██   ", "   ██   ", "        ", "        ", "        ", "        "],
    "/": ["      ██", "     ██ ", "    ██  ", "   ██   ", "  ██    ", " ██     "],
    "\\": [
      " ██     ",
      "  ██    ",
      "   ██   ",
      "    ██  ",
      "     ██ ",
      "      ██",
    ],
    "*": ["        ", "██ ██ ██", " ██████ ", "██ ██ ██", "        ", "        "],
    "<": ["     ██ ", "   ██   ", " ██     ", "   ██   ", "     ██ ", "        "],
    ">": [" ██     ", "   ██   ", "     ██ ", "   ██   ", " ██     ", "        "],
    "(": ["    ██  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "    ██  "],
    ")": ["  ██    ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ██    "],
    "[": ["  ████  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "  ████  "],
    "]": ["  ████  ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ████  "],
    "#": [" ██  ██ ", "████████", " ██  ██ ", "████████", " ██  ██ ", "        "],
    "@": [" ██████ ", "██    ██", "██ ██ ██", "██ ████ ", "██      ", " ██████ "],
    _: ["        ", "        ", "        ", "        ", "        ", "████████"],
    " ": ["        ", "        ", "        ", "        ", "        ", "        "],
  };

  /**
   * Раскладывает текст в прямоугольную сетку on/off.
   * Неизвестные символы пропускаются, а не подменяются.
   */
  function buildGrid(text: string, letterSpacing: number): boolean[][] {
    const glyphs = text
      .toUpperCase()
      .split("")
      .map((ch) => FONT[ch])
      .filter((g): g is string[] => Boolean(g));

    if (glyphs.length === 0) return [];

    const gap = " ".repeat(Math.max(0, letterSpacing));
    const rows: boolean[][] = [];

    for (let row = 0; row < GLYPH_HEIGHT; row++) {
      const line = glyphs.map((g) => g[row]).join(gap);
      rows.push(line.split("").map((ch) => ch !== " "));
    }

    return rows;
  }

  /**
   * Чистая функция: рисует один кадр анимации в виде многострочной строки.
   * @param t время в секундах, уже умноженное на speed
   */
  function renderFrame(
    grid: boolean[][],
    mode: AsciiAnimation,
    t: number,
    ramp: string,
  ): string {
    const last = ramp.length - 1;
    const lit = ramp[last] ?? "█";
    const rows = grid.length;
    const width = grid[0]?.length ?? 0;

    const out: string[] = [];

    for (let y = 0; y < rows; y++) {
      let line = "";
      for (let x = 0; x < width; x++) {
        if (!grid[y][x]) {
          line += " ";
          continue;
        }

        switch (mode) {
          case "wave": {
            // Бегущая синусоида выбирает плотность символа из ramp.
            const v = (Math.sin(x * 0.18 - t * 3 + y * 0.25) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "pulse": {
            const v = (Math.sin(t * 2.2) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "scan": {
            // Яркая полоса движется сверху вниз по глифам.
            const band = ((t * 0.6) % 1) * rows;
            const d = Math.abs(y - band);
            const v = Math.max(0, 1 - d / 2);
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "glitch": {
            // Детерминированный хэш, чтобы шум не перемешивался каждый кадр.
            const seed =
              Math.sin(x * 12.9898 + y * 78.233 + Math.floor(t * 8)) *
              43758.5453;
            const n = seed - Math.floor(seed);

            // «Сбойные» ячейки должны быть строго темнее самой яркой ступени,
            // иначе они неотличимы от обычных и эффект не виден.
            if (n > 0.96) {
              line += " ";
            } else if (n > 0.82) {
              const dim = Math.floor(((n - 0.82) / 0.14) * last);
              line +=
                ramp[Math.min(Math.max(last - 1, 0), Math.max(0, dim))] ?? lit;
            } else {
              line += lit;
            }
            break;
          }
          case "typewriter": {
            const revealed = ((t * 12) % (width + 12)) | 0;
            line += x <= revealed ? lit : " ";
            break;
          }
          default:
            line += lit;
        }
      }
      out.push(line);
    }

    return out.join("\n");
  }
</script>

<script lang="ts">
  import type { HTMLAttributes } from "svelte/elements";

  type Props = Omit<HTMLAttributes<HTMLDivElement>, "children" | "color"> & {
    /** Текст для отрисовки. Неподдерживаемые символы пропускаются. */
    text?: string;
    /** Тип анимации глифов. */
    animation?: AsciiAnimation;
    /** Множитель скорости анимации. */
    speed?: number;
    /** Размер глифов в px. Игнорируется при autoFit. */
    fontSize?: number;
    /** Масштабировать глифы так, чтобы строка занимала всю ширину контейнера. */
    autoFit?: boolean;
    /** Пустые колонки между буквами. */
    letterSpacing?: number;
    /** Любой CSS-цвет. По умолчанию наследуется цвет текста. */
    color?: string;
    /** Мягкое свечение за глифами. */
    glow?: boolean;
    /** Символы для «зажжённой» ячейки: от тёмного к светлому. */
    ramp?: string;
  };

  let {
    text = "OPENSOPHY",
    animation = "wave",
    speed = 1,
    fontSize = 14,
    autoFit = true,
    letterSpacing = 2,
    color,
    glow = false,
    ramp = DEFAULT_RAMP,
    class: className,
    ...rest
  }: Props = $props();

  let frame = $state("");
  let containerWidth = $state(0);

  const grid = $derived(buildGrid(text, letterSpacing));
  // Примитивы: эффект перезапускается только при смене формы сетки.
  const rowCount = $derived(grid.length);
  const cols = $derived(grid[0]?.length ?? 0);

  // Моноширинная ячейка ≈ 0.6em шириной.
  const fitSize = $derived(
    autoFit && cols > 0 && containerWidth > 0
      ? Math.max(4, containerWidth / (cols * 0.62))
      : null,
  );
  const size = $derived(autoFit ? (fitSize ?? fontSize) : fontSize);

  $effect(() => {
    // Явные зависимости: форма сетки и тип анимации.
    // speed, ramp и сама сетка читаются внутри rAF-колбэка и не отслеживаются,
    // поэтому смена speed на лету не перезапускает анимацию.
    const mode = animation;
    if (rowCount === 0 || cols === 0) {
      frame = "";
      return;
    }

    const reduceMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    ).matches;

    let raf = 0;
    const start = performance.now();

    const render = (now: number) => {
      const t = reduceMotion ? 0 : ((now - start) / 1000) * speed;
      frame = renderFrame(grid, mode, t, ramp);

      if (mode !== "none" && !reduceMotion) {
        raf = requestAnimationFrame(render);
      }
    };

    raf = requestAnimationFrame(render);
    return () => cancelAnimationFrame(raf);
  });
</script>

<div
  bind:clientWidth={containerWidth}
  class={["w-full overflow-hidden", className]}
  role="img"
  aria-label={text}
  {...rest}
>
  <pre
    aria-hidden="true"
    class="m-0 w-full text-center font-mono leading-none whitespace-pre select-none"
    style:font-size="{size}px"
    style:color
    style:text-shadow={glow ? "0 0 0.5em currentColor" : undefined}>{frame}</pre>
</div>

Глитч — glitch

Детерминированный шум временно затемняет и скрывает отдельные ячейки.

<script module lang="ts">
  export type AsciiAnimation =
    | "none"
    | "wave"
    | "glitch"
    | "typewriter"
    | "pulse"
    | "scan";

  const GLYPH_HEIGHT = 6;
  const DEFAULT_RAMP = "░▒▓█";

  // Блочный шрифт 8x6. Каждый глиф ровно 8 колонок шириной, чтобы сетка
  // оставалась прямоугольной и поколоночные эффекты совпадали между буквами.
  const FONT: Record<string, string[]> = {
    A: ["  ████  ", " ██  ██ ", "████████", "██    ██", "██    ██", "██    ██"],
    B: ["███████ ", "██    ██", "███████ ", "██    ██", "██    ██", "███████ "],
    C: [" ███████", "██      ", "██      ", "██      ", "██      ", " ███████"],
    D: ["███████ ", "██    ██", "██    ██", "██    ██", "██    ██", "███████ "],
    E: ["████████", "██      ", "██████  ", "██      ", "██      ", "████████"],
    F: ["████████", "██      ", "██████  ", "██      ", "██      ", "██      "],
    G: [" ███████", "██      ", "██  ████", "██    ██", "██    ██", " ███████"],
    H: ["██    ██", "██    ██", "████████", "██    ██", "██    ██", "██    ██"],
    I: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "████████"],
    J: ["████████", "      ██", "      ██", "      ██", "██    ██", " ███████"],
    K: ["██    ██", "██  ██  ", "████    ", "██  ██  ", "██    ██", "██    ██"],
    L: ["██      ", "██      ", "██      ", "██      ", "██      ", "████████"],
    M: ["██    ██", "████████", "██ ██ ██", "██    ██", "██    ██", "██    ██"],
    N: ["██    ██", "███   ██", "██ ██ ██", "██   ███", "██    ██", "██    ██"],
    O: [" ███████", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    P: ["███████ ", "██    ██", "███████ ", "██      ", "██      ", "██      "],
    Q: [" ██████ ", "██    ██", "██    ██", "██ ██ ██", "██   ███", " ███████"],
    R: ["███████ ", "██    ██", "███████ ", "██   ██ ", "██    ██", "██    ██"],
    S: [" ███████", "██      ", " ██████ ", "      ██", "      ██", "███████ "],
    T: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "   ██   "],
    U: ["██    ██", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    V: ["██    ██", "██    ██", "██    ██", " ██  ██ ", "  ████  ", "   ██   "],
    W: ["██    ██", "██    ██", "██    ██", "██ ██ ██", "████████", "██    ██"],
    X: ["██    ██", " ██  ██ ", "  ████  ", "  ████  ", " ██  ██ ", "██    ██"],
    Y: ["██    ██", " ██  ██ ", "  ████  ", "   ██   ", "   ██   ", "   ██   "],
    Z: ["████████", "      ██", "    ██  ", "  ██    ", "██      ", "████████"],
    "0": [" ██████ ", "██    ██", "██    ██", "██    ██", "██    ██", " ██████ "],
    "1": ["   ██   ", "  ███   ", "   ██   ", "   ██   ", "   ██   ", " ██████ "],
    "2": [" ██████ ", "██    ██", "     ██ ", "   ██   ", " ██     ", "████████"],
    "3": [" ██████ ", "██    ██", "    ███ ", "      ██", "██    ██", " ██████ "],
    "4": ["██    ██", "██    ██", "████████", "      ██", "      ██", "      ██"],
    "5": ["████████", "██      ", "███████ ", "      ██", "██    ██", " ██████ "],
    "6": [" ██████ ", "██      ", "███████ ", "██    ██", "██    ██", " ██████ "],
    "7": ["████████", "      ██", "     ██ ", "    ██  ", "   ██   ", "   ██   "],
    "8": [" ██████ ", "██    ██", " ██████ ", "██    ██", "██    ██", " ██████ "],
    "9": [" ██████ ", "██    ██", " ███████", "      ██", "      ██", " ██████ "],
    ".": ["        ", "        ", "        ", "        ", "   ██   ", "   ██   "],
    ",": ["        ", "        ", "        ", "   ██   ", "   ██   ", "  ██    "],
    "!": ["   ██   ", "   ██   ", "   ██   ", "   ██   ", "        ", "   ██   "],
    "?": [" ██████ ", "██    ██", "     ██ ", "   ██   ", "        ", "   ██   "],
    "-": ["        ", "        ", "████████", "        ", "        ", "        "],
    "+": ["        ", "   ██   ", "████████", "   ██   ", "        ", "        "],
    "=": ["        ", "████████", "        ", "████████", "        ", "        "],
    ":": ["        ", "   ██   ", "        ", "        ", "   ██   ", "        "],
    "'": ["   ██   ", "   ██   ", "        ", "        ", "        ", "        "],
    "/": ["      ██", "     ██ ", "    ██  ", "   ██   ", "  ██    ", " ██     "],
    "\\": [
      " ██     ",
      "  ██    ",
      "   ██   ",
      "    ██  ",
      "     ██ ",
      "      ██",
    ],
    "*": ["        ", "██ ██ ██", " ██████ ", "██ ██ ██", "        ", "        "],
    "<": ["     ██ ", "   ██   ", " ██     ", "   ██   ", "     ██ ", "        "],
    ">": [" ██     ", "   ██   ", "     ██ ", "   ██   ", " ██     ", "        "],
    "(": ["    ██  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "    ██  "],
    ")": ["  ██    ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ██    "],
    "[": ["  ████  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "  ████  "],
    "]": ["  ████  ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ████  "],
    "#": [" ██  ██ ", "████████", " ██  ██ ", "████████", " ██  ██ ", "        "],
    "@": [" ██████ ", "██    ██", "██ ██ ██", "██ ████ ", "██      ", " ██████ "],
    _: ["        ", "        ", "        ", "        ", "        ", "████████"],
    " ": ["        ", "        ", "        ", "        ", "        ", "        "],
  };

  /**
   * Раскладывает текст в прямоугольную сетку on/off.
   * Неизвестные символы пропускаются, а не подменяются.
   */
  function buildGrid(text: string, letterSpacing: number): boolean[][] {
    const glyphs = text
      .toUpperCase()
      .split("")
      .map((ch) => FONT[ch])
      .filter((g): g is string[] => Boolean(g));

    if (glyphs.length === 0) return [];

    const gap = " ".repeat(Math.max(0, letterSpacing));
    const rows: boolean[][] = [];

    for (let row = 0; row < GLYPH_HEIGHT; row++) {
      const line = glyphs.map((g) => g[row]).join(gap);
      rows.push(line.split("").map((ch) => ch !== " "));
    }

    return rows;
  }

  /**
   * Чистая функция: рисует один кадр анимации в виде многострочной строки.
   * @param t время в секундах, уже умноженное на speed
   */
  function renderFrame(
    grid: boolean[][],
    mode: AsciiAnimation,
    t: number,
    ramp: string,
  ): string {
    const last = ramp.length - 1;
    const lit = ramp[last] ?? "█";
    const rows = grid.length;
    const width = grid[0]?.length ?? 0;

    const out: string[] = [];

    for (let y = 0; y < rows; y++) {
      let line = "";
      for (let x = 0; x < width; x++) {
        if (!grid[y][x]) {
          line += " ";
          continue;
        }

        switch (mode) {
          case "wave": {
            // Бегущая синусоида выбирает плотность символа из ramp.
            const v = (Math.sin(x * 0.18 - t * 3 + y * 0.25) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "pulse": {
            const v = (Math.sin(t * 2.2) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "scan": {
            // Яркая полоса движется сверху вниз по глифам.
            const band = ((t * 0.6) % 1) * rows;
            const d = Math.abs(y - band);
            const v = Math.max(0, 1 - d / 2);
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "glitch": {
            // Детерминированный хэш, чтобы шум не перемешивался каждый кадр.
            const seed =
              Math.sin(x * 12.9898 + y * 78.233 + Math.floor(t * 8)) *
              43758.5453;
            const n = seed - Math.floor(seed);

            // «Сбойные» ячейки должны быть строго темнее самой яркой ступени,
            // иначе они неотличимы от обычных и эффект не виден.
            if (n > 0.96) {
              line += " ";
            } else if (n > 0.82) {
              const dim = Math.floor(((n - 0.82) / 0.14) * last);
              line +=
                ramp[Math.min(Math.max(last - 1, 0), Math.max(0, dim))] ?? lit;
            } else {
              line += lit;
            }
            break;
          }
          case "typewriter": {
            const revealed = ((t * 12) % (width + 12)) | 0;
            line += x <= revealed ? lit : " ";
            break;
          }
          default:
            line += lit;
        }
      }
      out.push(line);
    }

    return out.join("\n");
  }
</script>

<script lang="ts">
  import type { HTMLAttributes } from "svelte/elements";

  type Props = Omit<HTMLAttributes<HTMLDivElement>, "children" | "color"> & {
    /** Текст для отрисовки. Неподдерживаемые символы пропускаются. */
    text?: string;
    /** Тип анимации глифов. */
    animation?: AsciiAnimation;
    /** Множитель скорости анимации. */
    speed?: number;
    /** Размер глифов в px. Игнорируется при autoFit. */
    fontSize?: number;
    /** Масштабировать глифы так, чтобы строка занимала всю ширину контейнера. */
    autoFit?: boolean;
    /** Пустые колонки между буквами. */
    letterSpacing?: number;
    /** Любой CSS-цвет. По умолчанию наследуется цвет текста. */
    color?: string;
    /** Мягкое свечение за глифами. */
    glow?: boolean;
    /** Символы для «зажжённой» ячейки: от тёмного к светлому. */
    ramp?: string;
  };

  let {
    text = "OPENSOPHY",
    animation = "wave",
    speed = 1,
    fontSize = 14,
    autoFit = true,
    letterSpacing = 2,
    color,
    glow = false,
    ramp = DEFAULT_RAMP,
    class: className,
    ...rest
  }: Props = $props();

  let frame = $state("");
  let containerWidth = $state(0);

  const grid = $derived(buildGrid(text, letterSpacing));
  // Примитивы: эффект перезапускается только при смене формы сетки.
  const rowCount = $derived(grid.length);
  const cols = $derived(grid[0]?.length ?? 0);

  // Моноширинная ячейка ≈ 0.6em шириной.
  const fitSize = $derived(
    autoFit && cols > 0 && containerWidth > 0
      ? Math.max(4, containerWidth / (cols * 0.62))
      : null,
  );
  const size = $derived(autoFit ? (fitSize ?? fontSize) : fontSize);

  $effect(() => {
    // Явные зависимости: форма сетки и тип анимации.
    // speed, ramp и сама сетка читаются внутри rAF-колбэка и не отслеживаются,
    // поэтому смена speed на лету не перезапускает анимацию.
    const mode = animation;
    if (rowCount === 0 || cols === 0) {
      frame = "";
      return;
    }

    const reduceMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    ).matches;

    let raf = 0;
    const start = performance.now();

    const render = (now: number) => {
      const t = reduceMotion ? 0 : ((now - start) / 1000) * speed;
      frame = renderFrame(grid, mode, t, ramp);

      if (mode !== "none" && !reduceMotion) {
        raf = requestAnimationFrame(render);
      }
    };

    raf = requestAnimationFrame(render);
    return () => cancelAnimationFrame(raf);
  });
</script>

<div
  bind:clientWidth={containerWidth}
  class={["w-full overflow-hidden", className]}
  role="img"
  aria-label={text}
  {...rest}
>
  <pre
    aria-hidden="true"
    class="m-0 w-full text-center font-mono leading-none whitespace-pre select-none"
    style:font-size="{size}px"
    style:color
    style:text-shadow={glow ? "0 0 0.5em currentColor" : undefined}>{frame}</pre>
</div>
<script module lang="ts">
  export type AsciiAnimation =
    | "none"
    | "wave"
    | "glitch"
    | "typewriter"
    | "pulse"
    | "scan";

  const GLYPH_HEIGHT = 6;
  const DEFAULT_RAMP = "░▒▓█";

  // Блочный шрифт 8x6. Каждый глиф ровно 8 колонок шириной, чтобы сетка
  // оставалась прямоугольной и поколоночные эффекты совпадали между буквами.
  const FONT: Record<string, string[]> = {
    A: ["  ████  ", " ██  ██ ", "████████", "██    ██", "██    ██", "██    ██"],
    B: ["███████ ", "██    ██", "███████ ", "██    ██", "██    ██", "███████ "],
    C: [" ███████", "██      ", "██      ", "██      ", "██      ", " ███████"],
    D: ["███████ ", "██    ██", "██    ██", "██    ██", "██    ██", "███████ "],
    E: ["████████", "██      ", "██████  ", "██      ", "██      ", "████████"],
    F: ["████████", "██      ", "██████  ", "██      ", "██      ", "██      "],
    G: [" ███████", "██      ", "██  ████", "██    ██", "██    ██", " ███████"],
    H: ["██    ██", "██    ██", "████████", "██    ██", "██    ██", "██    ██"],
    I: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "████████"],
    J: ["████████", "      ██", "      ██", "      ██", "██    ██", " ███████"],
    K: ["██    ██", "██  ██  ", "████    ", "██  ██  ", "██    ██", "██    ██"],
    L: ["██      ", "██      ", "██      ", "██      ", "██      ", "████████"],
    M: ["██    ██", "████████", "██ ██ ██", "██    ██", "██    ██", "██    ██"],
    N: ["██    ██", "███   ██", "██ ██ ██", "██   ███", "██    ██", "██    ██"],
    O: [" ███████", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    P: ["███████ ", "██    ██", "███████ ", "██      ", "██      ", "██      "],
    Q: [" ██████ ", "██    ██", "██    ██", "██ ██ ██", "██   ███", " ███████"],
    R: ["███████ ", "██    ██", "███████ ", "██   ██ ", "██    ██", "██    ██"],
    S: [" ███████", "██      ", " ██████ ", "      ██", "      ██", "███████ "],
    T: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "   ██   "],
    U: ["██    ██", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    V: ["██    ██", "██    ██", "██    ██", " ██  ██ ", "  ████  ", "   ██   "],
    W: ["██    ██", "██    ██", "██    ██", "██ ██ ██", "████████", "██    ██"],
    X: ["██    ██", " ██  ██ ", "  ████  ", "  ████  ", " ██  ██ ", "██    ██"],
    Y: ["██    ██", " ██  ██ ", "  ████  ", "   ██   ", "   ██   ", "   ██   "],
    Z: ["████████", "      ██", "    ██  ", "  ██    ", "██      ", "████████"],
    "0": [" ██████ ", "██    ██", "██    ██", "██    ██", "██    ██", " ██████ "],
    "1": ["   ██   ", "  ███   ", "   ██   ", "   ██   ", "   ██   ", " ██████ "],
    "2": [" ██████ ", "██    ██", "     ██ ", "   ██   ", " ██     ", "████████"],
    "3": [" ██████ ", "██    ██", "    ███ ", "      ██", "██    ██", " ██████ "],
    "4": ["██    ██", "██    ██", "████████", "      ██", "      ██", "      ██"],
    "5": ["████████", "██      ", "███████ ", "      ██", "██    ██", " ██████ "],
    "6": [" ██████ ", "██      ", "███████ ", "██    ██", "██    ██", " ██████ "],
    "7": ["████████", "      ██", "     ██ ", "    ██  ", "   ██   ", "   ██   "],
    "8": [" ██████ ", "██    ██", " ██████ ", "██    ██", "██    ██", " ██████ "],
    "9": [" ██████ ", "██    ██", " ███████", "      ██", "      ██", " ██████ "],
    ".": ["        ", "        ", "        ", "        ", "   ██   ", "   ██   "],
    ",": ["        ", "        ", "        ", "   ██   ", "   ██   ", "  ██    "],
    "!": ["   ██   ", "   ██   ", "   ██   ", "   ██   ", "        ", "   ██   "],
    "?": [" ██████ ", "██    ██", "     ██ ", "   ██   ", "        ", "   ██   "],
    "-": ["        ", "        ", "████████", "        ", "        ", "        "],
    "+": ["        ", "   ██   ", "████████", "   ██   ", "        ", "        "],
    "=": ["        ", "████████", "        ", "████████", "        ", "        "],
    ":": ["        ", "   ██   ", "        ", "        ", "   ██   ", "        "],
    "'": ["   ██   ", "   ██   ", "        ", "        ", "        ", "        "],
    "/": ["      ██", "     ██ ", "    ██  ", "   ██   ", "  ██    ", " ██     "],
    "\\": [
      " ██     ",
      "  ██    ",
      "   ██   ",
      "    ██  ",
      "     ██ ",
      "      ██",
    ],
    "*": ["        ", "██ ██ ██", " ██████ ", "██ ██ ██", "        ", "        "],
    "<": ["     ██ ", "   ██   ", " ██     ", "   ██   ", "     ██ ", "        "],
    ">": [" ██     ", "   ██   ", "     ██ ", "   ██   ", " ██     ", "        "],
    "(": ["    ██  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "    ██  "],
    ")": ["  ██    ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ██    "],
    "[": ["  ████  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "  ████  "],
    "]": ["  ████  ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ████  "],
    "#": [" ██  ██ ", "████████", " ██  ██ ", "████████", " ██  ██ ", "        "],
    "@": [" ██████ ", "██    ██", "██ ██ ██", "██ ████ ", "██      ", " ██████ "],
    _: ["        ", "        ", "        ", "        ", "        ", "████████"],
    " ": ["        ", "        ", "        ", "        ", "        ", "        "],
  };

  /**
   * Раскладывает текст в прямоугольную сетку on/off.
   * Неизвестные символы пропускаются, а не подменяются.
   */
  function buildGrid(text: string, letterSpacing: number): boolean[][] {
    const glyphs = text
      .toUpperCase()
      .split("")
      .map((ch) => FONT[ch])
      .filter((g): g is string[] => Boolean(g));

    if (glyphs.length === 0) return [];

    const gap = " ".repeat(Math.max(0, letterSpacing));
    const rows: boolean[][] = [];

    for (let row = 0; row < GLYPH_HEIGHT; row++) {
      const line = glyphs.map((g) => g[row]).join(gap);
      rows.push(line.split("").map((ch) => ch !== " "));
    }

    return rows;
  }

  /**
   * Чистая функция: рисует один кадр анимации в виде многострочной строки.
   * @param t время в секундах, уже умноженное на speed
   */
  function renderFrame(
    grid: boolean[][],
    mode: AsciiAnimation,
    t: number,
    ramp: string,
  ): string {
    const last = ramp.length - 1;
    const lit = ramp[last] ?? "█";
    const rows = grid.length;
    const width = grid[0]?.length ?? 0;

    const out: string[] = [];

    for (let y = 0; y < rows; y++) {
      let line = "";
      for (let x = 0; x < width; x++) {
        if (!grid[y][x]) {
          line += " ";
          continue;
        }

        switch (mode) {
          case "wave": {
            // Бегущая синусоида выбирает плотность символа из ramp.
            const v = (Math.sin(x * 0.18 - t * 3 + y * 0.25) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "pulse": {
            const v = (Math.sin(t * 2.2) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "scan": {
            // Яркая полоса движется сверху вниз по глифам.
            const band = ((t * 0.6) % 1) * rows;
            const d = Math.abs(y - band);
            const v = Math.max(0, 1 - d / 2);
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "glitch": {
            // Детерминированный хэш, чтобы шум не перемешивался каждый кадр.
            const seed =
              Math.sin(x * 12.9898 + y * 78.233 + Math.floor(t * 8)) *
              43758.5453;
            const n = seed - Math.floor(seed);

            // «Сбойные» ячейки должны быть строго темнее самой яркой ступени,
            // иначе они неотличимы от обычных и эффект не виден.
            if (n > 0.96) {
              line += " ";
            } else if (n > 0.82) {
              const dim = Math.floor(((n - 0.82) / 0.14) * last);
              line +=
                ramp[Math.min(Math.max(last - 1, 0), Math.max(0, dim))] ?? lit;
            } else {
              line += lit;
            }
            break;
          }
          case "typewriter": {
            const revealed = ((t * 12) % (width + 12)) | 0;
            line += x <= revealed ? lit : " ";
            break;
          }
          default:
            line += lit;
        }
      }
      out.push(line);
    }

    return out.join("\n");
  }
</script>

<script lang="ts">
  import type { HTMLAttributes } from "svelte/elements";

  type Props = Omit<HTMLAttributes<HTMLDivElement>, "children" | "color"> & {
    /** Текст для отрисовки. Неподдерживаемые символы пропускаются. */
    text?: string;
    /** Тип анимации глифов. */
    animation?: AsciiAnimation;
    /** Множитель скорости анимации. */
    speed?: number;
    /** Размер глифов в px. Игнорируется при autoFit. */
    fontSize?: number;
    /** Масштабировать глифы так, чтобы строка занимала всю ширину контейнера. */
    autoFit?: boolean;
    /** Пустые колонки между буквами. */
    letterSpacing?: number;
    /** Любой CSS-цвет. По умолчанию наследуется цвет текста. */
    color?: string;
    /** Мягкое свечение за глифами. */
    glow?: boolean;
    /** Символы для «зажжённой» ячейки: от тёмного к светлому. */
    ramp?: string;
  };

  let {
    text = "OPENSOPHY",
    animation = "wave",
    speed = 1,
    fontSize = 14,
    autoFit = true,
    letterSpacing = 2,
    color,
    glow = false,
    ramp = DEFAULT_RAMP,
    class: className,
    ...rest
  }: Props = $props();

  let frame = $state("");
  let containerWidth = $state(0);

  const grid = $derived(buildGrid(text, letterSpacing));
  // Примитивы: эффект перезапускается только при смене формы сетки.
  const rowCount = $derived(grid.length);
  const cols = $derived(grid[0]?.length ?? 0);

  // Моноширинная ячейка ≈ 0.6em шириной.
  const fitSize = $derived(
    autoFit && cols > 0 && containerWidth > 0
      ? Math.max(4, containerWidth / (cols * 0.62))
      : null,
  );
  const size = $derived(autoFit ? (fitSize ?? fontSize) : fontSize);

  $effect(() => {
    // Явные зависимости: форма сетки и тип анимации.
    // speed, ramp и сама сетка читаются внутри rAF-колбэка и не отслеживаются,
    // поэтому смена speed на лету не перезапускает анимацию.
    const mode = animation;
    if (rowCount === 0 || cols === 0) {
      frame = "";
      return;
    }

    const reduceMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    ).matches;

    let raf = 0;
    const start = performance.now();

    const render = (now: number) => {
      const t = reduceMotion ? 0 : ((now - start) / 1000) * speed;
      frame = renderFrame(grid, mode, t, ramp);

      if (mode !== "none" && !reduceMotion) {
        raf = requestAnimationFrame(render);
      }
    };

    raf = requestAnimationFrame(render);
    return () => cancelAnimationFrame(raf);
  });
</script>

<div
  bind:clientWidth={containerWidth}
  class={["w-full overflow-hidden", className]}
  role="img"
  aria-label={text}
  {...rest}
>
  <pre
    aria-hidden="true"
    class="m-0 w-full text-center font-mono leading-none whitespace-pre select-none"
    style:font-size="{size}px"
    style:color
    style:text-shadow={glow ? "0 0 0.5em currentColor" : undefined}>{frame}</pre>
</div>

Печатная машинка — typewriter

Символы открываются слева направо, после чего цикл начинается заново.

<script module lang="ts">
  export type AsciiAnimation =
    | "none"
    | "wave"
    | "glitch"
    | "typewriter"
    | "pulse"
    | "scan";

  const GLYPH_HEIGHT = 6;
  const DEFAULT_RAMP = "░▒▓█";

  // Блочный шрифт 8x6. Каждый глиф ровно 8 колонок шириной, чтобы сетка
  // оставалась прямоугольной и поколоночные эффекты совпадали между буквами.
  const FONT: Record<string, string[]> = {
    A: ["  ████  ", " ██  ██ ", "████████", "██    ██", "██    ██", "██    ██"],
    B: ["███████ ", "██    ██", "███████ ", "██    ██", "██    ██", "███████ "],
    C: [" ███████", "██      ", "██      ", "██      ", "██      ", " ███████"],
    D: ["███████ ", "██    ██", "██    ██", "██    ██", "██    ██", "███████ "],
    E: ["████████", "██      ", "██████  ", "██      ", "██      ", "████████"],
    F: ["████████", "██      ", "██████  ", "██      ", "██      ", "██      "],
    G: [" ███████", "██      ", "██  ████", "██    ██", "██    ██", " ███████"],
    H: ["██    ██", "██    ██", "████████", "██    ██", "██    ██", "██    ██"],
    I: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "████████"],
    J: ["████████", "      ██", "      ██", "      ██", "██    ██", " ███████"],
    K: ["██    ██", "██  ██  ", "████    ", "██  ██  ", "██    ██", "██    ██"],
    L: ["██      ", "██      ", "██      ", "██      ", "██      ", "████████"],
    M: ["██    ██", "████████", "██ ██ ██", "██    ██", "██    ██", "██    ██"],
    N: ["██    ██", "███   ██", "██ ██ ██", "██   ███", "██    ██", "██    ██"],
    O: [" ███████", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    P: ["███████ ", "██    ██", "███████ ", "██      ", "██      ", "██      "],
    Q: [" ██████ ", "██    ██", "██    ██", "██ ██ ██", "██   ███", " ███████"],
    R: ["███████ ", "██    ██", "███████ ", "██   ██ ", "██    ██", "██    ██"],
    S: [" ███████", "██      ", " ██████ ", "      ██", "      ██", "███████ "],
    T: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "   ██   "],
    U: ["██    ██", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    V: ["██    ██", "██    ██", "██    ██", " ██  ██ ", "  ████  ", "   ██   "],
    W: ["██    ██", "██    ██", "██    ██", "██ ██ ██", "████████", "██    ██"],
    X: ["██    ██", " ██  ██ ", "  ████  ", "  ████  ", " ██  ██ ", "██    ██"],
    Y: ["██    ██", " ██  ██ ", "  ████  ", "   ██   ", "   ██   ", "   ██   "],
    Z: ["████████", "      ██", "    ██  ", "  ██    ", "██      ", "████████"],
    "0": [" ██████ ", "██    ██", "██    ██", "██    ██", "██    ██", " ██████ "],
    "1": ["   ██   ", "  ███   ", "   ██   ", "   ██   ", "   ██   ", " ██████ "],
    "2": [" ██████ ", "██    ██", "     ██ ", "   ██   ", " ██     ", "████████"],
    "3": [" ██████ ", "██    ██", "    ███ ", "      ██", "██    ██", " ██████ "],
    "4": ["██    ██", "██    ██", "████████", "      ██", "      ██", "      ██"],
    "5": ["████████", "██      ", "███████ ", "      ██", "██    ██", " ██████ "],
    "6": [" ██████ ", "██      ", "███████ ", "██    ██", "██    ██", " ██████ "],
    "7": ["████████", "      ██", "     ██ ", "    ██  ", "   ██   ", "   ██   "],
    "8": [" ██████ ", "██    ██", " ██████ ", "██    ██", "██    ██", " ██████ "],
    "9": [" ██████ ", "██    ██", " ███████", "      ██", "      ██", " ██████ "],
    ".": ["        ", "        ", "        ", "        ", "   ██   ", "   ██   "],
    ",": ["        ", "        ", "        ", "   ██   ", "   ██   ", "  ██    "],
    "!": ["   ██   ", "   ██   ", "   ██   ", "   ██   ", "        ", "   ██   "],
    "?": [" ██████ ", "██    ██", "     ██ ", "   ██   ", "        ", "   ██   "],
    "-": ["        ", "        ", "████████", "        ", "        ", "        "],
    "+": ["        ", "   ██   ", "████████", "   ██   ", "        ", "        "],
    "=": ["        ", "████████", "        ", "████████", "        ", "        "],
    ":": ["        ", "   ██   ", "        ", "        ", "   ██   ", "        "],
    "'": ["   ██   ", "   ██   ", "        ", "        ", "        ", "        "],
    "/": ["      ██", "     ██ ", "    ██  ", "   ██   ", "  ██    ", " ██     "],
    "\\": [
      " ██     ",
      "  ██    ",
      "   ██   ",
      "    ██  ",
      "     ██ ",
      "      ██",
    ],
    "*": ["        ", "██ ██ ██", " ██████ ", "██ ██ ██", "        ", "        "],
    "<": ["     ██ ", "   ██   ", " ██     ", "   ██   ", "     ██ ", "        "],
    ">": [" ██     ", "   ██   ", "     ██ ", "   ██   ", " ██     ", "        "],
    "(": ["    ██  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "    ██  "],
    ")": ["  ██    ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ██    "],
    "[": ["  ████  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "  ████  "],
    "]": ["  ████  ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ████  "],
    "#": [" ██  ██ ", "████████", " ██  ██ ", "████████", " ██  ██ ", "        "],
    "@": [" ██████ ", "██    ██", "██ ██ ██", "██ ████ ", "██      ", " ██████ "],
    _: ["        ", "        ", "        ", "        ", "        ", "████████"],
    " ": ["        ", "        ", "        ", "        ", "        ", "        "],
  };

  /**
   * Раскладывает текст в прямоугольную сетку on/off.
   * Неизвестные символы пропускаются, а не подменяются.
   */
  function buildGrid(text: string, letterSpacing: number): boolean[][] {
    const glyphs = text
      .toUpperCase()
      .split("")
      .map((ch) => FONT[ch])
      .filter((g): g is string[] => Boolean(g));

    if (glyphs.length === 0) return [];

    const gap = " ".repeat(Math.max(0, letterSpacing));
    const rows: boolean[][] = [];

    for (let row = 0; row < GLYPH_HEIGHT; row++) {
      const line = glyphs.map((g) => g[row]).join(gap);
      rows.push(line.split("").map((ch) => ch !== " "));
    }

    return rows;
  }

  /**
   * Чистая функция: рисует один кадр анимации в виде многострочной строки.
   * @param t время в секундах, уже умноженное на speed
   */
  function renderFrame(
    grid: boolean[][],
    mode: AsciiAnimation,
    t: number,
    ramp: string,
  ): string {
    const last = ramp.length - 1;
    const lit = ramp[last] ?? "█";
    const rows = grid.length;
    const width = grid[0]?.length ?? 0;

    const out: string[] = [];

    for (let y = 0; y < rows; y++) {
      let line = "";
      for (let x = 0; x < width; x++) {
        if (!grid[y][x]) {
          line += " ";
          continue;
        }

        switch (mode) {
          case "wave": {
            // Бегущая синусоида выбирает плотность символа из ramp.
            const v = (Math.sin(x * 0.18 - t * 3 + y * 0.25) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "pulse": {
            const v = (Math.sin(t * 2.2) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "scan": {
            // Яркая полоса движется сверху вниз по глифам.
            const band = ((t * 0.6) % 1) * rows;
            const d = Math.abs(y - band);
            const v = Math.max(0, 1 - d / 2);
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "glitch": {
            // Детерминированный хэш, чтобы шум не перемешивался каждый кадр.
            const seed =
              Math.sin(x * 12.9898 + y * 78.233 + Math.floor(t * 8)) *
              43758.5453;
            const n = seed - Math.floor(seed);

            // «Сбойные» ячейки должны быть строго темнее самой яркой ступени,
            // иначе они неотличимы от обычных и эффект не виден.
            if (n > 0.96) {
              line += " ";
            } else if (n > 0.82) {
              const dim = Math.floor(((n - 0.82) / 0.14) * last);
              line +=
                ramp[Math.min(Math.max(last - 1, 0), Math.max(0, dim))] ?? lit;
            } else {
              line += lit;
            }
            break;
          }
          case "typewriter": {
            const revealed = ((t * 12) % (width + 12)) | 0;
            line += x <= revealed ? lit : " ";
            break;
          }
          default:
            line += lit;
        }
      }
      out.push(line);
    }

    return out.join("\n");
  }
</script>

<script lang="ts">
  import type { HTMLAttributes } from "svelte/elements";

  type Props = Omit<HTMLAttributes<HTMLDivElement>, "children" | "color"> & {
    /** Текст для отрисовки. Неподдерживаемые символы пропускаются. */
    text?: string;
    /** Тип анимации глифов. */
    animation?: AsciiAnimation;
    /** Множитель скорости анимации. */
    speed?: number;
    /** Размер глифов в px. Игнорируется при autoFit. */
    fontSize?: number;
    /** Масштабировать глифы так, чтобы строка занимала всю ширину контейнера. */
    autoFit?: boolean;
    /** Пустые колонки между буквами. */
    letterSpacing?: number;
    /** Любой CSS-цвет. По умолчанию наследуется цвет текста. */
    color?: string;
    /** Мягкое свечение за глифами. */
    glow?: boolean;
    /** Символы для «зажжённой» ячейки: от тёмного к светлому. */
    ramp?: string;
  };

  let {
    text = "OPENSOPHY",
    animation = "wave",
    speed = 1,
    fontSize = 14,
    autoFit = true,
    letterSpacing = 2,
    color,
    glow = false,
    ramp = DEFAULT_RAMP,
    class: className,
    ...rest
  }: Props = $props();

  let frame = $state("");
  let containerWidth = $state(0);

  const grid = $derived(buildGrid(text, letterSpacing));
  // Примитивы: эффект перезапускается только при смене формы сетки.
  const rowCount = $derived(grid.length);
  const cols = $derived(grid[0]?.length ?? 0);

  // Моноширинная ячейка ≈ 0.6em шириной.
  const fitSize = $derived(
    autoFit && cols > 0 && containerWidth > 0
      ? Math.max(4, containerWidth / (cols * 0.62))
      : null,
  );
  const size = $derived(autoFit ? (fitSize ?? fontSize) : fontSize);

  $effect(() => {
    // Явные зависимости: форма сетки и тип анимации.
    // speed, ramp и сама сетка читаются внутри rAF-колбэка и не отслеживаются,
    // поэтому смена speed на лету не перезапускает анимацию.
    const mode = animation;
    if (rowCount === 0 || cols === 0) {
      frame = "";
      return;
    }

    const reduceMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    ).matches;

    let raf = 0;
    const start = performance.now();

    const render = (now: number) => {
      const t = reduceMotion ? 0 : ((now - start) / 1000) * speed;
      frame = renderFrame(grid, mode, t, ramp);

      if (mode !== "none" && !reduceMotion) {
        raf = requestAnimationFrame(render);
      }
    };

    raf = requestAnimationFrame(render);
    return () => cancelAnimationFrame(raf);
  });
</script>

<div
  bind:clientWidth={containerWidth}
  class={["w-full overflow-hidden", className]}
  role="img"
  aria-label={text}
  {...rest}
>
  <pre
    aria-hidden="true"
    class="m-0 w-full text-center font-mono leading-none whitespace-pre select-none"
    style:font-size="{size}px"
    style:color
    style:text-shadow={glow ? "0 0 0.5em currentColor" : undefined}>{frame}</pre>
</div>
<script module lang="ts">
  export type AsciiAnimation =
    | "none"
    | "wave"
    | "glitch"
    | "typewriter"
    | "pulse"
    | "scan";

  const GLYPH_HEIGHT = 6;
  const DEFAULT_RAMP = "░▒▓█";

  // Блочный шрифт 8x6. Каждый глиф ровно 8 колонок шириной, чтобы сетка
  // оставалась прямоугольной и поколоночные эффекты совпадали между буквами.
  const FONT: Record<string, string[]> = {
    A: ["  ████  ", " ██  ██ ", "████████", "██    ██", "██    ██", "██    ██"],
    B: ["███████ ", "██    ██", "███████ ", "██    ██", "██    ██", "███████ "],
    C: [" ███████", "██      ", "██      ", "██      ", "██      ", " ███████"],
    D: ["███████ ", "██    ██", "██    ██", "██    ██", "██    ██", "███████ "],
    E: ["████████", "██      ", "██████  ", "██      ", "██      ", "████████"],
    F: ["████████", "██      ", "██████  ", "██      ", "██      ", "██      "],
    G: [" ███████", "██      ", "██  ████", "██    ██", "██    ██", " ███████"],
    H: ["██    ██", "██    ██", "████████", "██    ██", "██    ██", "██    ██"],
    I: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "████████"],
    J: ["████████", "      ██", "      ██", "      ██", "██    ██", " ███████"],
    K: ["██    ██", "██  ██  ", "████    ", "██  ██  ", "██    ██", "██    ██"],
    L: ["██      ", "██      ", "██      ", "██      ", "██      ", "████████"],
    M: ["██    ██", "████████", "██ ██ ██", "██    ██", "██    ██", "██    ██"],
    N: ["██    ██", "███   ██", "██ ██ ██", "██   ███", "██    ██", "██    ██"],
    O: [" ███████", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    P: ["███████ ", "██    ██", "███████ ", "██      ", "██      ", "██      "],
    Q: [" ██████ ", "██    ██", "██    ██", "██ ██ ██", "██   ███", " ███████"],
    R: ["███████ ", "██    ██", "███████ ", "██   ██ ", "██    ██", "██    ██"],
    S: [" ███████", "██      ", " ██████ ", "      ██", "      ██", "███████ "],
    T: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "   ██   "],
    U: ["██    ██", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    V: ["██    ██", "██    ██", "██    ██", " ██  ██ ", "  ████  ", "   ██   "],
    W: ["██    ██", "██    ██", "██    ██", "██ ██ ██", "████████", "██    ██"],
    X: ["██    ██", " ██  ██ ", "  ████  ", "  ████  ", " ██  ██ ", "██    ██"],
    Y: ["██    ██", " ██  ██ ", "  ████  ", "   ██   ", "   ██   ", "   ██   "],
    Z: ["████████", "      ██", "    ██  ", "  ██    ", "██      ", "████████"],
    "0": [" ██████ ", "██    ██", "██    ██", "██    ██", "██    ██", " ██████ "],
    "1": ["   ██   ", "  ███   ", "   ██   ", "   ██   ", "   ██   ", " ██████ "],
    "2": [" ██████ ", "██    ██", "     ██ ", "   ██   ", " ██     ", "████████"],
    "3": [" ██████ ", "██    ██", "    ███ ", "      ██", "██    ██", " ██████ "],
    "4": ["██    ██", "██    ██", "████████", "      ██", "      ██", "      ██"],
    "5": ["████████", "██      ", "███████ ", "      ██", "██    ██", " ██████ "],
    "6": [" ██████ ", "██      ", "███████ ", "██    ██", "██    ██", " ██████ "],
    "7": ["████████", "      ██", "     ██ ", "    ██  ", "   ██   ", "   ██   "],
    "8": [" ██████ ", "██    ██", " ██████ ", "██    ██", "██    ██", " ██████ "],
    "9": [" ██████ ", "██    ██", " ███████", "      ██", "      ██", " ██████ "],
    ".": ["        ", "        ", "        ", "        ", "   ██   ", "   ██   "],
    ",": ["        ", "        ", "        ", "   ██   ", "   ██   ", "  ██    "],
    "!": ["   ██   ", "   ██   ", "   ██   ", "   ██   ", "        ", "   ██   "],
    "?": [" ██████ ", "██    ██", "     ██ ", "   ██   ", "        ", "   ██   "],
    "-": ["        ", "        ", "████████", "        ", "        ", "        "],
    "+": ["        ", "   ██   ", "████████", "   ██   ", "        ", "        "],
    "=": ["        ", "████████", "        ", "████████", "        ", "        "],
    ":": ["        ", "   ██   ", "        ", "        ", "   ██   ", "        "],
    "'": ["   ██   ", "   ██   ", "        ", "        ", "        ", "        "],
    "/": ["      ██", "     ██ ", "    ██  ", "   ██   ", "  ██    ", " ██     "],
    "\\": [
      " ██     ",
      "  ██    ",
      "   ██   ",
      "    ██  ",
      "     ██ ",
      "      ██",
    ],
    "*": ["        ", "██ ██ ██", " ██████ ", "██ ██ ██", "        ", "        "],
    "<": ["     ██ ", "   ██   ", " ██     ", "   ██   ", "     ██ ", "        "],
    ">": [" ██     ", "   ██   ", "     ██ ", "   ██   ", " ██     ", "        "],
    "(": ["    ██  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "    ██  "],
    ")": ["  ██    ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ██    "],
    "[": ["  ████  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "  ████  "],
    "]": ["  ████  ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ████  "],
    "#": [" ██  ██ ", "████████", " ██  ██ ", "████████", " ██  ██ ", "        "],
    "@": [" ██████ ", "██    ██", "██ ██ ██", "██ ████ ", "██      ", " ██████ "],
    _: ["        ", "        ", "        ", "        ", "        ", "████████"],
    " ": ["        ", "        ", "        ", "        ", "        ", "        "],
  };

  /**
   * Раскладывает текст в прямоугольную сетку on/off.
   * Неизвестные символы пропускаются, а не подменяются.
   */
  function buildGrid(text: string, letterSpacing: number): boolean[][] {
    const glyphs = text
      .toUpperCase()
      .split("")
      .map((ch) => FONT[ch])
      .filter((g): g is string[] => Boolean(g));

    if (glyphs.length === 0) return [];

    const gap = " ".repeat(Math.max(0, letterSpacing));
    const rows: boolean[][] = [];

    for (let row = 0; row < GLYPH_HEIGHT; row++) {
      const line = glyphs.map((g) => g[row]).join(gap);
      rows.push(line.split("").map((ch) => ch !== " "));
    }

    return rows;
  }

  /**
   * Чистая функция: рисует один кадр анимации в виде многострочной строки.
   * @param t время в секундах, уже умноженное на speed
   */
  function renderFrame(
    grid: boolean[][],
    mode: AsciiAnimation,
    t: number,
    ramp: string,
  ): string {
    const last = ramp.length - 1;
    const lit = ramp[last] ?? "█";
    const rows = grid.length;
    const width = grid[0]?.length ?? 0;

    const out: string[] = [];

    for (let y = 0; y < rows; y++) {
      let line = "";
      for (let x = 0; x < width; x++) {
        if (!grid[y][x]) {
          line += " ";
          continue;
        }

        switch (mode) {
          case "wave": {
            // Бегущая синусоида выбирает плотность символа из ramp.
            const v = (Math.sin(x * 0.18 - t * 3 + y * 0.25) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "pulse": {
            const v = (Math.sin(t * 2.2) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "scan": {
            // Яркая полоса движется сверху вниз по глифам.
            const band = ((t * 0.6) % 1) * rows;
            const d = Math.abs(y - band);
            const v = Math.max(0, 1 - d / 2);
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "glitch": {
            // Детерминированный хэш, чтобы шум не перемешивался каждый кадр.
            const seed =
              Math.sin(x * 12.9898 + y * 78.233 + Math.floor(t * 8)) *
              43758.5453;
            const n = seed - Math.floor(seed);

            // «Сбойные» ячейки должны быть строго темнее самой яркой ступени,
            // иначе они неотличимы от обычных и эффект не виден.
            if (n > 0.96) {
              line += " ";
            } else if (n > 0.82) {
              const dim = Math.floor(((n - 0.82) / 0.14) * last);
              line +=
                ramp[Math.min(Math.max(last - 1, 0), Math.max(0, dim))] ?? lit;
            } else {
              line += lit;
            }
            break;
          }
          case "typewriter": {
            const revealed = ((t * 12) % (width + 12)) | 0;
            line += x <= revealed ? lit : " ";
            break;
          }
          default:
            line += lit;
        }
      }
      out.push(line);
    }

    return out.join("\n");
  }
</script>

<script lang="ts">
  import type { HTMLAttributes } from "svelte/elements";

  type Props = Omit<HTMLAttributes<HTMLDivElement>, "children" | "color"> & {
    /** Текст для отрисовки. Неподдерживаемые символы пропускаются. */
    text?: string;
    /** Тип анимации глифов. */
    animation?: AsciiAnimation;
    /** Множитель скорости анимации. */
    speed?: number;
    /** Размер глифов в px. Игнорируется при autoFit. */
    fontSize?: number;
    /** Масштабировать глифы так, чтобы строка занимала всю ширину контейнера. */
    autoFit?: boolean;
    /** Пустые колонки между буквами. */
    letterSpacing?: number;
    /** Любой CSS-цвет. По умолчанию наследуется цвет текста. */
    color?: string;
    /** Мягкое свечение за глифами. */
    glow?: boolean;
    /** Символы для «зажжённой» ячейки: от тёмного к светлому. */
    ramp?: string;
  };

  let {
    text = "OPENSOPHY",
    animation = "wave",
    speed = 1,
    fontSize = 14,
    autoFit = true,
    letterSpacing = 2,
    color,
    glow = false,
    ramp = DEFAULT_RAMP,
    class: className,
    ...rest
  }: Props = $props();

  let frame = $state("");
  let containerWidth = $state(0);

  const grid = $derived(buildGrid(text, letterSpacing));
  // Примитивы: эффект перезапускается только при смене формы сетки.
  const rowCount = $derived(grid.length);
  const cols = $derived(grid[0]?.length ?? 0);

  // Моноширинная ячейка ≈ 0.6em шириной.
  const fitSize = $derived(
    autoFit && cols > 0 && containerWidth > 0
      ? Math.max(4, containerWidth / (cols * 0.62))
      : null,
  );
  const size = $derived(autoFit ? (fitSize ?? fontSize) : fontSize);

  $effect(() => {
    // Явные зависимости: форма сетки и тип анимации.
    // speed, ramp и сама сетка читаются внутри rAF-колбэка и не отслеживаются,
    // поэтому смена speed на лету не перезапускает анимацию.
    const mode = animation;
    if (rowCount === 0 || cols === 0) {
      frame = "";
      return;
    }

    const reduceMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    ).matches;

    let raf = 0;
    const start = performance.now();

    const render = (now: number) => {
      const t = reduceMotion ? 0 : ((now - start) / 1000) * speed;
      frame = renderFrame(grid, mode, t, ramp);

      if (mode !== "none" && !reduceMotion) {
        raf = requestAnimationFrame(render);
      }
    };

    raf = requestAnimationFrame(render);
    return () => cancelAnimationFrame(raf);
  });
</script>

<div
  bind:clientWidth={containerWidth}
  class={["w-full overflow-hidden", className]}
  role="img"
  aria-label={text}
  {...rest}
>
  <pre
    aria-hidden="true"
    class="m-0 w-full text-center font-mono leading-none whitespace-pre select-none"
    style:font-size="{size}px"
    style:color
    style:text-shadow={glow ? "0 0 0.5em currentColor" : undefined}>{frame}</pre>
</div>

Пульсация — pulse

Вся надпись одновременно меняет плотность символов. Цвет и мягкое свечение можно настраивать отдельно.

<script module lang="ts">
  export type AsciiAnimation =
    | "none"
    | "wave"
    | "glitch"
    | "typewriter"
    | "pulse"
    | "scan";

  const GLYPH_HEIGHT = 6;
  const DEFAULT_RAMP = "░▒▓█";

  // Блочный шрифт 8x6. Каждый глиф ровно 8 колонок шириной, чтобы сетка
  // оставалась прямоугольной и поколоночные эффекты совпадали между буквами.
  const FONT: Record<string, string[]> = {
    A: ["  ████  ", " ██  ██ ", "████████", "██    ██", "██    ██", "██    ██"],
    B: ["███████ ", "██    ██", "███████ ", "██    ██", "██    ██", "███████ "],
    C: [" ███████", "██      ", "██      ", "██      ", "██      ", " ███████"],
    D: ["███████ ", "██    ██", "██    ██", "██    ██", "██    ██", "███████ "],
    E: ["████████", "██      ", "██████  ", "██      ", "██      ", "████████"],
    F: ["████████", "██      ", "██████  ", "██      ", "██      ", "██      "],
    G: [" ███████", "██      ", "██  ████", "██    ██", "██    ██", " ███████"],
    H: ["██    ██", "██    ██", "████████", "██    ██", "██    ██", "██    ██"],
    I: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "████████"],
    J: ["████████", "      ██", "      ██", "      ██", "██    ██", " ███████"],
    K: ["██    ██", "██  ██  ", "████    ", "██  ██  ", "██    ██", "██    ██"],
    L: ["██      ", "██      ", "██      ", "██      ", "██      ", "████████"],
    M: ["██    ██", "████████", "██ ██ ██", "██    ██", "██    ██", "██    ██"],
    N: ["██    ██", "███   ██", "██ ██ ██", "██   ███", "██    ██", "██    ██"],
    O: [" ███████", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    P: ["███████ ", "██    ██", "███████ ", "██      ", "██      ", "██      "],
    Q: [" ██████ ", "██    ██", "██    ██", "██ ██ ██", "██   ███", " ███████"],
    R: ["███████ ", "██    ██", "███████ ", "██   ██ ", "██    ██", "██    ██"],
    S: [" ███████", "██      ", " ██████ ", "      ██", "      ██", "███████ "],
    T: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "   ██   "],
    U: ["██    ██", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    V: ["██    ██", "██    ██", "██    ██", " ██  ██ ", "  ████  ", "   ██   "],
    W: ["██    ██", "██    ██", "██    ██", "██ ██ ██", "████████", "██    ██"],
    X: ["██    ██", " ██  ██ ", "  ████  ", "  ████  ", " ██  ██ ", "██    ██"],
    Y: ["██    ██", " ██  ██ ", "  ████  ", "   ██   ", "   ██   ", "   ██   "],
    Z: ["████████", "      ██", "    ██  ", "  ██    ", "██      ", "████████"],
    "0": [" ██████ ", "██    ██", "██    ██", "██    ██", "██    ██", " ██████ "],
    "1": ["   ██   ", "  ███   ", "   ██   ", "   ██   ", "   ██   ", " ██████ "],
    "2": [" ██████ ", "██    ██", "     ██ ", "   ██   ", " ██     ", "████████"],
    "3": [" ██████ ", "██    ██", "    ███ ", "      ██", "██    ██", " ██████ "],
    "4": ["██    ██", "██    ██", "████████", "      ██", "      ██", "      ██"],
    "5": ["████████", "██      ", "███████ ", "      ██", "██    ██", " ██████ "],
    "6": [" ██████ ", "██      ", "███████ ", "██    ██", "██    ██", " ██████ "],
    "7": ["████████", "      ██", "     ██ ", "    ██  ", "   ██   ", "   ██   "],
    "8": [" ██████ ", "██    ██", " ██████ ", "██    ██", "██    ██", " ██████ "],
    "9": [" ██████ ", "██    ██", " ███████", "      ██", "      ██", " ██████ "],
    ".": ["        ", "        ", "        ", "        ", "   ██   ", "   ██   "],
    ",": ["        ", "        ", "        ", "   ██   ", "   ██   ", "  ██    "],
    "!": ["   ██   ", "   ██   ", "   ██   ", "   ██   ", "        ", "   ██   "],
    "?": [" ██████ ", "██    ██", "     ██ ", "   ██   ", "        ", "   ██   "],
    "-": ["        ", "        ", "████████", "        ", "        ", "        "],
    "+": ["        ", "   ██   ", "████████", "   ██   ", "        ", "        "],
    "=": ["        ", "████████", "        ", "████████", "        ", "        "],
    ":": ["        ", "   ██   ", "        ", "        ", "   ██   ", "        "],
    "'": ["   ██   ", "   ██   ", "        ", "        ", "        ", "        "],
    "/": ["      ██", "     ██ ", "    ██  ", "   ██   ", "  ██    ", " ██     "],
    "\\": [
      " ██     ",
      "  ██    ",
      "   ██   ",
      "    ██  ",
      "     ██ ",
      "      ██",
    ],
    "*": ["        ", "██ ██ ██", " ██████ ", "██ ██ ██", "        ", "        "],
    "<": ["     ██ ", "   ██   ", " ██     ", "   ██   ", "     ██ ", "        "],
    ">": [" ██     ", "   ██   ", "     ██ ", "   ██   ", " ██     ", "        "],
    "(": ["    ██  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "    ██  "],
    ")": ["  ██    ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ██    "],
    "[": ["  ████  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "  ████  "],
    "]": ["  ████  ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ████  "],
    "#": [" ██  ██ ", "████████", " ██  ██ ", "████████", " ██  ██ ", "        "],
    "@": [" ██████ ", "██    ██", "██ ██ ██", "██ ████ ", "██      ", " ██████ "],
    _: ["        ", "        ", "        ", "        ", "        ", "████████"],
    " ": ["        ", "        ", "        ", "        ", "        ", "        "],
  };

  /**
   * Раскладывает текст в прямоугольную сетку on/off.
   * Неизвестные символы пропускаются, а не подменяются.
   */
  function buildGrid(text: string, letterSpacing: number): boolean[][] {
    const glyphs = text
      .toUpperCase()
      .split("")
      .map((ch) => FONT[ch])
      .filter((g): g is string[] => Boolean(g));

    if (glyphs.length === 0) return [];

    const gap = " ".repeat(Math.max(0, letterSpacing));
    const rows: boolean[][] = [];

    for (let row = 0; row < GLYPH_HEIGHT; row++) {
      const line = glyphs.map((g) => g[row]).join(gap);
      rows.push(line.split("").map((ch) => ch !== " "));
    }

    return rows;
  }

  /**
   * Чистая функция: рисует один кадр анимации в виде многострочной строки.
   * @param t время в секундах, уже умноженное на speed
   */
  function renderFrame(
    grid: boolean[][],
    mode: AsciiAnimation,
    t: number,
    ramp: string,
  ): string {
    const last = ramp.length - 1;
    const lit = ramp[last] ?? "█";
    const rows = grid.length;
    const width = grid[0]?.length ?? 0;

    const out: string[] = [];

    for (let y = 0; y < rows; y++) {
      let line = "";
      for (let x = 0; x < width; x++) {
        if (!grid[y][x]) {
          line += " ";
          continue;
        }

        switch (mode) {
          case "wave": {
            // Бегущая синусоида выбирает плотность символа из ramp.
            const v = (Math.sin(x * 0.18 - t * 3 + y * 0.25) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "pulse": {
            const v = (Math.sin(t * 2.2) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "scan": {
            // Яркая полоса движется сверху вниз по глифам.
            const band = ((t * 0.6) % 1) * rows;
            const d = Math.abs(y - band);
            const v = Math.max(0, 1 - d / 2);
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "glitch": {
            // Детерминированный хэш, чтобы шум не перемешивался каждый кадр.
            const seed =
              Math.sin(x * 12.9898 + y * 78.233 + Math.floor(t * 8)) *
              43758.5453;
            const n = seed - Math.floor(seed);

            // «Сбойные» ячейки должны быть строго темнее самой яркой ступени,
            // иначе они неотличимы от обычных и эффект не виден.
            if (n > 0.96) {
              line += " ";
            } else if (n > 0.82) {
              const dim = Math.floor(((n - 0.82) / 0.14) * last);
              line +=
                ramp[Math.min(Math.max(last - 1, 0), Math.max(0, dim))] ?? lit;
            } else {
              line += lit;
            }
            break;
          }
          case "typewriter": {
            const revealed = ((t * 12) % (width + 12)) | 0;
            line += x <= revealed ? lit : " ";
            break;
          }
          default:
            line += lit;
        }
      }
      out.push(line);
    }

    return out.join("\n");
  }
</script>

<script lang="ts">
  import type { HTMLAttributes } from "svelte/elements";

  type Props = Omit<HTMLAttributes<HTMLDivElement>, "children" | "color"> & {
    /** Текст для отрисовки. Неподдерживаемые символы пропускаются. */
    text?: string;
    /** Тип анимации глифов. */
    animation?: AsciiAnimation;
    /** Множитель скорости анимации. */
    speed?: number;
    /** Размер глифов в px. Игнорируется при autoFit. */
    fontSize?: number;
    /** Масштабировать глифы так, чтобы строка занимала всю ширину контейнера. */
    autoFit?: boolean;
    /** Пустые колонки между буквами. */
    letterSpacing?: number;
    /** Любой CSS-цвет. По умолчанию наследуется цвет текста. */
    color?: string;
    /** Мягкое свечение за глифами. */
    glow?: boolean;
    /** Символы для «зажжённой» ячейки: от тёмного к светлому. */
    ramp?: string;
  };

  let {
    text = "OPENSOPHY",
    animation = "wave",
    speed = 1,
    fontSize = 14,
    autoFit = true,
    letterSpacing = 2,
    color,
    glow = false,
    ramp = DEFAULT_RAMP,
    class: className,
    ...rest
  }: Props = $props();

  let frame = $state("");
  let containerWidth = $state(0);

  const grid = $derived(buildGrid(text, letterSpacing));
  // Примитивы: эффект перезапускается только при смене формы сетки.
  const rowCount = $derived(grid.length);
  const cols = $derived(grid[0]?.length ?? 0);

  // Моноширинная ячейка ≈ 0.6em шириной.
  const fitSize = $derived(
    autoFit && cols > 0 && containerWidth > 0
      ? Math.max(4, containerWidth / (cols * 0.62))
      : null,
  );
  const size = $derived(autoFit ? (fitSize ?? fontSize) : fontSize);

  $effect(() => {
    // Явные зависимости: форма сетки и тип анимации.
    // speed, ramp и сама сетка читаются внутри rAF-колбэка и не отслеживаются,
    // поэтому смена speed на лету не перезапускает анимацию.
    const mode = animation;
    if (rowCount === 0 || cols === 0) {
      frame = "";
      return;
    }

    const reduceMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    ).matches;

    let raf = 0;
    const start = performance.now();

    const render = (now: number) => {
      const t = reduceMotion ? 0 : ((now - start) / 1000) * speed;
      frame = renderFrame(grid, mode, t, ramp);

      if (mode !== "none" && !reduceMotion) {
        raf = requestAnimationFrame(render);
      }
    };

    raf = requestAnimationFrame(render);
    return () => cancelAnimationFrame(raf);
  });
</script>

<div
  bind:clientWidth={containerWidth}
  class={["w-full overflow-hidden", className]}
  role="img"
  aria-label={text}
  {...rest}
>
  <pre
    aria-hidden="true"
    class="m-0 w-full text-center font-mono leading-none whitespace-pre select-none"
    style:font-size="{size}px"
    style:color
    style:text-shadow={glow ? "0 0 0.5em currentColor" : undefined}>{frame}</pre>
</div>
<script module lang="ts">
  export type AsciiAnimation =
    | "none"
    | "wave"
    | "glitch"
    | "typewriter"
    | "pulse"
    | "scan";

  const GLYPH_HEIGHT = 6;
  const DEFAULT_RAMP = "░▒▓█";

  // Блочный шрифт 8x6. Каждый глиф ровно 8 колонок шириной, чтобы сетка
  // оставалась прямоугольной и поколоночные эффекты совпадали между буквами.
  const FONT: Record<string, string[]> = {
    A: ["  ████  ", " ██  ██ ", "████████", "██    ██", "██    ██", "██    ██"],
    B: ["███████ ", "██    ██", "███████ ", "██    ██", "██    ██", "███████ "],
    C: [" ███████", "██      ", "██      ", "██      ", "██      ", " ███████"],
    D: ["███████ ", "██    ██", "██    ██", "██    ██", "██    ██", "███████ "],
    E: ["████████", "██      ", "██████  ", "██      ", "██      ", "████████"],
    F: ["████████", "██      ", "██████  ", "██      ", "██      ", "██      "],
    G: [" ███████", "██      ", "██  ████", "██    ██", "██    ██", " ███████"],
    H: ["██    ██", "██    ██", "████████", "██    ██", "██    ██", "██    ██"],
    I: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "████████"],
    J: ["████████", "      ██", "      ██", "      ██", "██    ██", " ███████"],
    K: ["██    ██", "██  ██  ", "████    ", "██  ██  ", "██    ██", "██    ██"],
    L: ["██      ", "██      ", "██      ", "██      ", "██      ", "████████"],
    M: ["██    ██", "████████", "██ ██ ██", "██    ██", "██    ██", "██    ██"],
    N: ["██    ██", "███   ██", "██ ██ ██", "██   ███", "██    ██", "██    ██"],
    O: [" ███████", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    P: ["███████ ", "██    ██", "███████ ", "██      ", "██      ", "██      "],
    Q: [" ██████ ", "██    ██", "██    ██", "██ ██ ██", "██   ███", " ███████"],
    R: ["███████ ", "██    ██", "███████ ", "██   ██ ", "██    ██", "██    ██"],
    S: [" ███████", "██      ", " ██████ ", "      ██", "      ██", "███████ "],
    T: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "   ██   "],
    U: ["██    ██", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    V: ["██    ██", "██    ██", "██    ██", " ██  ██ ", "  ████  ", "   ██   "],
    W: ["██    ██", "██    ██", "██    ██", "██ ██ ██", "████████", "██    ██"],
    X: ["██    ██", " ██  ██ ", "  ████  ", "  ████  ", " ██  ██ ", "██    ██"],
    Y: ["██    ██", " ██  ██ ", "  ████  ", "   ██   ", "   ██   ", "   ██   "],
    Z: ["████████", "      ██", "    ██  ", "  ██    ", "██      ", "████████"],
    "0": [" ██████ ", "██    ██", "██    ██", "██    ██", "██    ██", " ██████ "],
    "1": ["   ██   ", "  ███   ", "   ██   ", "   ██   ", "   ██   ", " ██████ "],
    "2": [" ██████ ", "██    ██", "     ██ ", "   ██   ", " ██     ", "████████"],
    "3": [" ██████ ", "██    ██", "    ███ ", "      ██", "██    ██", " ██████ "],
    "4": ["██    ██", "██    ██", "████████", "      ██", "      ██", "      ██"],
    "5": ["████████", "██      ", "███████ ", "      ██", "██    ██", " ██████ "],
    "6": [" ██████ ", "██      ", "███████ ", "██    ██", "██    ██", " ██████ "],
    "7": ["████████", "      ██", "     ██ ", "    ██  ", "   ██   ", "   ██   "],
    "8": [" ██████ ", "██    ██", " ██████ ", "██    ██", "██    ██", " ██████ "],
    "9": [" ██████ ", "██    ██", " ███████", "      ██", "      ██", " ██████ "],
    ".": ["        ", "        ", "        ", "        ", "   ██   ", "   ██   "],
    ",": ["        ", "        ", "        ", "   ██   ", "   ██   ", "  ██    "],
    "!": ["   ██   ", "   ██   ", "   ██   ", "   ██   ", "        ", "   ██   "],
    "?": [" ██████ ", "██    ██", "     ██ ", "   ██   ", "        ", "   ██   "],
    "-": ["        ", "        ", "████████", "        ", "        ", "        "],
    "+": ["        ", "   ██   ", "████████", "   ██   ", "        ", "        "],
    "=": ["        ", "████████", "        ", "████████", "        ", "        "],
    ":": ["        ", "   ██   ", "        ", "        ", "   ██   ", "        "],
    "'": ["   ██   ", "   ██   ", "        ", "        ", "        ", "        "],
    "/": ["      ██", "     ██ ", "    ██  ", "   ██   ", "  ██    ", " ██     "],
    "\\": [
      " ██     ",
      "  ██    ",
      "   ██   ",
      "    ██  ",
      "     ██ ",
      "      ██",
    ],
    "*": ["        ", "██ ██ ██", " ██████ ", "██ ██ ██", "        ", "        "],
    "<": ["     ██ ", "   ██   ", " ██     ", "   ██   ", "     ██ ", "        "],
    ">": [" ██     ", "   ██   ", "     ██ ", "   ██   ", " ██     ", "        "],
    "(": ["    ██  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "    ██  "],
    ")": ["  ██    ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ██    "],
    "[": ["  ████  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "  ████  "],
    "]": ["  ████  ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ████  "],
    "#": [" ██  ██ ", "████████", " ██  ██ ", "████████", " ██  ██ ", "        "],
    "@": [" ██████ ", "██    ██", "██ ██ ██", "██ ████ ", "██      ", " ██████ "],
    _: ["        ", "        ", "        ", "        ", "        ", "████████"],
    " ": ["        ", "        ", "        ", "        ", "        ", "        "],
  };

  /**
   * Раскладывает текст в прямоугольную сетку on/off.
   * Неизвестные символы пропускаются, а не подменяются.
   */
  function buildGrid(text: string, letterSpacing: number): boolean[][] {
    const glyphs = text
      .toUpperCase()
      .split("")
      .map((ch) => FONT[ch])
      .filter((g): g is string[] => Boolean(g));

    if (glyphs.length === 0) return [];

    const gap = " ".repeat(Math.max(0, letterSpacing));
    const rows: boolean[][] = [];

    for (let row = 0; row < GLYPH_HEIGHT; row++) {
      const line = glyphs.map((g) => g[row]).join(gap);
      rows.push(line.split("").map((ch) => ch !== " "));
    }

    return rows;
  }

  /**
   * Чистая функция: рисует один кадр анимации в виде многострочной строки.
   * @param t время в секундах, уже умноженное на speed
   */
  function renderFrame(
    grid: boolean[][],
    mode: AsciiAnimation,
    t: number,
    ramp: string,
  ): string {
    const last = ramp.length - 1;
    const lit = ramp[last] ?? "█";
    const rows = grid.length;
    const width = grid[0]?.length ?? 0;

    const out: string[] = [];

    for (let y = 0; y < rows; y++) {
      let line = "";
      for (let x = 0; x < width; x++) {
        if (!grid[y][x]) {
          line += " ";
          continue;
        }

        switch (mode) {
          case "wave": {
            // Бегущая синусоида выбирает плотность символа из ramp.
            const v = (Math.sin(x * 0.18 - t * 3 + y * 0.25) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "pulse": {
            const v = (Math.sin(t * 2.2) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "scan": {
            // Яркая полоса движется сверху вниз по глифам.
            const band = ((t * 0.6) % 1) * rows;
            const d = Math.abs(y - band);
            const v = Math.max(0, 1 - d / 2);
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "glitch": {
            // Детерминированный хэш, чтобы шум не перемешивался каждый кадр.
            const seed =
              Math.sin(x * 12.9898 + y * 78.233 + Math.floor(t * 8)) *
              43758.5453;
            const n = seed - Math.floor(seed);

            // «Сбойные» ячейки должны быть строго темнее самой яркой ступени,
            // иначе они неотличимы от обычных и эффект не виден.
            if (n > 0.96) {
              line += " ";
            } else if (n > 0.82) {
              const dim = Math.floor(((n - 0.82) / 0.14) * last);
              line +=
                ramp[Math.min(Math.max(last - 1, 0), Math.max(0, dim))] ?? lit;
            } else {
              line += lit;
            }
            break;
          }
          case "typewriter": {
            const revealed = ((t * 12) % (width + 12)) | 0;
            line += x <= revealed ? lit : " ";
            break;
          }
          default:
            line += lit;
        }
      }
      out.push(line);
    }

    return out.join("\n");
  }
</script>

<script lang="ts">
  import type { HTMLAttributes } from "svelte/elements";

  type Props = Omit<HTMLAttributes<HTMLDivElement>, "children" | "color"> & {
    /** Текст для отрисовки. Неподдерживаемые символы пропускаются. */
    text?: string;
    /** Тип анимации глифов. */
    animation?: AsciiAnimation;
    /** Множитель скорости анимации. */
    speed?: number;
    /** Размер глифов в px. Игнорируется при autoFit. */
    fontSize?: number;
    /** Масштабировать глифы так, чтобы строка занимала всю ширину контейнера. */
    autoFit?: boolean;
    /** Пустые колонки между буквами. */
    letterSpacing?: number;
    /** Любой CSS-цвет. По умолчанию наследуется цвет текста. */
    color?: string;
    /** Мягкое свечение за глифами. */
    glow?: boolean;
    /** Символы для «зажжённой» ячейки: от тёмного к светлому. */
    ramp?: string;
  };

  let {
    text = "OPENSOPHY",
    animation = "wave",
    speed = 1,
    fontSize = 14,
    autoFit = true,
    letterSpacing = 2,
    color,
    glow = false,
    ramp = DEFAULT_RAMP,
    class: className,
    ...rest
  }: Props = $props();

  let frame = $state("");
  let containerWidth = $state(0);

  const grid = $derived(buildGrid(text, letterSpacing));
  // Примитивы: эффект перезапускается только при смене формы сетки.
  const rowCount = $derived(grid.length);
  const cols = $derived(grid[0]?.length ?? 0);

  // Моноширинная ячейка ≈ 0.6em шириной.
  const fitSize = $derived(
    autoFit && cols > 0 && containerWidth > 0
      ? Math.max(4, containerWidth / (cols * 0.62))
      : null,
  );
  const size = $derived(autoFit ? (fitSize ?? fontSize) : fontSize);

  $effect(() => {
    // Явные зависимости: форма сетки и тип анимации.
    // speed, ramp и сама сетка читаются внутри rAF-колбэка и не отслеживаются,
    // поэтому смена speed на лету не перезапускает анимацию.
    const mode = animation;
    if (rowCount === 0 || cols === 0) {
      frame = "";
      return;
    }

    const reduceMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    ).matches;

    let raf = 0;
    const start = performance.now();

    const render = (now: number) => {
      const t = reduceMotion ? 0 : ((now - start) / 1000) * speed;
      frame = renderFrame(grid, mode, t, ramp);

      if (mode !== "none" && !reduceMotion) {
        raf = requestAnimationFrame(render);
      }
    };

    raf = requestAnimationFrame(render);
    return () => cancelAnimationFrame(raf);
  });
</script>

<div
  bind:clientWidth={containerWidth}
  class={["w-full overflow-hidden", className]}
  role="img"
  aria-label={text}
  {...rest}
>
  <pre
    aria-hidden="true"
    class="m-0 w-full text-center font-mono leading-none whitespace-pre select-none"
    style:font-size="{size}px"
    style:color
    style:text-shadow={glow ? "0 0 0.5em currentColor" : undefined}>{frame}</pre>
</div>

Статика — none

Постоянная сетка без анимации.

<script module lang="ts">
  export type AsciiAnimation =
    | "none"
    | "wave"
    | "glitch"
    | "typewriter"
    | "pulse"
    | "scan";

  const GLYPH_HEIGHT = 6;
  const DEFAULT_RAMP = "░▒▓█";

  // Блочный шрифт 8x6. Каждый глиф ровно 8 колонок шириной, чтобы сетка
  // оставалась прямоугольной и поколоночные эффекты совпадали между буквами.
  const FONT: Record<string, string[]> = {
    A: ["  ████  ", " ██  ██ ", "████████", "██    ██", "██    ██", "██    ██"],
    B: ["███████ ", "██    ██", "███████ ", "██    ██", "██    ██", "███████ "],
    C: [" ███████", "██      ", "██      ", "██      ", "██      ", " ███████"],
    D: ["███████ ", "██    ██", "██    ██", "██    ██", "██    ██", "███████ "],
    E: ["████████", "██      ", "██████  ", "██      ", "██      ", "████████"],
    F: ["████████", "██      ", "██████  ", "██      ", "██      ", "██      "],
    G: [" ███████", "██      ", "██  ████", "██    ██", "██    ██", " ███████"],
    H: ["██    ██", "██    ██", "████████", "██    ██", "██    ██", "██    ██"],
    I: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "████████"],
    J: ["████████", "      ██", "      ██", "      ██", "██    ██", " ███████"],
    K: ["██    ██", "██  ██  ", "████    ", "██  ██  ", "██    ██", "██    ██"],
    L: ["██      ", "██      ", "██      ", "██      ", "██      ", "████████"],
    M: ["██    ██", "████████", "██ ██ ██", "██    ██", "██    ██", "██    ██"],
    N: ["██    ██", "███   ██", "██ ██ ██", "██   ███", "██    ██", "██    ██"],
    O: [" ███████", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    P: ["███████ ", "██    ██", "███████ ", "██      ", "██      ", "██      "],
    Q: [" ██████ ", "██    ██", "██    ██", "██ ██ ██", "██   ███", " ███████"],
    R: ["███████ ", "██    ██", "███████ ", "██   ██ ", "██    ██", "██    ██"],
    S: [" ███████", "██      ", " ██████ ", "      ██", "      ██", "███████ "],
    T: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "   ██   "],
    U: ["██    ██", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    V: ["██    ██", "██    ██", "██    ██", " ██  ██ ", "  ████  ", "   ██   "],
    W: ["██    ██", "██    ██", "██    ██", "██ ██ ██", "████████", "██    ██"],
    X: ["██    ██", " ██  ██ ", "  ████  ", "  ████  ", " ██  ██ ", "██    ██"],
    Y: ["██    ██", " ██  ██ ", "  ████  ", "   ██   ", "   ██   ", "   ██   "],
    Z: ["████████", "      ██", "    ██  ", "  ██    ", "██      ", "████████"],
    "0": [" ██████ ", "██    ██", "██    ██", "██    ██", "██    ██", " ██████ "],
    "1": ["   ██   ", "  ███   ", "   ██   ", "   ██   ", "   ██   ", " ██████ "],
    "2": [" ██████ ", "██    ██", "     ██ ", "   ██   ", " ██     ", "████████"],
    "3": [" ██████ ", "██    ██", "    ███ ", "      ██", "██    ██", " ██████ "],
    "4": ["██    ██", "██    ██", "████████", "      ██", "      ██", "      ██"],
    "5": ["████████", "██      ", "███████ ", "      ██", "██    ██", " ██████ "],
    "6": [" ██████ ", "██      ", "███████ ", "██    ██", "██    ██", " ██████ "],
    "7": ["████████", "      ██", "     ██ ", "    ██  ", "   ██   ", "   ██   "],
    "8": [" ██████ ", "██    ██", " ██████ ", "██    ██", "██    ██", " ██████ "],
    "9": [" ██████ ", "██    ██", " ███████", "      ██", "      ██", " ██████ "],
    ".": ["        ", "        ", "        ", "        ", "   ██   ", "   ██   "],
    ",": ["        ", "        ", "        ", "   ██   ", "   ██   ", "  ██    "],
    "!": ["   ██   ", "   ██   ", "   ██   ", "   ██   ", "        ", "   ██   "],
    "?": [" ██████ ", "██    ██", "     ██ ", "   ██   ", "        ", "   ██   "],
    "-": ["        ", "        ", "████████", "        ", "        ", "        "],
    "+": ["        ", "   ██   ", "████████", "   ██   ", "        ", "        "],
    "=": ["        ", "████████", "        ", "████████", "        ", "        "],
    ":": ["        ", "   ██   ", "        ", "        ", "   ██   ", "        "],
    "'": ["   ██   ", "   ██   ", "        ", "        ", "        ", "        "],
    "/": ["      ██", "     ██ ", "    ██  ", "   ██   ", "  ██    ", " ██     "],
    "\\": [
      " ██     ",
      "  ██    ",
      "   ██   ",
      "    ██  ",
      "     ██ ",
      "      ██",
    ],
    "*": ["        ", "██ ██ ██", " ██████ ", "██ ██ ██", "        ", "        "],
    "<": ["     ██ ", "   ██   ", " ██     ", "   ██   ", "     ██ ", "        "],
    ">": [" ██     ", "   ██   ", "     ██ ", "   ██   ", " ██     ", "        "],
    "(": ["    ██  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "    ██  "],
    ")": ["  ██    ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ██    "],
    "[": ["  ████  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "  ████  "],
    "]": ["  ████  ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ████  "],
    "#": [" ██  ██ ", "████████", " ██  ██ ", "████████", " ██  ██ ", "        "],
    "@": [" ██████ ", "██    ██", "██ ██ ██", "██ ████ ", "██      ", " ██████ "],
    _: ["        ", "        ", "        ", "        ", "        ", "████████"],
    " ": ["        ", "        ", "        ", "        ", "        ", "        "],
  };

  /**
   * Раскладывает текст в прямоугольную сетку on/off.
   * Неизвестные символы пропускаются, а не подменяются.
   */
  function buildGrid(text: string, letterSpacing: number): boolean[][] {
    const glyphs = text
      .toUpperCase()
      .split("")
      .map((ch) => FONT[ch])
      .filter((g): g is string[] => Boolean(g));

    if (glyphs.length === 0) return [];

    const gap = " ".repeat(Math.max(0, letterSpacing));
    const rows: boolean[][] = [];

    for (let row = 0; row < GLYPH_HEIGHT; row++) {
      const line = glyphs.map((g) => g[row]).join(gap);
      rows.push(line.split("").map((ch) => ch !== " "));
    }

    return rows;
  }

  /**
   * Чистая функция: рисует один кадр анимации в виде многострочной строки.
   * @param t время в секундах, уже умноженное на speed
   */
  function renderFrame(
    grid: boolean[][],
    mode: AsciiAnimation,
    t: number,
    ramp: string,
  ): string {
    const last = ramp.length - 1;
    const lit = ramp[last] ?? "█";
    const rows = grid.length;
    const width = grid[0]?.length ?? 0;

    const out: string[] = [];

    for (let y = 0; y < rows; y++) {
      let line = "";
      for (let x = 0; x < width; x++) {
        if (!grid[y][x]) {
          line += " ";
          continue;
        }

        switch (mode) {
          case "wave": {
            // Бегущая синусоида выбирает плотность символа из ramp.
            const v = (Math.sin(x * 0.18 - t * 3 + y * 0.25) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "pulse": {
            const v = (Math.sin(t * 2.2) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "scan": {
            // Яркая полоса движется сверху вниз по глифам.
            const band = ((t * 0.6) % 1) * rows;
            const d = Math.abs(y - band);
            const v = Math.max(0, 1 - d / 2);
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "glitch": {
            // Детерминированный хэш, чтобы шум не перемешивался каждый кадр.
            const seed =
              Math.sin(x * 12.9898 + y * 78.233 + Math.floor(t * 8)) *
              43758.5453;
            const n = seed - Math.floor(seed);

            // «Сбойные» ячейки должны быть строго темнее самой яркой ступени,
            // иначе они неотличимы от обычных и эффект не виден.
            if (n > 0.96) {
              line += " ";
            } else if (n > 0.82) {
              const dim = Math.floor(((n - 0.82) / 0.14) * last);
              line +=
                ramp[Math.min(Math.max(last - 1, 0), Math.max(0, dim))] ?? lit;
            } else {
              line += lit;
            }
            break;
          }
          case "typewriter": {
            const revealed = ((t * 12) % (width + 12)) | 0;
            line += x <= revealed ? lit : " ";
            break;
          }
          default:
            line += lit;
        }
      }
      out.push(line);
    }

    return out.join("\n");
  }
</script>

<script lang="ts">
  import type { HTMLAttributes } from "svelte/elements";

  type Props = Omit<HTMLAttributes<HTMLDivElement>, "children" | "color"> & {
    /** Текст для отрисовки. Неподдерживаемые символы пропускаются. */
    text?: string;
    /** Тип анимации глифов. */
    animation?: AsciiAnimation;
    /** Множитель скорости анимации. */
    speed?: number;
    /** Размер глифов в px. Игнорируется при autoFit. */
    fontSize?: number;
    /** Масштабировать глифы так, чтобы строка занимала всю ширину контейнера. */
    autoFit?: boolean;
    /** Пустые колонки между буквами. */
    letterSpacing?: number;
    /** Любой CSS-цвет. По умолчанию наследуется цвет текста. */
    color?: string;
    /** Мягкое свечение за глифами. */
    glow?: boolean;
    /** Символы для «зажжённой» ячейки: от тёмного к светлому. */
    ramp?: string;
  };

  let {
    text = "OPENSOPHY",
    animation = "wave",
    speed = 1,
    fontSize = 14,
    autoFit = true,
    letterSpacing = 2,
    color,
    glow = false,
    ramp = DEFAULT_RAMP,
    class: className,
    ...rest
  }: Props = $props();

  let frame = $state("");
  let containerWidth = $state(0);

  const grid = $derived(buildGrid(text, letterSpacing));
  // Примитивы: эффект перезапускается только при смене формы сетки.
  const rowCount = $derived(grid.length);
  const cols = $derived(grid[0]?.length ?? 0);

  // Моноширинная ячейка ≈ 0.6em шириной.
  const fitSize = $derived(
    autoFit && cols > 0 && containerWidth > 0
      ? Math.max(4, containerWidth / (cols * 0.62))
      : null,
  );
  const size = $derived(autoFit ? (fitSize ?? fontSize) : fontSize);

  $effect(() => {
    // Явные зависимости: форма сетки и тип анимации.
    // speed, ramp и сама сетка читаются внутри rAF-колбэка и не отслеживаются,
    // поэтому смена speed на лету не перезапускает анимацию.
    const mode = animation;
    if (rowCount === 0 || cols === 0) {
      frame = "";
      return;
    }

    const reduceMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    ).matches;

    let raf = 0;
    const start = performance.now();

    const render = (now: number) => {
      const t = reduceMotion ? 0 : ((now - start) / 1000) * speed;
      frame = renderFrame(grid, mode, t, ramp);

      if (mode !== "none" && !reduceMotion) {
        raf = requestAnimationFrame(render);
      }
    };

    raf = requestAnimationFrame(render);
    return () => cancelAnimationFrame(raf);
  });
</script>

<div
  bind:clientWidth={containerWidth}
  class={["w-full overflow-hidden", className]}
  role="img"
  aria-label={text}
  {...rest}
>
  <pre
    aria-hidden="true"
    class="m-0 w-full text-center font-mono leading-none whitespace-pre select-none"
    style:font-size="{size}px"
    style:color
    style:text-shadow={glow ? "0 0 0.5em currentColor" : undefined}>{frame}</pre>
</div>
<script module lang="ts">
  export type AsciiAnimation =
    | "none"
    | "wave"
    | "glitch"
    | "typewriter"
    | "pulse"
    | "scan";

  const GLYPH_HEIGHT = 6;
  const DEFAULT_RAMP = "░▒▓█";

  // Блочный шрифт 8x6. Каждый глиф ровно 8 колонок шириной, чтобы сетка
  // оставалась прямоугольной и поколоночные эффекты совпадали между буквами.
  const FONT: Record<string, string[]> = {
    A: ["  ████  ", " ██  ██ ", "████████", "██    ██", "██    ██", "██    ██"],
    B: ["███████ ", "██    ██", "███████ ", "██    ██", "██    ██", "███████ "],
    C: [" ███████", "██      ", "██      ", "██      ", "██      ", " ███████"],
    D: ["███████ ", "██    ██", "██    ██", "██    ██", "██    ██", "███████ "],
    E: ["████████", "██      ", "██████  ", "██      ", "██      ", "████████"],
    F: ["████████", "██      ", "██████  ", "██      ", "██      ", "██      "],
    G: [" ███████", "██      ", "██  ████", "██    ██", "██    ██", " ███████"],
    H: ["██    ██", "██    ██", "████████", "██    ██", "██    ██", "██    ██"],
    I: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "████████"],
    J: ["████████", "      ██", "      ██", "      ██", "██    ██", " ███████"],
    K: ["██    ██", "██  ██  ", "████    ", "██  ██  ", "██    ██", "██    ██"],
    L: ["██      ", "██      ", "██      ", "██      ", "██      ", "████████"],
    M: ["██    ██", "████████", "██ ██ ██", "██    ██", "██    ██", "██    ██"],
    N: ["██    ██", "███   ██", "██ ██ ██", "██   ███", "██    ██", "██    ██"],
    O: [" ███████", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    P: ["███████ ", "██    ██", "███████ ", "██      ", "██      ", "██      "],
    Q: [" ██████ ", "██    ██", "██    ██", "██ ██ ██", "██   ███", " ███████"],
    R: ["███████ ", "██    ██", "███████ ", "██   ██ ", "██    ██", "██    ██"],
    S: [" ███████", "██      ", " ██████ ", "      ██", "      ██", "███████ "],
    T: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "   ██   "],
    U: ["██    ██", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    V: ["██    ██", "██    ██", "██    ██", " ██  ██ ", "  ████  ", "   ██   "],
    W: ["██    ██", "██    ██", "██    ██", "██ ██ ██", "████████", "██    ██"],
    X: ["██    ██", " ██  ██ ", "  ████  ", "  ████  ", " ██  ██ ", "██    ██"],
    Y: ["██    ██", " ██  ██ ", "  ████  ", "   ██   ", "   ██   ", "   ██   "],
    Z: ["████████", "      ██", "    ██  ", "  ██    ", "██      ", "████████"],
    "0": [" ██████ ", "██    ██", "██    ██", "██    ██", "██    ██", " ██████ "],
    "1": ["   ██   ", "  ███   ", "   ██   ", "   ██   ", "   ██   ", " ██████ "],
    "2": [" ██████ ", "██    ██", "     ██ ", "   ██   ", " ██     ", "████████"],
    "3": [" ██████ ", "██    ██", "    ███ ", "      ██", "██    ██", " ██████ "],
    "4": ["██    ██", "██    ██", "████████", "      ██", "      ██", "      ██"],
    "5": ["████████", "██      ", "███████ ", "      ██", "██    ██", " ██████ "],
    "6": [" ██████ ", "██      ", "███████ ", "██    ██", "██    ██", " ██████ "],
    "7": ["████████", "      ██", "     ██ ", "    ██  ", "   ██   ", "   ██   "],
    "8": [" ██████ ", "██    ██", " ██████ ", "██    ██", "██    ██", " ██████ "],
    "9": [" ██████ ", "██    ██", " ███████", "      ██", "      ██", " ██████ "],
    ".": ["        ", "        ", "        ", "        ", "   ██   ", "   ██   "],
    ",": ["        ", "        ", "        ", "   ██   ", "   ██   ", "  ██    "],
    "!": ["   ██   ", "   ██   ", "   ██   ", "   ██   ", "        ", "   ██   "],
    "?": [" ██████ ", "██    ██", "     ██ ", "   ██   ", "        ", "   ██   "],
    "-": ["        ", "        ", "████████", "        ", "        ", "        "],
    "+": ["        ", "   ██   ", "████████", "   ██   ", "        ", "        "],
    "=": ["        ", "████████", "        ", "████████", "        ", "        "],
    ":": ["        ", "   ██   ", "        ", "        ", "   ██   ", "        "],
    "'": ["   ██   ", "   ██   ", "        ", "        ", "        ", "        "],
    "/": ["      ██", "     ██ ", "    ██  ", "   ██   ", "  ██    ", " ██     "],
    "\\": [
      " ██     ",
      "  ██    ",
      "   ██   ",
      "    ██  ",
      "     ██ ",
      "      ██",
    ],
    "*": ["        ", "██ ██ ██", " ██████ ", "██ ██ ██", "        ", "        "],
    "<": ["     ██ ", "   ██   ", " ██     ", "   ██   ", "     ██ ", "        "],
    ">": [" ██     ", "   ██   ", "     ██ ", "   ██   ", " ██     ", "        "],
    "(": ["    ██  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "    ██  "],
    ")": ["  ██    ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ██    "],
    "[": ["  ████  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "  ████  "],
    "]": ["  ████  ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ████  "],
    "#": [" ██  ██ ", "████████", " ██  ██ ", "████████", " ██  ██ ", "        "],
    "@": [" ██████ ", "██    ██", "██ ██ ██", "██ ████ ", "██      ", " ██████ "],
    _: ["        ", "        ", "        ", "        ", "        ", "████████"],
    " ": ["        ", "        ", "        ", "        ", "        ", "        "],
  };

  /**
   * Раскладывает текст в прямоугольную сетку on/off.
   * Неизвестные символы пропускаются, а не подменяются.
   */
  function buildGrid(text: string, letterSpacing: number): boolean[][] {
    const glyphs = text
      .toUpperCase()
      .split("")
      .map((ch) => FONT[ch])
      .filter((g): g is string[] => Boolean(g));

    if (glyphs.length === 0) return [];

    const gap = " ".repeat(Math.max(0, letterSpacing));
    const rows: boolean[][] = [];

    for (let row = 0; row < GLYPH_HEIGHT; row++) {
      const line = glyphs.map((g) => g[row]).join(gap);
      rows.push(line.split("").map((ch) => ch !== " "));
    }

    return rows;
  }

  /**
   * Чистая функция: рисует один кадр анимации в виде многострочной строки.
   * @param t время в секундах, уже умноженное на speed
   */
  function renderFrame(
    grid: boolean[][],
    mode: AsciiAnimation,
    t: number,
    ramp: string,
  ): string {
    const last = ramp.length - 1;
    const lit = ramp[last] ?? "█";
    const rows = grid.length;
    const width = grid[0]?.length ?? 0;

    const out: string[] = [];

    for (let y = 0; y < rows; y++) {
      let line = "";
      for (let x = 0; x < width; x++) {
        if (!grid[y][x]) {
          line += " ";
          continue;
        }

        switch (mode) {
          case "wave": {
            // Бегущая синусоида выбирает плотность символа из ramp.
            const v = (Math.sin(x * 0.18 - t * 3 + y * 0.25) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "pulse": {
            const v = (Math.sin(t * 2.2) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "scan": {
            // Яркая полоса движется сверху вниз по глифам.
            const band = ((t * 0.6) % 1) * rows;
            const d = Math.abs(y - band);
            const v = Math.max(0, 1 - d / 2);
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "glitch": {
            // Детерминированный хэш, чтобы шум не перемешивался каждый кадр.
            const seed =
              Math.sin(x * 12.9898 + y * 78.233 + Math.floor(t * 8)) *
              43758.5453;
            const n = seed - Math.floor(seed);

            // «Сбойные» ячейки должны быть строго темнее самой яркой ступени,
            // иначе они неотличимы от обычных и эффект не виден.
            if (n > 0.96) {
              line += " ";
            } else if (n > 0.82) {
              const dim = Math.floor(((n - 0.82) / 0.14) * last);
              line +=
                ramp[Math.min(Math.max(last - 1, 0), Math.max(0, dim))] ?? lit;
            } else {
              line += lit;
            }
            break;
          }
          case "typewriter": {
            const revealed = ((t * 12) % (width + 12)) | 0;
            line += x <= revealed ? lit : " ";
            break;
          }
          default:
            line += lit;
        }
      }
      out.push(line);
    }

    return out.join("\n");
  }
</script>

<script lang="ts">
  import type { HTMLAttributes } from "svelte/elements";

  type Props = Omit<HTMLAttributes<HTMLDivElement>, "children" | "color"> & {
    /** Текст для отрисовки. Неподдерживаемые символы пропускаются. */
    text?: string;
    /** Тип анимации глифов. */
    animation?: AsciiAnimation;
    /** Множитель скорости анимации. */
    speed?: number;
    /** Размер глифов в px. Игнорируется при autoFit. */
    fontSize?: number;
    /** Масштабировать глифы так, чтобы строка занимала всю ширину контейнера. */
    autoFit?: boolean;
    /** Пустые колонки между буквами. */
    letterSpacing?: number;
    /** Любой CSS-цвет. По умолчанию наследуется цвет текста. */
    color?: string;
    /** Мягкое свечение за глифами. */
    glow?: boolean;
    /** Символы для «зажжённой» ячейки: от тёмного к светлому. */
    ramp?: string;
  };

  let {
    text = "OPENSOPHY",
    animation = "wave",
    speed = 1,
    fontSize = 14,
    autoFit = true,
    letterSpacing = 2,
    color,
    glow = false,
    ramp = DEFAULT_RAMP,
    class: className,
    ...rest
  }: Props = $props();

  let frame = $state("");
  let containerWidth = $state(0);

  const grid = $derived(buildGrid(text, letterSpacing));
  // Примитивы: эффект перезапускается только при смене формы сетки.
  const rowCount = $derived(grid.length);
  const cols = $derived(grid[0]?.length ?? 0);

  // Моноширинная ячейка ≈ 0.6em шириной.
  const fitSize = $derived(
    autoFit && cols > 0 && containerWidth > 0
      ? Math.max(4, containerWidth / (cols * 0.62))
      : null,
  );
  const size = $derived(autoFit ? (fitSize ?? fontSize) : fontSize);

  $effect(() => {
    // Явные зависимости: форма сетки и тип анимации.
    // speed, ramp и сама сетка читаются внутри rAF-колбэка и не отслеживаются,
    // поэтому смена speed на лету не перезапускает анимацию.
    const mode = animation;
    if (rowCount === 0 || cols === 0) {
      frame = "";
      return;
    }

    const reduceMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    ).matches;

    let raf = 0;
    const start = performance.now();

    const render = (now: number) => {
      const t = reduceMotion ? 0 : ((now - start) / 1000) * speed;
      frame = renderFrame(grid, mode, t, ramp);

      if (mode !== "none" && !reduceMotion) {
        raf = requestAnimationFrame(render);
      }
    };

    raf = requestAnimationFrame(render);
    return () => cancelAnimationFrame(raf);
  });
</script>

<div
  bind:clientWidth={containerWidth}
  class={["w-full overflow-hidden", className]}
  role="img"
  aria-label={text}
  {...rest}
>
  <pre
    aria-hidden="true"
    class="m-0 w-full text-center font-mono leading-none whitespace-pre select-none"
    style:font-size="{size}px"
    style:color
    style:text-shadow={glow ? "0 0 0.5em currentColor" : undefined}>{frame}</pre>
</div>

Пользовательский ramp

Символы в ramp задаются от самых тёмных к самым ярким. Например, .:+*#%@ создаёт более тонкую градацию.

<script module lang="ts">
  export type AsciiAnimation =
    | "none"
    | "wave"
    | "glitch"
    | "typewriter"
    | "pulse"
    | "scan";

  const GLYPH_HEIGHT = 6;
  const DEFAULT_RAMP = "░▒▓█";

  // Блочный шрифт 8x6. Каждый глиф ровно 8 колонок шириной, чтобы сетка
  // оставалась прямоугольной и поколоночные эффекты совпадали между буквами.
  const FONT: Record<string, string[]> = {
    A: ["  ████  ", " ██  ██ ", "████████", "██    ██", "██    ██", "██    ██"],
    B: ["███████ ", "██    ██", "███████ ", "██    ██", "██    ██", "███████ "],
    C: [" ███████", "██      ", "██      ", "██      ", "██      ", " ███████"],
    D: ["███████ ", "██    ██", "██    ██", "██    ██", "██    ██", "███████ "],
    E: ["████████", "██      ", "██████  ", "██      ", "██      ", "████████"],
    F: ["████████", "██      ", "██████  ", "██      ", "██      ", "██      "],
    G: [" ███████", "██      ", "██  ████", "██    ██", "██    ██", " ███████"],
    H: ["██    ██", "██    ██", "████████", "██    ██", "██    ██", "██    ██"],
    I: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "████████"],
    J: ["████████", "      ██", "      ██", "      ██", "██    ██", " ███████"],
    K: ["██    ██", "██  ██  ", "████    ", "██  ██  ", "██    ██", "██    ██"],
    L: ["██      ", "██      ", "██      ", "██      ", "██      ", "████████"],
    M: ["██    ██", "████████", "██ ██ ██", "██    ██", "██    ██", "██    ██"],
    N: ["██    ██", "███   ██", "██ ██ ██", "██   ███", "██    ██", "██    ██"],
    O: [" ███████", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    P: ["███████ ", "██    ██", "███████ ", "██      ", "██      ", "██      "],
    Q: [" ██████ ", "██    ██", "██    ██", "██ ██ ██", "██   ███", " ███████"],
    R: ["███████ ", "██    ██", "███████ ", "██   ██ ", "██    ██", "██    ██"],
    S: [" ███████", "██      ", " ██████ ", "      ██", "      ██", "███████ "],
    T: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "   ██   "],
    U: ["██    ██", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    V: ["██    ██", "██    ██", "██    ██", " ██  ██ ", "  ████  ", "   ██   "],
    W: ["██    ██", "██    ██", "██    ██", "██ ██ ██", "████████", "██    ██"],
    X: ["██    ██", " ██  ██ ", "  ████  ", "  ████  ", " ██  ██ ", "██    ██"],
    Y: ["██    ██", " ██  ██ ", "  ████  ", "   ██   ", "   ██   ", "   ██   "],
    Z: ["████████", "      ██", "    ██  ", "  ██    ", "██      ", "████████"],
    "0": [" ██████ ", "██    ██", "██    ██", "██    ██", "██    ██", " ██████ "],
    "1": ["   ██   ", "  ███   ", "   ██   ", "   ██   ", "   ██   ", " ██████ "],
    "2": [" ██████ ", "██    ██", "     ██ ", "   ██   ", " ██     ", "████████"],
    "3": [" ██████ ", "██    ██", "    ███ ", "      ██", "██    ██", " ██████ "],
    "4": ["██    ██", "██    ██", "████████", "      ██", "      ██", "      ██"],
    "5": ["████████", "██      ", "███████ ", "      ██", "██    ██", " ██████ "],
    "6": [" ██████ ", "██      ", "███████ ", "██    ██", "██    ██", " ██████ "],
    "7": ["████████", "      ██", "     ██ ", "    ██  ", "   ██   ", "   ██   "],
    "8": [" ██████ ", "██    ██", " ██████ ", "██    ██", "██    ██", " ██████ "],
    "9": [" ██████ ", "██    ██", " ███████", "      ██", "      ██", " ██████ "],
    ".": ["        ", "        ", "        ", "        ", "   ██   ", "   ██   "],
    ",": ["        ", "        ", "        ", "   ██   ", "   ██   ", "  ██    "],
    "!": ["   ██   ", "   ██   ", "   ██   ", "   ██   ", "        ", "   ██   "],
    "?": [" ██████ ", "██    ██", "     ██ ", "   ██   ", "        ", "   ██   "],
    "-": ["        ", "        ", "████████", "        ", "        ", "        "],
    "+": ["        ", "   ██   ", "████████", "   ██   ", "        ", "        "],
    "=": ["        ", "████████", "        ", "████████", "        ", "        "],
    ":": ["        ", "   ██   ", "        ", "        ", "   ██   ", "        "],
    "'": ["   ██   ", "   ██   ", "        ", "        ", "        ", "        "],
    "/": ["      ██", "     ██ ", "    ██  ", "   ██   ", "  ██    ", " ██     "],
    "\\": [
      " ██     ",
      "  ██    ",
      "   ██   ",
      "    ██  ",
      "     ██ ",
      "      ██",
    ],
    "*": ["        ", "██ ██ ██", " ██████ ", "██ ██ ██", "        ", "        "],
    "<": ["     ██ ", "   ██   ", " ██     ", "   ██   ", "     ██ ", "        "],
    ">": [" ██     ", "   ██   ", "     ██ ", "   ██   ", " ██     ", "        "],
    "(": ["    ██  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "    ██  "],
    ")": ["  ██    ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ██    "],
    "[": ["  ████  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "  ████  "],
    "]": ["  ████  ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ████  "],
    "#": [" ██  ██ ", "████████", " ██  ██ ", "████████", " ██  ██ ", "        "],
    "@": [" ██████ ", "██    ██", "██ ██ ██", "██ ████ ", "██      ", " ██████ "],
    _: ["        ", "        ", "        ", "        ", "        ", "████████"],
    " ": ["        ", "        ", "        ", "        ", "        ", "        "],
  };

  /**
   * Раскладывает текст в прямоугольную сетку on/off.
   * Неизвестные символы пропускаются, а не подменяются.
   */
  function buildGrid(text: string, letterSpacing: number): boolean[][] {
    const glyphs = text
      .toUpperCase()
      .split("")
      .map((ch) => FONT[ch])
      .filter((g): g is string[] => Boolean(g));

    if (glyphs.length === 0) return [];

    const gap = " ".repeat(Math.max(0, letterSpacing));
    const rows: boolean[][] = [];

    for (let row = 0; row < GLYPH_HEIGHT; row++) {
      const line = glyphs.map((g) => g[row]).join(gap);
      rows.push(line.split("").map((ch) => ch !== " "));
    }

    return rows;
  }

  /**
   * Чистая функция: рисует один кадр анимации в виде многострочной строки.
   * @param t время в секундах, уже умноженное на speed
   */
  function renderFrame(
    grid: boolean[][],
    mode: AsciiAnimation,
    t: number,
    ramp: string,
  ): string {
    const last = ramp.length - 1;
    const lit = ramp[last] ?? "█";
    const rows = grid.length;
    const width = grid[0]?.length ?? 0;

    const out: string[] = [];

    for (let y = 0; y < rows; y++) {
      let line = "";
      for (let x = 0; x < width; x++) {
        if (!grid[y][x]) {
          line += " ";
          continue;
        }

        switch (mode) {
          case "wave": {
            // Бегущая синусоида выбирает плотность символа из ramp.
            const v = (Math.sin(x * 0.18 - t * 3 + y * 0.25) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "pulse": {
            const v = (Math.sin(t * 2.2) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "scan": {
            // Яркая полоса движется сверху вниз по глифам.
            const band = ((t * 0.6) % 1) * rows;
            const d = Math.abs(y - band);
            const v = Math.max(0, 1 - d / 2);
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "glitch": {
            // Детерминированный хэш, чтобы шум не перемешивался каждый кадр.
            const seed =
              Math.sin(x * 12.9898 + y * 78.233 + Math.floor(t * 8)) *
              43758.5453;
            const n = seed - Math.floor(seed);

            // «Сбойные» ячейки должны быть строго темнее самой яркой ступени,
            // иначе они неотличимы от обычных и эффект не виден.
            if (n > 0.96) {
              line += " ";
            } else if (n > 0.82) {
              const dim = Math.floor(((n - 0.82) / 0.14) * last);
              line +=
                ramp[Math.min(Math.max(last - 1, 0), Math.max(0, dim))] ?? lit;
            } else {
              line += lit;
            }
            break;
          }
          case "typewriter": {
            const revealed = ((t * 12) % (width + 12)) | 0;
            line += x <= revealed ? lit : " ";
            break;
          }
          default:
            line += lit;
        }
      }
      out.push(line);
    }

    return out.join("\n");
  }
</script>

<script lang="ts">
  import type { HTMLAttributes } from "svelte/elements";

  type Props = Omit<HTMLAttributes<HTMLDivElement>, "children" | "color"> & {
    /** Текст для отрисовки. Неподдерживаемые символы пропускаются. */
    text?: string;
    /** Тип анимации глифов. */
    animation?: AsciiAnimation;
    /** Множитель скорости анимации. */
    speed?: number;
    /** Размер глифов в px. Игнорируется при autoFit. */
    fontSize?: number;
    /** Масштабировать глифы так, чтобы строка занимала всю ширину контейнера. */
    autoFit?: boolean;
    /** Пустые колонки между буквами. */
    letterSpacing?: number;
    /** Любой CSS-цвет. По умолчанию наследуется цвет текста. */
    color?: string;
    /** Мягкое свечение за глифами. */
    glow?: boolean;
    /** Символы для «зажжённой» ячейки: от тёмного к светлому. */
    ramp?: string;
  };

  let {
    text = "OPENSOPHY",
    animation = "wave",
    speed = 1,
    fontSize = 14,
    autoFit = true,
    letterSpacing = 2,
    color,
    glow = false,
    ramp = DEFAULT_RAMP,
    class: className,
    ...rest
  }: Props = $props();

  let frame = $state("");
  let containerWidth = $state(0);

  const grid = $derived(buildGrid(text, letterSpacing));
  // Примитивы: эффект перезапускается только при смене формы сетки.
  const rowCount = $derived(grid.length);
  const cols = $derived(grid[0]?.length ?? 0);

  // Моноширинная ячейка ≈ 0.6em шириной.
  const fitSize = $derived(
    autoFit && cols > 0 && containerWidth > 0
      ? Math.max(4, containerWidth / (cols * 0.62))
      : null,
  );
  const size = $derived(autoFit ? (fitSize ?? fontSize) : fontSize);

  $effect(() => {
    // Явные зависимости: форма сетки и тип анимации.
    // speed, ramp и сама сетка читаются внутри rAF-колбэка и не отслеживаются,
    // поэтому смена speed на лету не перезапускает анимацию.
    const mode = animation;
    if (rowCount === 0 || cols === 0) {
      frame = "";
      return;
    }

    const reduceMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    ).matches;

    let raf = 0;
    const start = performance.now();

    const render = (now: number) => {
      const t = reduceMotion ? 0 : ((now - start) / 1000) * speed;
      frame = renderFrame(grid, mode, t, ramp);

      if (mode !== "none" && !reduceMotion) {
        raf = requestAnimationFrame(render);
      }
    };

    raf = requestAnimationFrame(render);
    return () => cancelAnimationFrame(raf);
  });
</script>

<div
  bind:clientWidth={containerWidth}
  class={["w-full overflow-hidden", className]}
  role="img"
  aria-label={text}
  {...rest}
>
  <pre
    aria-hidden="true"
    class="m-0 w-full text-center font-mono leading-none whitespace-pre select-none"
    style:font-size="{size}px"
    style:color
    style:text-shadow={glow ? "0 0 0.5em currentColor" : undefined}>{frame}</pre>
</div>
<script module lang="ts">
  export type AsciiAnimation =
    | "none"
    | "wave"
    | "glitch"
    | "typewriter"
    | "pulse"
    | "scan";

  const GLYPH_HEIGHT = 6;
  const DEFAULT_RAMP = "░▒▓█";

  // Блочный шрифт 8x6. Каждый глиф ровно 8 колонок шириной, чтобы сетка
  // оставалась прямоугольной и поколоночные эффекты совпадали между буквами.
  const FONT: Record<string, string[]> = {
    A: ["  ████  ", " ██  ██ ", "████████", "██    ██", "██    ██", "██    ██"],
    B: ["███████ ", "██    ██", "███████ ", "██    ██", "██    ██", "███████ "],
    C: [" ███████", "██      ", "██      ", "██      ", "██      ", " ███████"],
    D: ["███████ ", "██    ██", "██    ██", "██    ██", "██    ██", "███████ "],
    E: ["████████", "██      ", "██████  ", "██      ", "██      ", "████████"],
    F: ["████████", "██      ", "██████  ", "██      ", "██      ", "██      "],
    G: [" ███████", "██      ", "██  ████", "██    ██", "██    ██", " ███████"],
    H: ["██    ██", "██    ██", "████████", "██    ██", "██    ██", "██    ██"],
    I: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "████████"],
    J: ["████████", "      ██", "      ██", "      ██", "██    ██", " ███████"],
    K: ["██    ██", "██  ██  ", "████    ", "██  ██  ", "██    ██", "██    ██"],
    L: ["██      ", "██      ", "██      ", "██      ", "██      ", "████████"],
    M: ["██    ██", "████████", "██ ██ ██", "██    ██", "██    ██", "██    ██"],
    N: ["██    ██", "███   ██", "██ ██ ██", "██   ███", "██    ██", "██    ██"],
    O: [" ███████", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    P: ["███████ ", "██    ██", "███████ ", "██      ", "██      ", "██      "],
    Q: [" ██████ ", "██    ██", "██    ██", "██ ██ ██", "██   ███", " ███████"],
    R: ["███████ ", "██    ██", "███████ ", "██   ██ ", "██    ██", "██    ██"],
    S: [" ███████", "██      ", " ██████ ", "      ██", "      ██", "███████ "],
    T: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "   ██   "],
    U: ["██    ██", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    V: ["██    ██", "██    ██", "██    ██", " ██  ██ ", "  ████  ", "   ██   "],
    W: ["██    ██", "██    ██", "██    ██", "██ ██ ██", "████████", "██    ██"],
    X: ["██    ██", " ██  ██ ", "  ████  ", "  ████  ", " ██  ██ ", "██    ██"],
    Y: ["██    ██", " ██  ██ ", "  ████  ", "   ██   ", "   ██   ", "   ██   "],
    Z: ["████████", "      ██", "    ██  ", "  ██    ", "██      ", "████████"],
    "0": [" ██████ ", "██    ██", "██    ██", "██    ██", "██    ██", " ██████ "],
    "1": ["   ██   ", "  ███   ", "   ██   ", "   ██   ", "   ██   ", " ██████ "],
    "2": [" ██████ ", "██    ██", "     ██ ", "   ██   ", " ██     ", "████████"],
    "3": [" ██████ ", "██    ██", "    ███ ", "      ██", "██    ██", " ██████ "],
    "4": ["██    ██", "██    ██", "████████", "      ██", "      ██", "      ██"],
    "5": ["████████", "██      ", "███████ ", "      ██", "██    ██", " ██████ "],
    "6": [" ██████ ", "██      ", "███████ ", "██    ██", "██    ██", " ██████ "],
    "7": ["████████", "      ██", "     ██ ", "    ██  ", "   ██   ", "   ██   "],
    "8": [" ██████ ", "██    ██", " ██████ ", "██    ██", "██    ██", " ██████ "],
    "9": [" ██████ ", "██    ██", " ███████", "      ██", "      ██", " ██████ "],
    ".": ["        ", "        ", "        ", "        ", "   ██   ", "   ██   "],
    ",": ["        ", "        ", "        ", "   ██   ", "   ██   ", "  ██    "],
    "!": ["   ██   ", "   ██   ", "   ██   ", "   ██   ", "        ", "   ██   "],
    "?": [" ██████ ", "██    ██", "     ██ ", "   ██   ", "        ", "   ██   "],
    "-": ["        ", "        ", "████████", "        ", "        ", "        "],
    "+": ["        ", "   ██   ", "████████", "   ██   ", "        ", "        "],
    "=": ["        ", "████████", "        ", "████████", "        ", "        "],
    ":": ["        ", "   ██   ", "        ", "        ", "   ██   ", "        "],
    "'": ["   ██   ", "   ██   ", "        ", "        ", "        ", "        "],
    "/": ["      ██", "     ██ ", "    ██  ", "   ██   ", "  ██    ", " ██     "],
    "\\": [
      " ██     ",
      "  ██    ",
      "   ██   ",
      "    ██  ",
      "     ██ ",
      "      ██",
    ],
    "*": ["        ", "██ ██ ██", " ██████ ", "██ ██ ██", "        ", "        "],
    "<": ["     ██ ", "   ██   ", " ██     ", "   ██   ", "     ██ ", "        "],
    ">": [" ██     ", "   ██   ", "     ██ ", "   ██   ", " ██     ", "        "],
    "(": ["    ██  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "    ██  "],
    ")": ["  ██    ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ██    "],
    "[": ["  ████  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "  ████  "],
    "]": ["  ████  ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ████  "],
    "#": [" ██  ██ ", "████████", " ██  ██ ", "████████", " ██  ██ ", "        "],
    "@": [" ██████ ", "██    ██", "██ ██ ██", "██ ████ ", "██      ", " ██████ "],
    _: ["        ", "        ", "        ", "        ", "        ", "████████"],
    " ": ["        ", "        ", "        ", "        ", "        ", "        "],
  };

  /**
   * Раскладывает текст в прямоугольную сетку on/off.
   * Неизвестные символы пропускаются, а не подменяются.
   */
  function buildGrid(text: string, letterSpacing: number): boolean[][] {
    const glyphs = text
      .toUpperCase()
      .split("")
      .map((ch) => FONT[ch])
      .filter((g): g is string[] => Boolean(g));

    if (glyphs.length === 0) return [];

    const gap = " ".repeat(Math.max(0, letterSpacing));
    const rows: boolean[][] = [];

    for (let row = 0; row < GLYPH_HEIGHT; row++) {
      const line = glyphs.map((g) => g[row]).join(gap);
      rows.push(line.split("").map((ch) => ch !== " "));
    }

    return rows;
  }

  /**
   * Чистая функция: рисует один кадр анимации в виде многострочной строки.
   * @param t время в секундах, уже умноженное на speed
   */
  function renderFrame(
    grid: boolean[][],
    mode: AsciiAnimation,
    t: number,
    ramp: string,
  ): string {
    const last = ramp.length - 1;
    const lit = ramp[last] ?? "█";
    const rows = grid.length;
    const width = grid[0]?.length ?? 0;

    const out: string[] = [];

    for (let y = 0; y < rows; y++) {
      let line = "";
      for (let x = 0; x < width; x++) {
        if (!grid[y][x]) {
          line += " ";
          continue;
        }

        switch (mode) {
          case "wave": {
            // Бегущая синусоида выбирает плотность символа из ramp.
            const v = (Math.sin(x * 0.18 - t * 3 + y * 0.25) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "pulse": {
            const v = (Math.sin(t * 2.2) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "scan": {
            // Яркая полоса движется сверху вниз по глифам.
            const band = ((t * 0.6) % 1) * rows;
            const d = Math.abs(y - band);
            const v = Math.max(0, 1 - d / 2);
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "glitch": {
            // Детерминированный хэш, чтобы шум не перемешивался каждый кадр.
            const seed =
              Math.sin(x * 12.9898 + y * 78.233 + Math.floor(t * 8)) *
              43758.5453;
            const n = seed - Math.floor(seed);

            // «Сбойные» ячейки должны быть строго темнее самой яркой ступени,
            // иначе они неотличимы от обычных и эффект не виден.
            if (n > 0.96) {
              line += " ";
            } else if (n > 0.82) {
              const dim = Math.floor(((n - 0.82) / 0.14) * last);
              line +=
                ramp[Math.min(Math.max(last - 1, 0), Math.max(0, dim))] ?? lit;
            } else {
              line += lit;
            }
            break;
          }
          case "typewriter": {
            const revealed = ((t * 12) % (width + 12)) | 0;
            line += x <= revealed ? lit : " ";
            break;
          }
          default:
            line += lit;
        }
      }
      out.push(line);
    }

    return out.join("\n");
  }
</script>

<script lang="ts">
  import type { HTMLAttributes } from "svelte/elements";

  type Props = Omit<HTMLAttributes<HTMLDivElement>, "children" | "color"> & {
    /** Текст для отрисовки. Неподдерживаемые символы пропускаются. */
    text?: string;
    /** Тип анимации глифов. */
    animation?: AsciiAnimation;
    /** Множитель скорости анимации. */
    speed?: number;
    /** Размер глифов в px. Игнорируется при autoFit. */
    fontSize?: number;
    /** Масштабировать глифы так, чтобы строка занимала всю ширину контейнера. */
    autoFit?: boolean;
    /** Пустые колонки между буквами. */
    letterSpacing?: number;
    /** Любой CSS-цвет. По умолчанию наследуется цвет текста. */
    color?: string;
    /** Мягкое свечение за глифами. */
    glow?: boolean;
    /** Символы для «зажжённой» ячейки: от тёмного к светлому. */
    ramp?: string;
  };

  let {
    text = "OPENSOPHY",
    animation = "wave",
    speed = 1,
    fontSize = 14,
    autoFit = true,
    letterSpacing = 2,
    color,
    glow = false,
    ramp = DEFAULT_RAMP,
    class: className,
    ...rest
  }: Props = $props();

  let frame = $state("");
  let containerWidth = $state(0);

  const grid = $derived(buildGrid(text, letterSpacing));
  // Примитивы: эффект перезапускается только при смене формы сетки.
  const rowCount = $derived(grid.length);
  const cols = $derived(grid[0]?.length ?? 0);

  // Моноширинная ячейка ≈ 0.6em шириной.
  const fitSize = $derived(
    autoFit && cols > 0 && containerWidth > 0
      ? Math.max(4, containerWidth / (cols * 0.62))
      : null,
  );
  const size = $derived(autoFit ? (fitSize ?? fontSize) : fontSize);

  $effect(() => {
    // Явные зависимости: форма сетки и тип анимации.
    // speed, ramp и сама сетка читаются внутри rAF-колбэка и не отслеживаются,
    // поэтому смена speed на лету не перезапускает анимацию.
    const mode = animation;
    if (rowCount === 0 || cols === 0) {
      frame = "";
      return;
    }

    const reduceMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    ).matches;

    let raf = 0;
    const start = performance.now();

    const render = (now: number) => {
      const t = reduceMotion ? 0 : ((now - start) / 1000) * speed;
      frame = renderFrame(grid, mode, t, ramp);

      if (mode !== "none" && !reduceMotion) {
        raf = requestAnimationFrame(render);
      }
    };

    raf = requestAnimationFrame(render);
    return () => cancelAnimationFrame(raf);
  });
</script>

<div
  bind:clientWidth={containerWidth}
  class={["w-full overflow-hidden", className]}
  role="img"
  aria-label={text}
  {...rest}
>
  <pre
    aria-hidden="true"
    class="m-0 w-full text-center font-mono leading-none whitespace-pre select-none"
    style:font-size="{size}px"
    style:color
    style:text-shadow={glow ? "0 0 0.5em currentColor" : undefined}>{frame}</pre>
</div>

Фиксированный размер

При autoFit={false} размер ячейки задаётся в пикселях через fontSize; при включённой автоподгонке строка масштабируется под ширину контейнера.

<script module lang="ts">
  export type AsciiAnimation =
    | "none"
    | "wave"
    | "glitch"
    | "typewriter"
    | "pulse"
    | "scan";

  const GLYPH_HEIGHT = 6;
  const DEFAULT_RAMP = "░▒▓█";

  // Блочный шрифт 8x6. Каждый глиф ровно 8 колонок шириной, чтобы сетка
  // оставалась прямоугольной и поколоночные эффекты совпадали между буквами.
  const FONT: Record<string, string[]> = {
    A: ["  ████  ", " ██  ██ ", "████████", "██    ██", "██    ██", "██    ██"],
    B: ["███████ ", "██    ██", "███████ ", "██    ██", "██    ██", "███████ "],
    C: [" ███████", "██      ", "██      ", "██      ", "██      ", " ███████"],
    D: ["███████ ", "██    ██", "██    ██", "██    ██", "██    ██", "███████ "],
    E: ["████████", "██      ", "██████  ", "██      ", "██      ", "████████"],
    F: ["████████", "██      ", "██████  ", "██      ", "██      ", "██      "],
    G: [" ███████", "██      ", "██  ████", "██    ██", "██    ██", " ███████"],
    H: ["██    ██", "██    ██", "████████", "██    ██", "██    ██", "██    ██"],
    I: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "████████"],
    J: ["████████", "      ██", "      ██", "      ██", "██    ██", " ███████"],
    K: ["██    ██", "██  ██  ", "████    ", "██  ██  ", "██    ██", "██    ██"],
    L: ["██      ", "██      ", "██      ", "██      ", "██      ", "████████"],
    M: ["██    ██", "████████", "██ ██ ██", "██    ██", "██    ██", "██    ██"],
    N: ["██    ██", "███   ██", "██ ██ ██", "██   ███", "██    ██", "██    ██"],
    O: [" ███████", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    P: ["███████ ", "██    ██", "███████ ", "██      ", "██      ", "██      "],
    Q: [" ██████ ", "██    ██", "██    ██", "██ ██ ██", "██   ███", " ███████"],
    R: ["███████ ", "██    ██", "███████ ", "██   ██ ", "██    ██", "██    ██"],
    S: [" ███████", "██      ", " ██████ ", "      ██", "      ██", "███████ "],
    T: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "   ██   "],
    U: ["██    ██", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    V: ["██    ██", "██    ██", "██    ██", " ██  ██ ", "  ████  ", "   ██   "],
    W: ["██    ██", "██    ██", "██    ██", "██ ██ ██", "████████", "██    ██"],
    X: ["██    ██", " ██  ██ ", "  ████  ", "  ████  ", " ██  ██ ", "██    ██"],
    Y: ["██    ██", " ██  ██ ", "  ████  ", "   ██   ", "   ██   ", "   ██   "],
    Z: ["████████", "      ██", "    ██  ", "  ██    ", "██      ", "████████"],
    "0": [" ██████ ", "██    ██", "██    ██", "██    ██", "██    ██", " ██████ "],
    "1": ["   ██   ", "  ███   ", "   ██   ", "   ██   ", "   ██   ", " ██████ "],
    "2": [" ██████ ", "██    ██", "     ██ ", "   ██   ", " ██     ", "████████"],
    "3": [" ██████ ", "██    ██", "    ███ ", "      ██", "██    ██", " ██████ "],
    "4": ["██    ██", "██    ██", "████████", "      ██", "      ██", "      ██"],
    "5": ["████████", "██      ", "███████ ", "      ██", "██    ██", " ██████ "],
    "6": [" ██████ ", "██      ", "███████ ", "██    ██", "██    ██", " ██████ "],
    "7": ["████████", "      ██", "     ██ ", "    ██  ", "   ██   ", "   ██   "],
    "8": [" ██████ ", "██    ██", " ██████ ", "██    ██", "██    ██", " ██████ "],
    "9": [" ██████ ", "██    ██", " ███████", "      ██", "      ██", " ██████ "],
    ".": ["        ", "        ", "        ", "        ", "   ██   ", "   ██   "],
    ",": ["        ", "        ", "        ", "   ██   ", "   ██   ", "  ██    "],
    "!": ["   ██   ", "   ██   ", "   ██   ", "   ██   ", "        ", "   ██   "],
    "?": [" ██████ ", "██    ██", "     ██ ", "   ██   ", "        ", "   ██   "],
    "-": ["        ", "        ", "████████", "        ", "        ", "        "],
    "+": ["        ", "   ██   ", "████████", "   ██   ", "        ", "        "],
    "=": ["        ", "████████", "        ", "████████", "        ", "        "],
    ":": ["        ", "   ██   ", "        ", "        ", "   ██   ", "        "],
    "'": ["   ██   ", "   ██   ", "        ", "        ", "        ", "        "],
    "/": ["      ██", "     ██ ", "    ██  ", "   ██   ", "  ██    ", " ██     "],
    "\\": [
      " ██     ",
      "  ██    ",
      "   ██   ",
      "    ██  ",
      "     ██ ",
      "      ██",
    ],
    "*": ["        ", "██ ██ ██", " ██████ ", "██ ██ ██", "        ", "        "],
    "<": ["     ██ ", "   ██   ", " ██     ", "   ██   ", "     ██ ", "        "],
    ">": [" ██     ", "   ██   ", "     ██ ", "   ██   ", " ██     ", "        "],
    "(": ["    ██  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "    ██  "],
    ")": ["  ██    ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ██    "],
    "[": ["  ████  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "  ████  "],
    "]": ["  ████  ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ████  "],
    "#": [" ██  ██ ", "████████", " ██  ██ ", "████████", " ██  ██ ", "        "],
    "@": [" ██████ ", "██    ██", "██ ██ ██", "██ ████ ", "██      ", " ██████ "],
    _: ["        ", "        ", "        ", "        ", "        ", "████████"],
    " ": ["        ", "        ", "        ", "        ", "        ", "        "],
  };

  /**
   * Раскладывает текст в прямоугольную сетку on/off.
   * Неизвестные символы пропускаются, а не подменяются.
   */
  function buildGrid(text: string, letterSpacing: number): boolean[][] {
    const glyphs = text
      .toUpperCase()
      .split("")
      .map((ch) => FONT[ch])
      .filter((g): g is string[] => Boolean(g));

    if (glyphs.length === 0) return [];

    const gap = " ".repeat(Math.max(0, letterSpacing));
    const rows: boolean[][] = [];

    for (let row = 0; row < GLYPH_HEIGHT; row++) {
      const line = glyphs.map((g) => g[row]).join(gap);
      rows.push(line.split("").map((ch) => ch !== " "));
    }

    return rows;
  }

  /**
   * Чистая функция: рисует один кадр анимации в виде многострочной строки.
   * @param t время в секундах, уже умноженное на speed
   */
  function renderFrame(
    grid: boolean[][],
    mode: AsciiAnimation,
    t: number,
    ramp: string,
  ): string {
    const last = ramp.length - 1;
    const lit = ramp[last] ?? "█";
    const rows = grid.length;
    const width = grid[0]?.length ?? 0;

    const out: string[] = [];

    for (let y = 0; y < rows; y++) {
      let line = "";
      for (let x = 0; x < width; x++) {
        if (!grid[y][x]) {
          line += " ";
          continue;
        }

        switch (mode) {
          case "wave": {
            // Бегущая синусоида выбирает плотность символа из ramp.
            const v = (Math.sin(x * 0.18 - t * 3 + y * 0.25) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "pulse": {
            const v = (Math.sin(t * 2.2) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "scan": {
            // Яркая полоса движется сверху вниз по глифам.
            const band = ((t * 0.6) % 1) * rows;
            const d = Math.abs(y - band);
            const v = Math.max(0, 1 - d / 2);
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "glitch": {
            // Детерминированный хэш, чтобы шум не перемешивался каждый кадр.
            const seed =
              Math.sin(x * 12.9898 + y * 78.233 + Math.floor(t * 8)) *
              43758.5453;
            const n = seed - Math.floor(seed);

            // «Сбойные» ячейки должны быть строго темнее самой яркой ступени,
            // иначе они неотличимы от обычных и эффект не виден.
            if (n > 0.96) {
              line += " ";
            } else if (n > 0.82) {
              const dim = Math.floor(((n - 0.82) / 0.14) * last);
              line +=
                ramp[Math.min(Math.max(last - 1, 0), Math.max(0, dim))] ?? lit;
            } else {
              line += lit;
            }
            break;
          }
          case "typewriter": {
            const revealed = ((t * 12) % (width + 12)) | 0;
            line += x <= revealed ? lit : " ";
            break;
          }
          default:
            line += lit;
        }
      }
      out.push(line);
    }

    return out.join("\n");
  }
</script>

<script lang="ts">
  import type { HTMLAttributes } from "svelte/elements";

  type Props = Omit<HTMLAttributes<HTMLDivElement>, "children" | "color"> & {
    /** Текст для отрисовки. Неподдерживаемые символы пропускаются. */
    text?: string;
    /** Тип анимации глифов. */
    animation?: AsciiAnimation;
    /** Множитель скорости анимации. */
    speed?: number;
    /** Размер глифов в px. Игнорируется при autoFit. */
    fontSize?: number;
    /** Масштабировать глифы так, чтобы строка занимала всю ширину контейнера. */
    autoFit?: boolean;
    /** Пустые колонки между буквами. */
    letterSpacing?: number;
    /** Любой CSS-цвет. По умолчанию наследуется цвет текста. */
    color?: string;
    /** Мягкое свечение за глифами. */
    glow?: boolean;
    /** Символы для «зажжённой» ячейки: от тёмного к светлому. */
    ramp?: string;
  };

  let {
    text = "OPENSOPHY",
    animation = "wave",
    speed = 1,
    fontSize = 14,
    autoFit = true,
    letterSpacing = 2,
    color,
    glow = false,
    ramp = DEFAULT_RAMP,
    class: className,
    ...rest
  }: Props = $props();

  let frame = $state("");
  let containerWidth = $state(0);

  const grid = $derived(buildGrid(text, letterSpacing));
  // Примитивы: эффект перезапускается только при смене формы сетки.
  const rowCount = $derived(grid.length);
  const cols = $derived(grid[0]?.length ?? 0);

  // Моноширинная ячейка ≈ 0.6em шириной.
  const fitSize = $derived(
    autoFit && cols > 0 && containerWidth > 0
      ? Math.max(4, containerWidth / (cols * 0.62))
      : null,
  );
  const size = $derived(autoFit ? (fitSize ?? fontSize) : fontSize);

  $effect(() => {
    // Явные зависимости: форма сетки и тип анимации.
    // speed, ramp и сама сетка читаются внутри rAF-колбэка и не отслеживаются,
    // поэтому смена speed на лету не перезапускает анимацию.
    const mode = animation;
    if (rowCount === 0 || cols === 0) {
      frame = "";
      return;
    }

    const reduceMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    ).matches;

    let raf = 0;
    const start = performance.now();

    const render = (now: number) => {
      const t = reduceMotion ? 0 : ((now - start) / 1000) * speed;
      frame = renderFrame(grid, mode, t, ramp);

      if (mode !== "none" && !reduceMotion) {
        raf = requestAnimationFrame(render);
      }
    };

    raf = requestAnimationFrame(render);
    return () => cancelAnimationFrame(raf);
  });
</script>

<div
  bind:clientWidth={containerWidth}
  class={["w-full overflow-hidden", className]}
  role="img"
  aria-label={text}
  {...rest}
>
  <pre
    aria-hidden="true"
    class="m-0 w-full text-center font-mono leading-none whitespace-pre select-none"
    style:font-size="{size}px"
    style:color
    style:text-shadow={glow ? "0 0 0.5em currentColor" : undefined}>{frame}</pre>
</div>
<script module lang="ts">
  export type AsciiAnimation =
    | "none"
    | "wave"
    | "glitch"
    | "typewriter"
    | "pulse"
    | "scan";

  const GLYPH_HEIGHT = 6;
  const DEFAULT_RAMP = "░▒▓█";

  // Блочный шрифт 8x6. Каждый глиф ровно 8 колонок шириной, чтобы сетка
  // оставалась прямоугольной и поколоночные эффекты совпадали между буквами.
  const FONT: Record<string, string[]> = {
    A: ["  ████  ", " ██  ██ ", "████████", "██    ██", "██    ██", "██    ██"],
    B: ["███████ ", "██    ██", "███████ ", "██    ██", "██    ██", "███████ "],
    C: [" ███████", "██      ", "██      ", "██      ", "██      ", " ███████"],
    D: ["███████ ", "██    ██", "██    ██", "██    ██", "██    ██", "███████ "],
    E: ["████████", "██      ", "██████  ", "██      ", "██      ", "████████"],
    F: ["████████", "██      ", "██████  ", "██      ", "██      ", "██      "],
    G: [" ███████", "██      ", "██  ████", "██    ██", "██    ██", " ███████"],
    H: ["██    ██", "██    ██", "████████", "██    ██", "██    ██", "██    ██"],
    I: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "████████"],
    J: ["████████", "      ██", "      ██", "      ██", "██    ██", " ███████"],
    K: ["██    ██", "██  ██  ", "████    ", "██  ██  ", "██    ██", "██    ██"],
    L: ["██      ", "██      ", "██      ", "██      ", "██      ", "████████"],
    M: ["██    ██", "████████", "██ ██ ██", "██    ██", "██    ██", "██    ██"],
    N: ["██    ██", "███   ██", "██ ██ ██", "██   ███", "██    ██", "██    ██"],
    O: [" ███████", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    P: ["███████ ", "██    ██", "███████ ", "██      ", "██      ", "██      "],
    Q: [" ██████ ", "██    ██", "██    ██", "██ ██ ██", "██   ███", " ███████"],
    R: ["███████ ", "██    ██", "███████ ", "██   ██ ", "██    ██", "██    ██"],
    S: [" ███████", "██      ", " ██████ ", "      ██", "      ██", "███████ "],
    T: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "   ██   "],
    U: ["██    ██", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    V: ["██    ██", "██    ██", "██    ██", " ██  ██ ", "  ████  ", "   ██   "],
    W: ["██    ██", "██    ██", "██    ██", "██ ██ ██", "████████", "██    ██"],
    X: ["██    ██", " ██  ██ ", "  ████  ", "  ████  ", " ██  ██ ", "██    ██"],
    Y: ["██    ██", " ██  ██ ", "  ████  ", "   ██   ", "   ██   ", "   ██   "],
    Z: ["████████", "      ██", "    ██  ", "  ██    ", "██      ", "████████"],
    "0": [" ██████ ", "██    ██", "██    ██", "██    ██", "██    ██", " ██████ "],
    "1": ["   ██   ", "  ███   ", "   ██   ", "   ██   ", "   ██   ", " ██████ "],
    "2": [" ██████ ", "██    ██", "     ██ ", "   ██   ", " ██     ", "████████"],
    "3": [" ██████ ", "██    ██", "    ███ ", "      ██", "██    ██", " ██████ "],
    "4": ["██    ██", "██    ██", "████████", "      ██", "      ██", "      ██"],
    "5": ["████████", "██      ", "███████ ", "      ██", "██    ██", " ██████ "],
    "6": [" ██████ ", "██      ", "███████ ", "██    ██", "██    ██", " ██████ "],
    "7": ["████████", "      ██", "     ██ ", "    ██  ", "   ██   ", "   ██   "],
    "8": [" ██████ ", "██    ██", " ██████ ", "██    ██", "██    ██", " ██████ "],
    "9": [" ██████ ", "██    ██", " ███████", "      ██", "      ██", " ██████ "],
    ".": ["        ", "        ", "        ", "        ", "   ██   ", "   ██   "],
    ",": ["        ", "        ", "        ", "   ██   ", "   ██   ", "  ██    "],
    "!": ["   ██   ", "   ██   ", "   ██   ", "   ██   ", "        ", "   ██   "],
    "?": [" ██████ ", "██    ██", "     ██ ", "   ██   ", "        ", "   ██   "],
    "-": ["        ", "        ", "████████", "        ", "        ", "        "],
    "+": ["        ", "   ██   ", "████████", "   ██   ", "        ", "        "],
    "=": ["        ", "████████", "        ", "████████", "        ", "        "],
    ":": ["        ", "   ██   ", "        ", "        ", "   ██   ", "        "],
    "'": ["   ██   ", "   ██   ", "        ", "        ", "        ", "        "],
    "/": ["      ██", "     ██ ", "    ██  ", "   ██   ", "  ██    ", " ██     "],
    "\\": [
      " ██     ",
      "  ██    ",
      "   ██   ",
      "    ██  ",
      "     ██ ",
      "      ██",
    ],
    "*": ["        ", "██ ██ ██", " ██████ ", "██ ██ ██", "        ", "        "],
    "<": ["     ██ ", "   ██   ", " ██     ", "   ██   ", "     ██ ", "        "],
    ">": [" ██     ", "   ██   ", "     ██ ", "   ██   ", " ██     ", "        "],
    "(": ["    ██  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "    ██  "],
    ")": ["  ██    ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ██    "],
    "[": ["  ████  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "  ████  "],
    "]": ["  ████  ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ████  "],
    "#": [" ██  ██ ", "████████", " ██  ██ ", "████████", " ██  ██ ", "        "],
    "@": [" ██████ ", "██    ██", "██ ██ ██", "██ ████ ", "██      ", " ██████ "],
    _: ["        ", "        ", "        ", "        ", "        ", "████████"],
    " ": ["        ", "        ", "        ", "        ", "        ", "        "],
  };

  /**
   * Раскладывает текст в прямоугольную сетку on/off.
   * Неизвестные символы пропускаются, а не подменяются.
   */
  function buildGrid(text: string, letterSpacing: number): boolean[][] {
    const glyphs = text
      .toUpperCase()
      .split("")
      .map((ch) => FONT[ch])
      .filter((g): g is string[] => Boolean(g));

    if (glyphs.length === 0) return [];

    const gap = " ".repeat(Math.max(0, letterSpacing));
    const rows: boolean[][] = [];

    for (let row = 0; row < GLYPH_HEIGHT; row++) {
      const line = glyphs.map((g) => g[row]).join(gap);
      rows.push(line.split("").map((ch) => ch !== " "));
    }

    return rows;
  }

  /**
   * Чистая функция: рисует один кадр анимации в виде многострочной строки.
   * @param t время в секундах, уже умноженное на speed
   */
  function renderFrame(
    grid: boolean[][],
    mode: AsciiAnimation,
    t: number,
    ramp: string,
  ): string {
    const last = ramp.length - 1;
    const lit = ramp[last] ?? "█";
    const rows = grid.length;
    const width = grid[0]?.length ?? 0;

    const out: string[] = [];

    for (let y = 0; y < rows; y++) {
      let line = "";
      for (let x = 0; x < width; x++) {
        if (!grid[y][x]) {
          line += " ";
          continue;
        }

        switch (mode) {
          case "wave": {
            // Бегущая синусоида выбирает плотность символа из ramp.
            const v = (Math.sin(x * 0.18 - t * 3 + y * 0.25) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "pulse": {
            const v = (Math.sin(t * 2.2) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "scan": {
            // Яркая полоса движется сверху вниз по глифам.
            const band = ((t * 0.6) % 1) * rows;
            const d = Math.abs(y - band);
            const v = Math.max(0, 1 - d / 2);
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "glitch": {
            // Детерминированный хэш, чтобы шум не перемешивался каждый кадр.
            const seed =
              Math.sin(x * 12.9898 + y * 78.233 + Math.floor(t * 8)) *
              43758.5453;
            const n = seed - Math.floor(seed);

            // «Сбойные» ячейки должны быть строго темнее самой яркой ступени,
            // иначе они неотличимы от обычных и эффект не виден.
            if (n > 0.96) {
              line += " ";
            } else if (n > 0.82) {
              const dim = Math.floor(((n - 0.82) / 0.14) * last);
              line +=
                ramp[Math.min(Math.max(last - 1, 0), Math.max(0, dim))] ?? lit;
            } else {
              line += lit;
            }
            break;
          }
          case "typewriter": {
            const revealed = ((t * 12) % (width + 12)) | 0;
            line += x <= revealed ? lit : " ";
            break;
          }
          default:
            line += lit;
        }
      }
      out.push(line);
    }

    return out.join("\n");
  }
</script>

<script lang="ts">
  import type { HTMLAttributes } from "svelte/elements";

  type Props = Omit<HTMLAttributes<HTMLDivElement>, "children" | "color"> & {
    /** Текст для отрисовки. Неподдерживаемые символы пропускаются. */
    text?: string;
    /** Тип анимации глифов. */
    animation?: AsciiAnimation;
    /** Множитель скорости анимации. */
    speed?: number;
    /** Размер глифов в px. Игнорируется при autoFit. */
    fontSize?: number;
    /** Масштабировать глифы так, чтобы строка занимала всю ширину контейнера. */
    autoFit?: boolean;
    /** Пустые колонки между буквами. */
    letterSpacing?: number;
    /** Любой CSS-цвет. По умолчанию наследуется цвет текста. */
    color?: string;
    /** Мягкое свечение за глифами. */
    glow?: boolean;
    /** Символы для «зажжённой» ячейки: от тёмного к светлому. */
    ramp?: string;
  };

  let {
    text = "OPENSOPHY",
    animation = "wave",
    speed = 1,
    fontSize = 14,
    autoFit = true,
    letterSpacing = 2,
    color,
    glow = false,
    ramp = DEFAULT_RAMP,
    class: className,
    ...rest
  }: Props = $props();

  let frame = $state("");
  let containerWidth = $state(0);

  const grid = $derived(buildGrid(text, letterSpacing));
  // Примитивы: эффект перезапускается только при смене формы сетки.
  const rowCount = $derived(grid.length);
  const cols = $derived(grid[0]?.length ?? 0);

  // Моноширинная ячейка ≈ 0.6em шириной.
  const fitSize = $derived(
    autoFit && cols > 0 && containerWidth > 0
      ? Math.max(4, containerWidth / (cols * 0.62))
      : null,
  );
  const size = $derived(autoFit ? (fitSize ?? fontSize) : fontSize);

  $effect(() => {
    // Явные зависимости: форма сетки и тип анимации.
    // speed, ramp и сама сетка читаются внутри rAF-колбэка и не отслеживаются,
    // поэтому смена speed на лету не перезапускает анимацию.
    const mode = animation;
    if (rowCount === 0 || cols === 0) {
      frame = "";
      return;
    }

    const reduceMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    ).matches;

    let raf = 0;
    const start = performance.now();

    const render = (now: number) => {
      const t = reduceMotion ? 0 : ((now - start) / 1000) * speed;
      frame = renderFrame(grid, mode, t, ramp);

      if (mode !== "none" && !reduceMotion) {
        raf = requestAnimationFrame(render);
      }
    };

    raf = requestAnimationFrame(render);
    return () => cancelAnimationFrame(raf);
  });
</script>

<div
  bind:clientWidth={containerWidth}
  class={["w-full overflow-hidden", className]}
  role="img"
  aria-label={text}
  {...rest}
>
  <pre
    aria-hidden="true"
    class="m-0 w-full text-center font-mono leading-none whitespace-pre select-none"
    style:font-size="{size}px"
    style:color
    style:text-shadow={glow ? "0 0 0.5em currentColor" : undefined}>{frame}</pre>
</div>

Цифры и пунктуация

Компонент поддерживает цифры и распространённую пунктуацию, включая !, ?, -, +, =, :, /, скобки и символы # @ _ *.

<script module lang="ts">
  export type AsciiAnimation =
    | "none"
    | "wave"
    | "glitch"
    | "typewriter"
    | "pulse"
    | "scan";

  const GLYPH_HEIGHT = 6;
  const DEFAULT_RAMP = "░▒▓█";

  // Блочный шрифт 8x6. Каждый глиф ровно 8 колонок шириной, чтобы сетка
  // оставалась прямоугольной и поколоночные эффекты совпадали между буквами.
  const FONT: Record<string, string[]> = {
    A: ["  ████  ", " ██  ██ ", "████████", "██    ██", "██    ██", "██    ██"],
    B: ["███████ ", "██    ██", "███████ ", "██    ██", "██    ██", "███████ "],
    C: [" ███████", "██      ", "██      ", "██      ", "██      ", " ███████"],
    D: ["███████ ", "██    ██", "██    ██", "██    ██", "██    ██", "███████ "],
    E: ["████████", "██      ", "██████  ", "██      ", "██      ", "████████"],
    F: ["████████", "██      ", "██████  ", "██      ", "██      ", "██      "],
    G: [" ███████", "██      ", "██  ████", "██    ██", "██    ██", " ███████"],
    H: ["██    ██", "██    ██", "████████", "██    ██", "██    ██", "██    ██"],
    I: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "████████"],
    J: ["████████", "      ██", "      ██", "      ██", "██    ██", " ███████"],
    K: ["██    ██", "██  ██  ", "████    ", "██  ██  ", "██    ██", "██    ██"],
    L: ["██      ", "██      ", "██      ", "██      ", "██      ", "████████"],
    M: ["██    ██", "████████", "██ ██ ██", "██    ██", "██    ██", "██    ██"],
    N: ["██    ██", "███   ██", "██ ██ ██", "██   ███", "██    ██", "██    ██"],
    O: [" ███████", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    P: ["███████ ", "██    ██", "███████ ", "██      ", "██      ", "██      "],
    Q: [" ██████ ", "██    ██", "██    ██", "██ ██ ██", "██   ███", " ███████"],
    R: ["███████ ", "██    ██", "███████ ", "██   ██ ", "██    ██", "██    ██"],
    S: [" ███████", "██      ", " ██████ ", "      ██", "      ██", "███████ "],
    T: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "   ██   "],
    U: ["██    ██", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    V: ["██    ██", "██    ██", "██    ██", " ██  ██ ", "  ████  ", "   ██   "],
    W: ["██    ██", "██    ██", "██    ██", "██ ██ ██", "████████", "██    ██"],
    X: ["██    ██", " ██  ██ ", "  ████  ", "  ████  ", " ██  ██ ", "██    ██"],
    Y: ["██    ██", " ██  ██ ", "  ████  ", "   ██   ", "   ██   ", "   ██   "],
    Z: ["████████", "      ██", "    ██  ", "  ██    ", "██      ", "████████"],
    "0": [" ██████ ", "██    ██", "██    ██", "██    ██", "██    ██", " ██████ "],
    "1": ["   ██   ", "  ███   ", "   ██   ", "   ██   ", "   ██   ", " ██████ "],
    "2": [" ██████ ", "██    ██", "     ██ ", "   ██   ", " ██     ", "████████"],
    "3": [" ██████ ", "██    ██", "    ███ ", "      ██", "██    ██", " ██████ "],
    "4": ["██    ██", "██    ██", "████████", "      ██", "      ██", "      ██"],
    "5": ["████████", "██      ", "███████ ", "      ██", "██    ██", " ██████ "],
    "6": [" ██████ ", "██      ", "███████ ", "██    ██", "██    ██", " ██████ "],
    "7": ["████████", "      ██", "     ██ ", "    ██  ", "   ██   ", "   ██   "],
    "8": [" ██████ ", "██    ██", " ██████ ", "██    ██", "██    ██", " ██████ "],
    "9": [" ██████ ", "██    ██", " ███████", "      ██", "      ██", " ██████ "],
    ".": ["        ", "        ", "        ", "        ", "   ██   ", "   ██   "],
    ",": ["        ", "        ", "        ", "   ██   ", "   ██   ", "  ██    "],
    "!": ["   ██   ", "   ██   ", "   ██   ", "   ██   ", "        ", "   ██   "],
    "?": [" ██████ ", "██    ██", "     ██ ", "   ██   ", "        ", "   ██   "],
    "-": ["        ", "        ", "████████", "        ", "        ", "        "],
    "+": ["        ", "   ██   ", "████████", "   ██   ", "        ", "        "],
    "=": ["        ", "████████", "        ", "████████", "        ", "        "],
    ":": ["        ", "   ██   ", "        ", "        ", "   ██   ", "        "],
    "'": ["   ██   ", "   ██   ", "        ", "        ", "        ", "        "],
    "/": ["      ██", "     ██ ", "    ██  ", "   ██   ", "  ██    ", " ██     "],
    "\\": [
      " ██     ",
      "  ██    ",
      "   ██   ",
      "    ██  ",
      "     ██ ",
      "      ██",
    ],
    "*": ["        ", "██ ██ ██", " ██████ ", "██ ██ ██", "        ", "        "],
    "<": ["     ██ ", "   ██   ", " ██     ", "   ██   ", "     ██ ", "        "],
    ">": [" ██     ", "   ██   ", "     ██ ", "   ██   ", " ██     ", "        "],
    "(": ["    ██  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "    ██  "],
    ")": ["  ██    ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ██    "],
    "[": ["  ████  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "  ████  "],
    "]": ["  ████  ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ████  "],
    "#": [" ██  ██ ", "████████", " ██  ██ ", "████████", " ██  ██ ", "        "],
    "@": [" ██████ ", "██    ██", "██ ██ ██", "██ ████ ", "██      ", " ██████ "],
    _: ["        ", "        ", "        ", "        ", "        ", "████████"],
    " ": ["        ", "        ", "        ", "        ", "        ", "        "],
  };

  /**
   * Раскладывает текст в прямоугольную сетку on/off.
   * Неизвестные символы пропускаются, а не подменяются.
   */
  function buildGrid(text: string, letterSpacing: number): boolean[][] {
    const glyphs = text
      .toUpperCase()
      .split("")
      .map((ch) => FONT[ch])
      .filter((g): g is string[] => Boolean(g));

    if (glyphs.length === 0) return [];

    const gap = " ".repeat(Math.max(0, letterSpacing));
    const rows: boolean[][] = [];

    for (let row = 0; row < GLYPH_HEIGHT; row++) {
      const line = glyphs.map((g) => g[row]).join(gap);
      rows.push(line.split("").map((ch) => ch !== " "));
    }

    return rows;
  }

  /**
   * Чистая функция: рисует один кадр анимации в виде многострочной строки.
   * @param t время в секундах, уже умноженное на speed
   */
  function renderFrame(
    grid: boolean[][],
    mode: AsciiAnimation,
    t: number,
    ramp: string,
  ): string {
    const last = ramp.length - 1;
    const lit = ramp[last] ?? "█";
    const rows = grid.length;
    const width = grid[0]?.length ?? 0;

    const out: string[] = [];

    for (let y = 0; y < rows; y++) {
      let line = "";
      for (let x = 0; x < width; x++) {
        if (!grid[y][x]) {
          line += " ";
          continue;
        }

        switch (mode) {
          case "wave": {
            // Бегущая синусоида выбирает плотность символа из ramp.
            const v = (Math.sin(x * 0.18 - t * 3 + y * 0.25) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "pulse": {
            const v = (Math.sin(t * 2.2) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "scan": {
            // Яркая полоса движется сверху вниз по глифам.
            const band = ((t * 0.6) % 1) * rows;
            const d = Math.abs(y - band);
            const v = Math.max(0, 1 - d / 2);
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "glitch": {
            // Детерминированный хэш, чтобы шум не перемешивался каждый кадр.
            const seed =
              Math.sin(x * 12.9898 + y * 78.233 + Math.floor(t * 8)) *
              43758.5453;
            const n = seed - Math.floor(seed);

            // «Сбойные» ячейки должны быть строго темнее самой яркой ступени,
            // иначе они неотличимы от обычных и эффект не виден.
            if (n > 0.96) {
              line += " ";
            } else if (n > 0.82) {
              const dim = Math.floor(((n - 0.82) / 0.14) * last);
              line +=
                ramp[Math.min(Math.max(last - 1, 0), Math.max(0, dim))] ?? lit;
            } else {
              line += lit;
            }
            break;
          }
          case "typewriter": {
            const revealed = ((t * 12) % (width + 12)) | 0;
            line += x <= revealed ? lit : " ";
            break;
          }
          default:
            line += lit;
        }
      }
      out.push(line);
    }

    return out.join("\n");
  }
</script>

<script lang="ts">
  import type { HTMLAttributes } from "svelte/elements";

  type Props = Omit<HTMLAttributes<HTMLDivElement>, "children" | "color"> & {
    /** Текст для отрисовки. Неподдерживаемые символы пропускаются. */
    text?: string;
    /** Тип анимации глифов. */
    animation?: AsciiAnimation;
    /** Множитель скорости анимации. */
    speed?: number;
    /** Размер глифов в px. Игнорируется при autoFit. */
    fontSize?: number;
    /** Масштабировать глифы так, чтобы строка занимала всю ширину контейнера. */
    autoFit?: boolean;
    /** Пустые колонки между буквами. */
    letterSpacing?: number;
    /** Любой CSS-цвет. По умолчанию наследуется цвет текста. */
    color?: string;
    /** Мягкое свечение за глифами. */
    glow?: boolean;
    /** Символы для «зажжённой» ячейки: от тёмного к светлому. */
    ramp?: string;
  };

  let {
    text = "OPENSOPHY",
    animation = "wave",
    speed = 1,
    fontSize = 14,
    autoFit = true,
    letterSpacing = 2,
    color,
    glow = false,
    ramp = DEFAULT_RAMP,
    class: className,
    ...rest
  }: Props = $props();

  let frame = $state("");
  let containerWidth = $state(0);

  const grid = $derived(buildGrid(text, letterSpacing));
  // Примитивы: эффект перезапускается только при смене формы сетки.
  const rowCount = $derived(grid.length);
  const cols = $derived(grid[0]?.length ?? 0);

  // Моноширинная ячейка ≈ 0.6em шириной.
  const fitSize = $derived(
    autoFit && cols > 0 && containerWidth > 0
      ? Math.max(4, containerWidth / (cols * 0.62))
      : null,
  );
  const size = $derived(autoFit ? (fitSize ?? fontSize) : fontSize);

  $effect(() => {
    // Явные зависимости: форма сетки и тип анимации.
    // speed, ramp и сама сетка читаются внутри rAF-колбэка и не отслеживаются,
    // поэтому смена speed на лету не перезапускает анимацию.
    const mode = animation;
    if (rowCount === 0 || cols === 0) {
      frame = "";
      return;
    }

    const reduceMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    ).matches;

    let raf = 0;
    const start = performance.now();

    const render = (now: number) => {
      const t = reduceMotion ? 0 : ((now - start) / 1000) * speed;
      frame = renderFrame(grid, mode, t, ramp);

      if (mode !== "none" && !reduceMotion) {
        raf = requestAnimationFrame(render);
      }
    };

    raf = requestAnimationFrame(render);
    return () => cancelAnimationFrame(raf);
  });
</script>

<div
  bind:clientWidth={containerWidth}
  class={["w-full overflow-hidden", className]}
  role="img"
  aria-label={text}
  {...rest}
>
  <pre
    aria-hidden="true"
    class="m-0 w-full text-center font-mono leading-none whitespace-pre select-none"
    style:font-size="{size}px"
    style:color
    style:text-shadow={glow ? "0 0 0.5em currentColor" : undefined}>{frame}</pre>
</div>
<script module lang="ts">
  export type AsciiAnimation =
    | "none"
    | "wave"
    | "glitch"
    | "typewriter"
    | "pulse"
    | "scan";

  const GLYPH_HEIGHT = 6;
  const DEFAULT_RAMP = "░▒▓█";

  // Блочный шрифт 8x6. Каждый глиф ровно 8 колонок шириной, чтобы сетка
  // оставалась прямоугольной и поколоночные эффекты совпадали между буквами.
  const FONT: Record<string, string[]> = {
    A: ["  ████  ", " ██  ██ ", "████████", "██    ██", "██    ██", "██    ██"],
    B: ["███████ ", "██    ██", "███████ ", "██    ██", "██    ██", "███████ "],
    C: [" ███████", "██      ", "██      ", "██      ", "██      ", " ███████"],
    D: ["███████ ", "██    ██", "██    ██", "██    ██", "██    ██", "███████ "],
    E: ["████████", "██      ", "██████  ", "██      ", "██      ", "████████"],
    F: ["████████", "██      ", "██████  ", "██      ", "██      ", "██      "],
    G: [" ███████", "██      ", "██  ████", "██    ██", "██    ██", " ███████"],
    H: ["██    ██", "██    ██", "████████", "██    ██", "██    ██", "██    ██"],
    I: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "████████"],
    J: ["████████", "      ██", "      ██", "      ██", "██    ██", " ███████"],
    K: ["██    ██", "██  ██  ", "████    ", "██  ██  ", "██    ██", "██    ██"],
    L: ["██      ", "██      ", "██      ", "██      ", "██      ", "████████"],
    M: ["██    ██", "████████", "██ ██ ██", "██    ██", "██    ██", "██    ██"],
    N: ["██    ██", "███   ██", "██ ██ ██", "██   ███", "██    ██", "██    ██"],
    O: [" ███████", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    P: ["███████ ", "██    ██", "███████ ", "██      ", "██      ", "██      "],
    Q: [" ██████ ", "██    ██", "██    ██", "██ ██ ██", "██   ███", " ███████"],
    R: ["███████ ", "██    ██", "███████ ", "██   ██ ", "██    ██", "██    ██"],
    S: [" ███████", "██      ", " ██████ ", "      ██", "      ██", "███████ "],
    T: ["████████", "   ██   ", "   ██   ", "   ██   ", "   ██   ", "   ██   "],
    U: ["██    ██", "██    ██", "██    ██", "██    ██", "██    ██", " ███████"],
    V: ["██    ██", "██    ██", "██    ██", " ██  ██ ", "  ████  ", "   ██   "],
    W: ["██    ██", "██    ██", "██    ██", "██ ██ ██", "████████", "██    ██"],
    X: ["██    ██", " ██  ██ ", "  ████  ", "  ████  ", " ██  ██ ", "██    ██"],
    Y: ["██    ██", " ██  ██ ", "  ████  ", "   ██   ", "   ██   ", "   ██   "],
    Z: ["████████", "      ██", "    ██  ", "  ██    ", "██      ", "████████"],
    "0": [" ██████ ", "██    ██", "██    ██", "██    ██", "██    ██", " ██████ "],
    "1": ["   ██   ", "  ███   ", "   ██   ", "   ██   ", "   ██   ", " ██████ "],
    "2": [" ██████ ", "██    ██", "     ██ ", "   ██   ", " ██     ", "████████"],
    "3": [" ██████ ", "██    ██", "    ███ ", "      ██", "██    ██", " ██████ "],
    "4": ["██    ██", "██    ██", "████████", "      ██", "      ██", "      ██"],
    "5": ["████████", "██      ", "███████ ", "      ██", "██    ██", " ██████ "],
    "6": [" ██████ ", "██      ", "███████ ", "██    ██", "██    ██", " ██████ "],
    "7": ["████████", "      ██", "     ██ ", "    ██  ", "   ██   ", "   ██   "],
    "8": [" ██████ ", "██    ██", " ██████ ", "██    ██", "██    ██", " ██████ "],
    "9": [" ██████ ", "██    ██", " ███████", "      ██", "      ██", " ██████ "],
    ".": ["        ", "        ", "        ", "        ", "   ██   ", "   ██   "],
    ",": ["        ", "        ", "        ", "   ██   ", "   ██   ", "  ██    "],
    "!": ["   ██   ", "   ██   ", "   ██   ", "   ██   ", "        ", "   ██   "],
    "?": [" ██████ ", "██    ██", "     ██ ", "   ██   ", "        ", "   ██   "],
    "-": ["        ", "        ", "████████", "        ", "        ", "        "],
    "+": ["        ", "   ██   ", "████████", "   ██   ", "        ", "        "],
    "=": ["        ", "████████", "        ", "████████", "        ", "        "],
    ":": ["        ", "   ██   ", "        ", "        ", "   ██   ", "        "],
    "'": ["   ██   ", "   ██   ", "        ", "        ", "        ", "        "],
    "/": ["      ██", "     ██ ", "    ██  ", "   ██   ", "  ██    ", " ██     "],
    "\\": [
      " ██     ",
      "  ██    ",
      "   ██   ",
      "    ██  ",
      "     ██ ",
      "      ██",
    ],
    "*": ["        ", "██ ██ ██", " ██████ ", "██ ██ ██", "        ", "        "],
    "<": ["     ██ ", "   ██   ", " ██     ", "   ██   ", "     ██ ", "        "],
    ">": [" ██     ", "   ██   ", "     ██ ", "   ██   ", " ██     ", "        "],
    "(": ["    ██  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "    ██  "],
    ")": ["  ██    ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ██    "],
    "[": ["  ████  ", "  ██    ", "  ██    ", "  ██    ", "  ██    ", "  ████  "],
    "]": ["  ████  ", "    ██  ", "    ██  ", "    ██  ", "    ██  ", "  ████  "],
    "#": [" ██  ██ ", "████████", " ██  ██ ", "████████", " ██  ██ ", "        "],
    "@": [" ██████ ", "██    ██", "██ ██ ██", "██ ████ ", "██      ", " ██████ "],
    _: ["        ", "        ", "        ", "        ", "        ", "████████"],
    " ": ["        ", "        ", "        ", "        ", "        ", "        "],
  };

  /**
   * Раскладывает текст в прямоугольную сетку on/off.
   * Неизвестные символы пропускаются, а не подменяются.
   */
  function buildGrid(text: string, letterSpacing: number): boolean[][] {
    const glyphs = text
      .toUpperCase()
      .split("")
      .map((ch) => FONT[ch])
      .filter((g): g is string[] => Boolean(g));

    if (glyphs.length === 0) return [];

    const gap = " ".repeat(Math.max(0, letterSpacing));
    const rows: boolean[][] = [];

    for (let row = 0; row < GLYPH_HEIGHT; row++) {
      const line = glyphs.map((g) => g[row]).join(gap);
      rows.push(line.split("").map((ch) => ch !== " "));
    }

    return rows;
  }

  /**
   * Чистая функция: рисует один кадр анимации в виде многострочной строки.
   * @param t время в секундах, уже умноженное на speed
   */
  function renderFrame(
    grid: boolean[][],
    mode: AsciiAnimation,
    t: number,
    ramp: string,
  ): string {
    const last = ramp.length - 1;
    const lit = ramp[last] ?? "█";
    const rows = grid.length;
    const width = grid[0]?.length ?? 0;

    const out: string[] = [];

    for (let y = 0; y < rows; y++) {
      let line = "";
      for (let x = 0; x < width; x++) {
        if (!grid[y][x]) {
          line += " ";
          continue;
        }

        switch (mode) {
          case "wave": {
            // Бегущая синусоида выбирает плотность символа из ramp.
            const v = (Math.sin(x * 0.18 - t * 3 + y * 0.25) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "pulse": {
            const v = (Math.sin(t * 2.2) + 1) / 2;
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "scan": {
            // Яркая полоса движется сверху вниз по глифам.
            const band = ((t * 0.6) % 1) * rows;
            const d = Math.abs(y - band);
            const v = Math.max(0, 1 - d / 2);
            line += ramp[Math.round(v * last)] ?? lit;
            break;
          }
          case "glitch": {
            // Детерминированный хэш, чтобы шум не перемешивался каждый кадр.
            const seed =
              Math.sin(x * 12.9898 + y * 78.233 + Math.floor(t * 8)) *
              43758.5453;
            const n = seed - Math.floor(seed);

            // «Сбойные» ячейки должны быть строго темнее самой яркой ступени,
            // иначе они неотличимы от обычных и эффект не виден.
            if (n > 0.96) {
              line += " ";
            } else if (n > 0.82) {
              const dim = Math.floor(((n - 0.82) / 0.14) * last);
              line +=
                ramp[Math.min(Math.max(last - 1, 0), Math.max(0, dim))] ?? lit;
            } else {
              line += lit;
            }
            break;
          }
          case "typewriter": {
            const revealed = ((t * 12) % (width + 12)) | 0;
            line += x <= revealed ? lit : " ";
            break;
          }
          default:
            line += lit;
        }
      }
      out.push(line);
    }

    return out.join("\n");
  }
</script>

<script lang="ts">
  import type { HTMLAttributes } from "svelte/elements";

  type Props = Omit<HTMLAttributes<HTMLDivElement>, "children" | "color"> & {
    /** Текст для отрисовки. Неподдерживаемые символы пропускаются. */
    text?: string;
    /** Тип анимации глифов. */
    animation?: AsciiAnimation;
    /** Множитель скорости анимации. */
    speed?: number;
    /** Размер глифов в px. Игнорируется при autoFit. */
    fontSize?: number;
    /** Масштабировать глифы так, чтобы строка занимала всю ширину контейнера. */
    autoFit?: boolean;
    /** Пустые колонки между буквами. */
    letterSpacing?: number;
    /** Любой CSS-цвет. По умолчанию наследуется цвет текста. */
    color?: string;
    /** Мягкое свечение за глифами. */
    glow?: boolean;
    /** Символы для «зажжённой» ячейки: от тёмного к светлому. */
    ramp?: string;
  };

  let {
    text = "OPENSOPHY",
    animation = "wave",
    speed = 1,
    fontSize = 14,
    autoFit = true,
    letterSpacing = 2,
    color,
    glow = false,
    ramp = DEFAULT_RAMP,
    class: className,
    ...rest
  }: Props = $props();

  let frame = $state("");
  let containerWidth = $state(0);

  const grid = $derived(buildGrid(text, letterSpacing));
  // Примитивы: эффект перезапускается только при смене формы сетки.
  const rowCount = $derived(grid.length);
  const cols = $derived(grid[0]?.length ?? 0);

  // Моноширинная ячейка ≈ 0.6em шириной.
  const fitSize = $derived(
    autoFit && cols > 0 && containerWidth > 0
      ? Math.max(4, containerWidth / (cols * 0.62))
      : null,
  );
  const size = $derived(autoFit ? (fitSize ?? fontSize) : fontSize);

  $effect(() => {
    // Явные зависимости: форма сетки и тип анимации.
    // speed, ramp и сама сетка читаются внутри rAF-колбэка и не отслеживаются,
    // поэтому смена speed на лету не перезапускает анимацию.
    const mode = animation;
    if (rowCount === 0 || cols === 0) {
      frame = "";
      return;
    }

    const reduceMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    ).matches;

    let raf = 0;
    const start = performance.now();

    const render = (now: number) => {
      const t = reduceMotion ? 0 : ((now - start) / 1000) * speed;
      frame = renderFrame(grid, mode, t, ramp);

      if (mode !== "none" && !reduceMotion) {
        raf = requestAnimationFrame(render);
      }
    };

    raf = requestAnimationFrame(render);
    return () => cancelAnimationFrame(raf);
  });
</script>

<div
  bind:clientWidth={containerWidth}
  class={["w-full overflow-hidden", className]}
  role="img"
  aria-label={text}
  {...rest}
>
  <pre
    aria-hidden="true"
    class="m-0 w-full text-center font-mono leading-none whitespace-pre select-none"
    style:font-size="{size}px"
    style:color
    style:text-shadow={glow ? "0 0 0.5em currentColor" : undefined}>{frame}</pre>
</div>

Использование

<script lang="ts">
  import AsciiText from '$lib/components/ui-registry/AsciiText.svelte';
</script>

<AsciiText
  text="HELLO"
  animation="wave"
  speed={1}
  autoFit
  letterSpacing={2}
  color="#ffffff"
  glow
/>
<script lang="ts">
  import AsciiText from '$lib/components/ui-registry/AsciiText.svelte';
</script>

<AsciiText
  text="HELLO"
  animation="wave"
  speed={1}
  autoFit
  letterSpacing={2}
  color="#ffffff"
  glow
/>

Пропсы

Пропс Тип По умолчанию Описание
text string "OPENSOPHY" Текст для отрисовки. Поддерживаемые буквы A–Z, цифры и перечисленные знаки пунктуации; неизвестные символы пропускаются.
animation "none" "wave" "glitch" "typewriter" "pulse" "scan" "wave" Режим отображения: статичный, волна, глитч, печать, пульсация или сканирование.
speed number 1 Множитель скорости анимации.
fontSize number 14 Размер моноширинной ячейки в пикселях; применяется при выключенном autoFit или пока ширина контейнера ещё неизвестна.
autoFit boolean true Масштабирует текст под ширину контейнера.
letterSpacing number 2 Количество пустых колонок между буквами; отрицательные значения обрезаются до нуля.
color string Наследуется Любой допустимый CSS-цвет.
glow boolean false Добавляет мягкое свечение через text-shadow.
ramp string "░▒▓█" Символы градации от тёмного к яркому.
class string — Дополнительные классы контейнера.
HTML-атрибуты div HTMLAttributes<HTMLDivElement> — Остальные атрибуты передаются корневому контейнеру.

Доступность и движение

Корневой контейнер имеет role="img" и aria-label={text}, а визуальная строка скрыта от скринридеров. При системной настройке prefers-reduced-motion: reduce компонент отображает неподвижный кадр.

Исходный код

Полный исходник AsciiText.svelte приложен к каждому превью и доступен для копирования в блоке кода выше.