Baktiyar Ablaikhan

Screenprint loop

Source
arlan.me/vault ↗
Author
Arlan
Licence
MIT

Loading…

A sprayed two-colour screenprint that changes motif and colours every three seconds; the pointer lays the ink on heavier.

Code

"use client";

import { CanvasStudy, hash, makeCanvas, type Frame, type Painter } from "./stage";

export const BG = "#efe8da";
const PAPER = [239, 232, 218];
const STEP = 3; // a new print every three seconds
const PALETTES: [string, string][] = [
  ["#e8452c", "#1d3fa8"],
  ["#1a1a1a", "#f0b400"],
  ["#2a7b4f", "#e8452c"],
  ["#ff6fa8", "#1d1d6b"],
  ["#1d3fa8", "#f08a24"],
  ["#7a2fbf", "#22a37a"],
  ["#d6262f", "#1a1a1a"],
];
const rgb = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));

type Plate = (c: CanvasRenderingContext2D, w: number, h: number, tau: number, seed: number, A: string, B: string) => void;

const A_INK = "rgb(255,0,0)";
const B_INK = "rgb(0,255,0)";

const MOTIFS: Plate[] = [
  // rings, each broken by a turning gap
  (c, w, h, tau, seed) => {
    const cols = 4;
    const rows = 3;
    const cell = Math.min(w / cols, h / rows);
    c.lineWidth = cell * 0.13;
    for (let j = 0; j < rows; j++)
      for (let i = 0; i < cols; i++) {
        const x = w / 2 + (i - (cols - 1) / 2) * cell;
        const y = h / 2 + (j - (rows - 1) / 2) * cell;
        const spin = tau * (1 + hash(i + j * 9, seed) * 2) * (hash(i, j + seed) > 0.5 ? 1 : -1);
        c.strokeStyle = (i + j) % 2 ? A_INK : B_INK;
        c.beginPath();
        c.arc(x, y, cell * 0.32, spin + 0.7, spin + Math.PI * 2 - 0.3);
        c.stroke();
      }
  },
  // pinwheel of wedges
  (c, w, h, tau) => {
    const n = 14;
    const R = Math.hypot(w, h);
    for (let i = 0; i < n; i++) {
      const a = (i / n) * Math.PI * 2 + tau * 0.6;
      c.fillStyle = i % 2 ? A_INK : B_INK;
      c.beginPath();
      c.moveTo(w / 2, h / 2);
      c.arc(w / 2, h / 2, R * (i % 2 ? 0.5 : 0.3), a, a + (Math.PI * 2) / n);
      c.closePath();
      c.fill();
    }
  },
  // a many-pointed starburst jumping around its plate
  (c, w, h, tau, seed) => {
    const pw = w * 0.62;
    const ph = h * 0.7;
    c.fillStyle = A_INK;
    c.fillRect((w - pw) / 2, (h - ph) / 2, pw, ph);
    const jump = Math.floor(tau * 4);
    const cx = w / 2 + (hash(jump, seed) - 0.5) * pw * 0.6;
    const cy = h / 2 + (hash(jump, seed + 1) - 0.5) * ph * 0.6;
    const R = Math.min(pw, ph) * 0.32;
    const pts = 22;
    c.fillStyle = B_INK;
    c.beginPath();
    for (let i = 0; i <= pts * 2; i++) {
      const a = (i / (pts * 2)) * Math.PI * 2 + jump * 0.3;
      const r = i % 2 ? R * 0.45 : R;
      c.lineTo(cx + Math.cos(a) * r, cy + Math.sin(a) * r);
    }
    c.fill();
  },
  // a field of dots swelling under a passing wave
  (c, w, h, tau) => {
    const gap = Math.min(w, h) / 7;
    const wave = -w * 0.3 + (tau / STEP) * w * 1.6;
    for (let y = gap / 2; y < h; y += gap)
      for (let x = gap / 2; x < w; x += gap) {
        const d = x + y * 0.4 - wave;
        const swell = Math.exp(-(d * d) / (w * w * 0.03));
        c.fillStyle = swell > 0.6 ? B_INK : A_INK;
        c.beginPath();
        c.arc(x, y, gap * (0.12 + 0.36 * swell), 0, Math.PI * 2);
        c.fill();
      }
  },
  // thick bars sliding across each other
  (c, w, h, tau) => {
    const bh = h / 7;
    for (let i = 0; i < 4; i++) {
      const y = bh * (0.5 + i * 2);
      const x = ((tau * 0.25 + i * 0.3) % 1.4) * w * (i % 2 ? -1 : 1) + (i % 2 ? w : -w * 0.4);
      c.fillStyle = A_INK;
      c.fillRect(x, y, w * 0.9, bh);
    }
    c.globalCompositeOperation = "lighter";
    const bw = w / 9;
    for (let i = 0; i < 4; i++) {
      const x = bw * (0.7 + i * 2.2);
      const y = ((tau * 0.3 + i * 0.25) % 1.4) * h * 1.2 - h * 0.6;
      c.fillStyle = B_INK;
      c.fillRect(x, y, bw, h * 0.9);
    }
    c.globalCompositeOperation = "source-over";
  },
  // a crowd of circles drifting together into blobs
  (c, w, h, tau, seed) => {
    c.globalCompositeOperation = "lighter";
    const k = Math.min(1, tau / (STEP * 0.85));
    for (let i = 0; i < 14; i++) {
      const sx = hash(i, seed) * w;
      const sy = hash(i, seed + 5) * h;
      const tx = w / 2 + (hash(i, seed + 9) - 0.5) * w * 0.3;
      const ty = h / 2 + (hash(i, seed + 11) - 0.5) * h * 0.3;
      const x = sx + (tx - sx) * k * 0.8;
      const y = sy + (ty - sy) * k * 0.8;
      const r = Math.min(w, h) * (0.08 + hash(i, 3) * 0.06) * 1.6;
      const g = c.createRadialGradient(x, y, 0, x, y, r);
      const ink = i % 3 ? "255,0,0" : "0,255,0";
      g.addColorStop(0, `rgba(${ink},0.9)`);
      g.addColorStop(0.5, `rgba(${ink},0.55)`);
      g.addColorStop(1, `rgba(${ink},0)`);
      c.fillStyle = g;
      c.fillRect(x - r, y - r, r * 2, r * 2);
    }
    c.globalCompositeOperation = "source-over";
  },
];

export function create(): Painter {
  let work: ReturnType<typeof makeCanvas> | null = null;
  let mask: ReturnType<typeof makeCanvas> | null = null;
  let heat = 0;

  return ({ ctx, w, h, t, dt, p }: Frame) => {
    const W = Math.max(60, Math.min(460, Math.round(w)));
    const H = Math.max(40, Math.round((h * W) / w));
    if (!work || work.c.width !== W || work.c.height !== H) {
      work = makeCanvas(W, H);
      mask = makeCanvas(Math.ceil(W / 3), Math.ceil(H / 3));
    }
    if (!mask) return;
    const print = Math.floor(t / STEP);
    const tau = t - print * STEP;
    const [A, B] = PALETTES[print % PALETTES.length];
    const m = mask.ctx;
    const mw = mask.c.width;
    const mh = mask.c.height;
    m.globalCompositeOperation = "source-over";
    m.fillStyle = "#000";
    m.fillRect(0, 0, mw, mh);
    MOTIFS[print % MOTIFS.length](m, mw, mh, tau, print * 17, A, B);

    // soft coverage from the small plate, then a fresh spray of dots every frame
    const wc = work.ctx;
    wc.imageSmoothingEnabled = true;
    wc.drawImage(mask.c, 0, 0, W, H);
    const img = wc.getImageData(0, 0, W, H);
    const d = img.data;
    const ca = rgb(A);
    const cb = rgb(B);
    heat += ((p.inside ? 1 : 0) - heat) * Math.min(1, dt * 4);
    const px = (p.x / w) * W;
    const py = (p.y / h) * H;
    const r2 = (W * 0.16) ** 2;
    for (let y = 0; y < H; y++) {
      for (let x = 0; x < W; x++) {
        const i = (y * W + x) * 4;
        let thr = Math.random();
        if (heat > 0.01) {
          const dx = x - px;
          const dy = y - py;
          thr *= 1 - 0.6 * heat * Math.exp(-(dx * dx + dy * dy) / r2);
        }
        const a = d[i] / 255;
        const b = d[i + 1] / 255;
        const col = b > thr * 0.9 + 0.05 ? cb : a > thr * 0.9 + 0.05 ? ca : null;
        if (col) {
          d[i] = col[0];
          d[i + 1] = col[1];
          d[i + 2] = col[2];
        } else {
          const n = (Math.random() - 0.5) * 8;
          d[i] = PAPER[0] + n;
          d[i + 1] = PAPER[1] + n;
          d[i + 2] = PAPER[2] + n;
        }
        d[i + 3] = 255;
      }
    }
    wc.putImageData(img, 0, 0);
    ctx.imageSmoothingEnabled = W < w * 0.7;
    // the whole print shivers a hair, as if redrawn by hand
    const jx = (Math.random() - 0.5) * 1.2;
    const jy = (Math.random() - 0.5) * 1.2;
    ctx.drawImage(work.c, jx - 1, jy - 1, w + 2, h + 2);
  };
}

export default function Screenprint() {
  return (
    <CanvasStudy
      create={create}
      background={BG}
      still={1.2}
      label="A two-colour screenprint on cream that changes every three seconds — rings, pinwheel, starburst, dot wave, bars, blobs — with sprayed edges; the pointer lays the ink on heavier."
    />
  );
}
Prompt

Build "Screenprint loop", a canvas animation. What it does: A sprayed two-colour screenprint that changes motif and colours every three seconds; the pointer lays the ink on heavier. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS. Technique: an HTML canvas, a requestAnimationFrame loop, pointer tracking. It sits on a light background, in a 16:10 frame. Respect prefers-reduced-motion with a still state, pause work when the element is off screen, and keep it accessible. Original: https://www.arlan.me/vault (Arlan, MIT).