website

Personal website
git clone https://github.com/michaelneuper/website.git
README | Files | Log | Refs

website/src/scripts/home/decode.js (5692B)


// The name arrives as bytes with a few bits flipped, so each letter starts out
// as some other character. The flipped bits are corrected one at a time, and
// every correction changes the letter to the character one bit closer, until
// it lands on the right one. The echo area shows the bytes while that happens.
//
// Afterwards, pointing at a letter flips a couple of its bits again, and
// M-x flip-bits replays the whole thing.

import { $, onFrame, reducedMotion } from "../util.js";
import { message, defcommand, goto } from "../emacs/index.js";

const rand = (n) => Math.floor(Math.random() * n);
const bin = (n) => n.toString(2).padStart(8, "0");

// Characters a letter may pass through: printable, and in the serif font
// (it has no backtick).
const printable = (v) => v >= 0x20 && v < 0x7f && v !== 0x60;

function shuffle(a) {
  for (let i = a.length - 1; i > 0; i--) {
    const j = rand(i + 1);
    [a[i], a[j]] = [a[j], a[i]];
  }
  return a;
}

// Pick k bits to flip and the order to correct them in, so that every byte on
// the way back is printable. Letters keep bit 6 (they stay in 0x40-0x7f) and
// the space keeps bits 5 and 6 (it stays in 0x20-0x3f).
function corrupt(code, k) {
  const pool = code === 0x20 ? [0, 1, 2, 3, 4] : [0, 1, 2, 3, 4, 5];
  for (let tries = 0; tries < 50; tries++) {
    const order = shuffle([...pool]).slice(0, k);
    let v = order.reduce((x, b) => x ^ (1 << b), code);
    const path = [v];
    for (const b of order) path.push((v ^= 1 << b));
    if (path.every(printable)) return path; // path[0] corrupted ... path[k] === code
  }
  return [code];
}

function build(h1) {
  const text = h1.textContent.trim();
  h1.setAttribute("aria-label", text);
  h1.textContent = "";
  // Measure on one line, so the space between the words keeps its width.
  h1.style.whiteSpace = "nowrap";
  let word = null;
  const cells = [...text].map((ch) => {
    // Each word's letters sit in a span that doesn't wrap: the browser would
    // otherwise break the line between any two of them.
    if (!word) {
      word = document.createElement("span");
      word.className = "bc-word";
      h1.append(word);
    }
    const el = document.createElement("span");
    el.className = "bc";
    const glyph = document.createElement("span");
    glyph.className = "bc-ch";
    glyph.textContent = ch;
    el.append(glyph);
    word.append(el);
    if (ch === " ") word = null; // the line may break after the space
    return { el, glyph, code: ch.charCodeAt(0), path: [], busy: false };
  });
  // Fix each cell at its final letter's width, so the other characters a letter
  // passes through never push the rest of the name around.
  const fs = parseFloat(getComputedStyle(h1).fontSize);
  const widths = cells.map((c) => c.el.getBoundingClientRect().width);
  cells.forEach((c, i) => { c.el.style.width = `${(widths[i] / fs).toFixed(4)}em`; });
  h1.style.whiteSpace = "";
  h1.classList.add("is-cells");
  return cells;
}

function show(c, v) {
  c.glyph.textContent = String.fromCharCode(v);
  c.el.classList.toggle("is-wrong", v !== c.code);
}

// Step every cell along its path on its own schedule until all are correct.
// onStep runs after any change, for the echo area.
function run(cells, onStep) {
  const t0 = performance.now();
  return new Promise((resolve) => {
    const off = onFrame(() => {
      const t = performance.now() - t0;
      let changed = false;
      let left = 0;
      for (const c of cells) {
        while (c.plan.length && c.plan[0].t <= t) {
          show(c, c.plan.shift().v);
          changed = true;
        }
        left += c.plan.length;
      }
      if (changed) onStep?.();
      if (!left) {
        off();
        resolve();
      }
    });
  });
}

export async function initDecode() {
  const root = document.documentElement;
  const h1 = $(".hero-name");
  const reveal = () => root.classList.remove("boot");
  if (!h1 || reducedMotion.matches) return reveal();

  let cells;
  try {
    // The cells are measured in the serif, so wait for it (briefly).
    await Promise.race([document.fonts.load(`400 64px "Computer Modern"`, "Michael Neuper"), new Promise((r) => setTimeout(r, 1500))]);
    cells = build(h1);
  } catch (err) {
    console.error(err);
    return reveal();
  }
  // the byte each cell shows right now
  const current = (c) => c.glyph.textContent.charCodeAt(0);

  async function decode() {
    if (cells.some((c) => c.busy)) return;
    let flipped = 0;
    cells.forEach((c, j) => {
      c.busy = true;
      const path = corrupt(c.code, 3 + rand(3));
      flipped += path.length - 1;
      show(c, path[0]);
      let t = 150 + j * 55;
      c.plan = path.slice(1).map((v) => ({ v, t: (t += 70 + Math.random() * 50) }));
    });
    const echo = () => message(cells.map((c) => bin(current(c))).join(" "), "", 0);
    reveal();
    echo();
    await run(cells, echo);
    cells.forEach((c) => { c.busy = false; });
    message(`Corrected ${flipped} flipped bits in ${cells.length} bytes.`, "ok", 4000);
  }

  // Point at a letter to flip a couple of its bits.
  for (const c of cells) {
    if (c.code === 0x20) continue;
    c.el.addEventListener("pointerenter", () => {
      if (c.busy || reducedMotion.matches) return;
      c.busy = true;
      const path = corrupt(c.code, 2);
      show(c, path[0]);
      c.plan = path.slice(1).map((v, n) => ({ v, t: 110 + n * 90 }));
      run([c]).then(() => { c.busy = false; });
    });
  }

  defcommand("flip-bits", () => {
    goto("top", true);
    setTimeout(decode, 400);
  }, { doc: "Flip random bits in my name and watch them get corrected" });

  if (root.classList.contains("boot")) await decode();
  else reveal();
}