NEMAWASHI LAB

Crypto

SVG

A visual-cryptography demo: text is rendered to a hidden canvas, then each macro-pixel is split into two shares using a random 2-of-4 sub-pixel pattern (all C(4,2)=6 combinations). Where the text is dark the second share gets the complement pattern; where it is light it duplicates the first. A rAF animation slides share 2 into alignment with easeOutCubic, revealing the message through multiply blending, then fades out and cycles to the next composition.

Source

CryptoCanvas.tsx403 lines
"use client";

import { useEffect, useRef, useState } from "react";
import { motion } from "framer-motion";
import ScrambleLink from "../ScrambleLink";

// ---------------------------------------------------------------------------
// Types & constants
// ---------------------------------------------------------------------------

interface CryptoComposition {
  name: string;
  text: string;
  fontSize: number;
  fontVariation: string;
  bg: string;
  color1: string;
  color2: string;
  uiColor: "light" | "dark";
  macroSize: number;
}

const VB = 1000;

const compositions: CryptoComposition[] = [
  {
    name: "GIRAGIRA",
    text: "GIRAGIRA",
    fontSize: 175,
    fontVariation: "'wght' 800, 'SRIF' 400",
    bg: "#f0eeeb",
    color1: "#7a82c8",
    color2: "#c8604a",
    uiColor: "dark",
    macroSize: 10,
  },
  {
    name: "giragira",
    text: "giragira",
    fontSize: 160,
    fontVariation: "'wght' 700, 'SRIF' 300",
    bg: "#e8e2f0",
    color1: "#5a4aad",
    color2: "#4aad6a",
    uiColor: "dark",
    macroSize: 12,
  },
  {
    name: "Glare",
    text: "Glare",
    fontSize: 250,
    fontVariation: "'wght' 800, 'SRIF' 400",
    bg: "#f5ede0",
    color1: "#c86040",
    color2: "#2a6a8a",
    uiColor: "dark",
    macroSize: 16,
  },
  {
    name: "GIRAGIRA LAB",
    text: "GIRAGIRA LAB",
    fontSize: 115,
    fontVariation: "'wght' 600, 'SRIF' 400",
    bg: "#f0eeeb",
    color1: "#9a9ec8",
    color2: "#2a6a5a",
    uiColor: "dark",
    macroSize: 8,
  },
];

// Sub-pixel offsets within a 2×2 macro-pixel: [col, row]
const SUB: [number, number][] = [
  [0, 0],
  [1, 0],
  [0, 1],
  [1, 1],
];

// All C(4,2)=6 ways to choose 2 of 4 sub-pixels
const PATTERNS: [number, number][] = [
  [0, 1],
  [0, 2],
  [0, 3],
  [1, 2],
  [1, 3],
  [2, 3],
];

function complement(p: [number, number]): [number, number] {
  const set = new Set(p);
  return [0, 1, 2, 3].filter((i) => !set.has(i)) as unknown as [
    number,
    number,
  ];
}

// ---------------------------------------------------------------------------
// Share computation
// ---------------------------------------------------------------------------

interface ShareData {
  path1: string;
  path2: string;
}

function computeShares(comp: CryptoComposition): ShareData {
  const fontFamily =
    getComputedStyle(document.documentElement)
      .getPropertyValue("--font-kt-flux")
      .trim() || "sans-serif";

  const canvas = document.createElement("canvas");
  canvas.width = VB;
  canvas.height = VB;
  const ctx = canvas.getContext("2d")!;
  ctx.font = `${comp.fontSize}px ${fontFamily}`;
  (ctx as unknown as { fontVariationSettings: string }).fontVariationSettings =
    comp.fontVariation;
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.fillStyle = "#000";
  ctx.fillText(comp.text, VB / 2, VB / 2);

  const { data } = ctx.getImageData(0, 0, VB, VB);

  const half = comp.macroSize / 2;
  const cols = Math.floor(VB / comp.macroSize);
  const rows = Math.floor(VB / comp.macroSize);
  const ox = (VB - cols * comp.macroSize) / 2;
  const oy = (VB - rows * comp.macroSize) / 2;

  // Build a single path string per share instead of thousands of rects
  const parts1: string[] = [];
  const parts2: string[] = [];

  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      const mx = ox + c * comp.macroSize;
      const my = oy + r * comp.macroSize;

      const px = Math.round(mx + comp.macroSize / 2);
      const py = Math.round(my + comp.macroSize / 2);
      const inside =
        px >= 0 &&
        px < VB &&
        py >= 0 &&
        py < VB &&
        data[(py * VB + px) * 4 + 3] > 128;

      const pattern = PATTERNS[Math.floor(Math.random() * PATTERNS.length)];

      for (const idx of pattern) {
        const x = mx + SUB[idx][0] * half;
        const y = my + SUB[idx][1] * half;
        parts1.push(`M${x} ${y}h${half}v${half}h${-half}Z`);
      }

      const s2pattern = inside ? complement(pattern) : [...pattern];
      for (const idx of s2pattern) {
        const x = mx + SUB[idx][0] * half;
        const y = my + SUB[idx][1] * half;
        parts2.push(`M${x} ${y}h${half}v${half}h${-half}Z`);
      }
    }
  }

  return { path1: parts1.join(""), path2: parts2.join("") };
}

// ---------------------------------------------------------------------------
// Easing
// ---------------------------------------------------------------------------

function easeOutCubic(t: number): number {
  return 1 - Math.pow(1 - t, 3);
}

function easeInCubic(t: number): number {
  return t * t * t;
}

// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------

export default function CryptoCanvas() {
  const [allShares, setAllShares] = useState<ShareData[] | null>(null);
  const [index, setIndex] = useState(0);

  const share2Ref = useRef<SVGGElement>(null);
  const svgRef = useRef<SVGSVGElement>(null);
  const containerRef = useRef<HTMLDivElement>(null);
  const rafRef = useRef(0);
  const indexRef = useRef(0);
  const cycleStartRef = useRef(0);

  const comp = compositions[index];
  const isLight = comp.uiColor === "light";
  const shareData = allShares ? allShares[index] : null;

  // Pre-compute all shares on mount
  useEffect(() => {
    setAllShares(compositions.map((c) => computeShares(c)));
  }, []);

  // Animation loop
  useEffect(() => {
    if (!allShares) return;

    const FADE_IN = 400;
    const SLIDE = 400;
    const HOLD = 300;
    const FADE_OUT = 500;
    const CYCLE = FADE_IN + SLIDE + HOLD + FADE_OUT;
    const OFFSET = 60;

    cycleStartRef.current = performance.now();

    function animate(now: number) {
      const elapsed = now - cycleStartRef.current;
      const svg = svgRef.current;
      const g = share2Ref.current;
      const container = containerRef.current;

      if (!svg || !g || !container) {
        rafRef.current = requestAnimationFrame(animate);
        return;
      }

      if (elapsed >= CYCLE) {
        // Hide, switch, restart
        svg.style.opacity = "0";
        cycleStartRef.current = now;
        indexRef.current =
          (indexRef.current + 1) % compositions.length;
        // Transition background
        container.style.backgroundColor =
          compositions[indexRef.current].bg;
        setIndex(indexRef.current);
        rafRef.current = requestAnimationFrame(animate);
        return;
      }

      if (elapsed < FADE_IN) {
        // Fade in, share 2 waiting off-screen left
        const t = elapsed / FADE_IN;
        svg.style.opacity = String(t);
        g.setAttribute("transform", `translate(${-OFFSET}, 0)`);
      } else if (elapsed < FADE_IN + SLIDE) {
        // Slide share 2 in from left
        svg.style.opacity = "1";
        const t = easeOutCubic((elapsed - FADE_IN) / SLIDE);
        g.setAttribute(
          "transform",
          `translate(${-OFFSET * (1 - t)}, 0)`
        );
      } else if (elapsed < FADE_IN + SLIDE + HOLD) {
        // Hold — text revealed
        g.setAttribute("transform", "translate(0, 0)");
      } else {
        // Fade out
        const t = easeInCubic(
          (elapsed - FADE_IN - SLIDE - HOLD) / FADE_OUT
        );
        svg.style.opacity = String(1 - t);
      }

      rafRef.current = requestAnimationFrame(animate);
    }

    rafRef.current = requestAnimationFrame(animate);
    return () => cancelAnimationFrame(rafRef.current);
  }, [allShares]);

  // Export both shares as separate SVGs
  const handleExport = (e: React.MouseEvent) => {
    e.stopPropagation();
    if (!shareData) return;

    function downloadShare(pathD: string, color: string, label: string) {
      const svg = `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${VB} ${VB}" width="${VB}" height="${VB}">
  <rect width="${VB}" height="${VB}" fill="${comp.bg}"/>
  <path fill="${color}" d="${pathD}"/>
</svg>`;

      const blob = new Blob([svg], { type: "image/svg+xml" });
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = `crypto-${comp.name}-${label}.svg`;
      a.click();
      URL.revokeObjectURL(url);
    }

    downloadShare(shareData.path1, comp.color1, "share1");
    setTimeout(
      () => downloadShare(shareData.path2, comp.color2, "share2"),
      200
    );
  };

  if (!shareData) {
    return (
      <div className="fixed inset-0" style={{ backgroundColor: comp.bg }} />
    );
  }

  return (
    <div
      ref={containerRef}
      className="fixed inset-0 select-none"
      style={{
        backgroundColor: comp.bg,
        transition: "background-color 0.5s ease",
      }}
    >
      <svg
        ref={svgRef}
        className="absolute inset-0 w-full h-full"
        viewBox={`0 0 ${VB} ${VB}`}
        preserveAspectRatio="xMidYMid slice"
        overflow="hidden"
        style={{ opacity: 0 }}
      >
        {/* Share 1 — bottom layer, single path */}
        <path fill={comp.color1} d={shareData.path1} />

        {/* Share 2 — top layer, animated, multiply blend */}
        <g
          ref={share2Ref}
          style={{ mixBlendMode: "multiply" }}
          transform="translate(-60, 0)"
        >
          <path fill={comp.color2} d={shareData.path2} />
        </g>
      </svg>

      {/* Export */}
      <button
        onClick={handleExport}
        className="fixed top-8 right-8 z-10 pointer-events-auto font-[family-name:var(--font-flux)] text-[12px] tracking-[0.08em] uppercase transition-opacity hover:opacity-100"
        style={{
          fontVariationSettings: "'wght' 300, 'SRIF' 100",
          color: isLight ? "rgba(255,255,255,0.4)" : "rgba(23,23,23,0.4)",
        }}
      >
        Export SVG
      </button>

      {/* Label */}
      <div
        className="fixed bottom-8 left-8 pointer-events-none z-10 font-[family-name:var(--font-flux)]"
        style={{ fontVariationSettings: "'wght' 300, 'SRIF' 100" }}
      >
        <motion.div
          key={comp.name}
          initial={{ opacity: 0, y: 10 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.4, delay: 0.2 }}
        >
          <motion.p
            className="text-[12px] tracking-[0.08em] uppercase"
            animate={{
              color: isLight
                ? "rgba(255,255,255,0.5)"
                : "rgba(23,23,23,0.5)",
            }}
            transition={{ duration: 0.6 }}
          >
            {comp.name}
          </motion.p>
          <motion.p
            className="text-[12px] tracking-[0.08em] mt-1"
            animate={{
              color: isLight
                ? "rgba(255,255,255,0.3)"
                : "rgba(23,23,23,0.3)",
            }}
            transition={{ duration: 0.6 }}
          >
            {index + 1} / {compositions.length}
          </motion.p>
        </motion.div>
      </div>

      {/* Back */}
      <div className="fixed top-8 left-8 z-10">
        <ScrambleLink
          from="NEMAWASHI LAB"
          to="← BACK"
          href="/lab"
          className={`text-[14px] tracking-[0.08em] uppercase pointer-events-auto font-[family-name:var(--font-flux)] ${
            isLight ? "text-white/60" : "text-foreground/60"
          }`}
          style={{ fontVariationSettings: "'wght' 600, 'SRIF' 400" }}
        />
      </div>
    </div>
  );
}

NEMAWASHI — Kotaro Abe