NEMAWASHI LAB

Blur

Canvas

Flat polygonal shapes rendered to a Canvas with layered radial and linear gradients inside the clip, then progressively blurred outward from a focal point using stacked Gaussian passes at decreasing radii. Rounded-rect holes are punched through via destination-out compositing. Clicking morphs between eight colour moods (Drift, Ember, Forest, etc.) by vertex-lerping the polygon, cross-fading gradient alphas, and interpolating the background -- all driven by a single easeInOutCubic ramp over 1.4 seconds. A pre-generated grain overlay gives the surface tooth.

DRIFT

1 / 8

Click anywhere

Source

compositions.ts383 lines
/** Each point is [x-fraction-of-vw, y-fraction-of-vh] */
type Pt = [number, number];

export interface GradientLayer {
  type: "radial" | "linear";
  /** For linear: [x0,y0,x1,y1] as fractions; for radial: [cx,cy,r] as fractions (r of unit) */
  coords: number[];
  stops: [number, string][];
}

export interface Hole {
  x: number; // fraction of vw
  y: number; // fraction of vh
  w: number; // fraction of unit
  h: number; // fraction of unit
  radius: number; // fraction of min(w,h) in px
}

export interface BlurComposition {
  name: string;
  bg: string;
  /** Polygon outline as viewport-relative points */
  path: Pt[];
  /** Base fill (drawn first inside clip) */
  baseFill: string;
  /** Additional gradient layers drawn inside clip */
  gradients: GradientLayer[];
  /** Rounded-rect holes punched through the shape */
  holes: Hole[];
  /** Progressive-blur focus point [x,y] as fractions of vw,vh */
  blurCenter: [number, number];
}

export const compositions: BlurComposition[] = [
  // ── 1. DRIFT (the original blue) ──────────────────────────────
  {
    name: "DRIFT",
    bg: "#e4e6f0",
    path: [
      [0.10, -0.05], [0.88, -0.05], [0.88, 0.43],
      [0.80, 0.75], [0.36, 0.75], [0.36, 0.78],
      [-0.02, 0.78], [-0.02, 0.08], [0.10, 0.08],
    ],
    baseFill: "rgba(90, 120, 200, 0.55)",
    gradients: [
      // Deep pool upper-left
      { type: "radial", coords: [0.18, 0.10, 0.40],
        stops: [[0, "rgba(18,38,120,0.60)"], [0.6, "rgba(18,38,120,0.25)"], [1, "rgba(18,38,120,0)"]] },
      // Secondary radial center-right
      { type: "radial", coords: [0.65, 0.35, 0.35],
        stops: [[0, "rgba(55,80,180,0.35)"], [0.5, "rgba(55,80,180,0.12)"], [1, "rgba(55,80,180,0)"]] },
      // Dark left bar
      { type: "linear", coords: [-0.02, 0.5, 0.36, 0.5],
        stops: [[0, "rgba(22,42,115,0.38)"], [0.4, "rgba(22,42,115,0.18)"], [1, "rgba(22,42,115,0)"]] },
      // Cool wash bottom
      { type: "linear", coords: [0.0, 0.5, 0.0, 0.78],
        stops: [[0, "rgba(60,85,165,0)"], [0.5, "rgba(60,85,165,0.12)"], [1, "rgba(60,85,165,0.30)"]] },
      // Lighter wash bottom-right
      { type: "linear", coords: [0.2, 0.2, 0.9, 0.8],
        stops: [[0, "rgba(170,190,235,0)"], [0.4, "rgba(170,190,235,0)"], [0.8, "rgba(170,190,235,0.25)"], [1, "rgba(170,190,235,0.45)"]] },
      // Lighter top-right band
      { type: "linear", coords: [0.5, -0.05, 0.9, 0.2],
        stops: [[0, "rgba(140,170,225,0)"], [0.4, "rgba(140,170,225,0.15)"], [0.7, "rgba(140,170,225,0.22)"], [1, "rgba(140,170,225,0)"]] },
      // Darker diagonal area (bottom-right)
      { type: "linear", coords: [0.45, 0.3, 0.88, 0.72],
        stops: [[0, "rgba(28,48,135,0)"], [0.25, "rgba(28,48,135,0.12)"], [0.6, "rgba(28,48,135,0.28)"], [1, "rgba(28,48,135,0.45)"]] },
      // Subtle warm accent upper-right
      { type: "radial", coords: [0.78, 0.08, 0.25],
        stops: [[0, "rgba(120,130,210,0.20)"], [1, "rgba(120,130,210,0)"]] },
    ],
    holes: [
      { x: 0.60, y: 0.06, w: 0.20, h: 0.20, radius: 0.18 },
      { x: 0.32, y: 0.38, w: 0.22, h: 0.25, radius: 0.16 },
    ],
    blurCenter: [0.45, 0.30],
  },

  // ── 2. EMBER (warm reds/oranges) ──────────────────────────────
  {
    name: "EMBER",
    bg: "#f0e4e0",
    path: [
      [-0.04, -0.06], [0.72, -0.06], [0.72, 0.12],
      [1.04, 0.12], [1.04, 0.68], [0.62, 0.68],
      [0.48, 0.84], [-0.04, 0.84],
    ],
    baseFill: "rgba(185, 75, 55, 0.48)",
    gradients: [
      // Deep crimson pool upper-left
      { type: "radial", coords: [0.12, 0.18, 0.45],
        stops: [[0, "rgba(130,22,12,0.60)"], [0.5, "rgba(130,22,12,0.25)"], [1, "rgba(130,22,12,0)"]] },
      // Hot spot center
      { type: "radial", coords: [0.50, 0.40, 0.30],
        stops: [[0, "rgba(210,80,40,0.35)"], [0.6, "rgba(210,80,40,0.10)"], [1, "rgba(210,80,40,0)"]] },
      // Dark left edge
      { type: "linear", coords: [-0.04, 0.5, 0.25, 0.5],
        stops: [[0, "rgba(110,18,8,0.40)"], [0.5, "rgba(110,18,8,0.15)"], [1, "rgba(110,18,8,0)"]] },
      // Warm glow right
      { type: "linear", coords: [0.5, 0.1, 1.04, 0.5],
        stops: [[0, "rgba(225,150,95,0)"], [0.4, "rgba(225,150,95,0.08)"], [0.8, "rgba(225,150,95,0.28)"], [1, "rgba(225,150,95,0.42)"]] },
      // Orange-pink bottom wash
      { type: "linear", coords: [0.1, 0.5, 0.5, 0.84],
        stops: [[0, "rgba(190,65,50,0)"], [0.4, "rgba(190,65,50,0.12)"], [1, "rgba(190,65,50,0.35)"]] },
      // Subtle cool accent top-right
      { type: "radial", coords: [0.85, 0.15, 0.22],
        stops: [[0, "rgba(180,110,80,0.22)"], [1, "rgba(180,110,80,0)"]] },
      // Deep shadow diagonal
      { type: "linear", coords: [0.3, 0.5, 0.52, 0.84],
        stops: [[0, "rgba(90,12,5,0)"], [0.4, "rgba(90,12,5,0.18)"], [1, "rgba(90,12,5,0.40)"]] },
      // Bright highlight top band
      { type: "linear", coords: [0.0, -0.06, 0.0, 0.15],
        stops: [[0, "rgba(230,165,120,0.25)"], [0.6, "rgba(230,165,120,0.08)"], [1, "rgba(230,165,120,0)"]] },
    ],
    holes: [
      { x: 0.12, y: 0.08, w: 0.22, h: 0.22, radius: 0.20 },
      { x: 0.58, y: 0.32, w: 0.26, h: 0.24, radius: 0.14 },
    ],
    blurCenter: [0.38, 0.35],
  },

  // ── 3. FOREST (deep greens) ───────────────────────────────────
  {
    name: "FOREST",
    bg: "#e2e8e0",
    path: [
      [0.08, -0.04], [1.04, -0.04], [1.04, 0.52],
      [0.68, 0.52], [0.68, 0.82], [0.28, 0.82],
      [0.12, 0.58], [-0.04, 0.58], [-0.04, 0.18],
      [0.08, 0.18],
    ],
    baseFill: "rgba(55, 115, 70, 0.45)",
    gradients: [
      // Deep canopy upper-left
      { type: "radial", coords: [0.20, 0.15, 0.45],
        stops: [[0, "rgba(12,58,28,0.58)"], [0.5, "rgba(12,58,28,0.22)"], [1, "rgba(12,58,28,0)"]] },
      // Dappled light center
      { type: "radial", coords: [0.48, 0.35, 0.28],
        stops: [[0, "rgba(90,155,85,0.30)"], [0.6, "rgba(90,155,85,0.08)"], [1, "rgba(90,155,85,0)"]] },
      // Dark trunk left
      { type: "linear", coords: [-0.04, 0.4, 0.25, 0.4],
        stops: [[0, "rgba(8,45,18,0.38)"], [0.5, "rgba(8,45,18,0.15)"], [1, "rgba(8,45,18,0)"]] },
      // Moss glow bottom
      { type: "linear", coords: [0.1, 0.5, 0.5, 0.82],
        stops: [[0, "rgba(35,80,40,0)"], [0.4, "rgba(35,80,40,0.12)"], [1, "rgba(35,80,40,0.32)"]] },
      // Emerald wash right
      { type: "linear", coords: [0.4, 0.1, 1.04, 0.5],
        stops: [[0, "rgba(120,185,110,0)"], [0.5, "rgba(120,185,110,0.05)"], [0.8, "rgba(120,185,110,0.22)"], [1, "rgba(120,185,110,0.38)"]] },
      // Dark understorey diagonal
      { type: "linear", coords: [0.15, 0.4, 0.40, 0.82],
        stops: [[0, "rgba(10,35,15,0)"], [0.3, "rgba(10,35,15,0.15)"], [1, "rgba(10,35,15,0.35)"]] },
      // Yellow-green highlight top
      { type: "linear", coords: [0.3, -0.04, 0.7, 0.15],
        stops: [[0, "rgba(110,160,80,0)"], [0.4, "rgba(110,160,80,0.15)"], [1, "rgba(110,160,80,0)"]] },
      // Cool shadow right-bottom
      { type: "radial", coords: [0.82, 0.48, 0.22],
        stops: [[0, "rgba(18,50,28,0.25)"], [1, "rgba(18,50,28,0)"]] },
    ],
    holes: [
      { x: 0.52, y: 0.05, w: 0.23, h: 0.23, radius: 0.18 },
      { x: 0.22, y: 0.42, w: 0.24, h: 0.26, radius: 0.15 },
    ],
    blurCenter: [0.40, 0.30],
  },

  // ── 4. DUSK (purple/violet) ───────────────────────────────────
  {
    name: "DUSK",
    bg: "#e6e0f0",
    path: [
      [0.18, -0.06], [1.04, -0.06], [1.04, 0.42],
      [0.76, 0.42], [0.76, 0.72], [0.56, 0.86],
      [-0.04, 0.86], [-0.04, 0.26], [0.18, 0.26],
    ],
    baseFill: "rgba(105, 65, 175, 0.45)",
    gradients: [
      // Deep violet pool upper-left
      { type: "radial", coords: [0.25, 0.12, 0.42],
        stops: [[0, "rgba(48,18,110,0.58)"], [0.5, "rgba(48,18,110,0.22)"], [1, "rgba(48,18,110,0)"]] },
      // Nebula glow center
      { type: "radial", coords: [0.52, 0.38, 0.32],
        stops: [[0, "rgba(130,85,200,0.30)"], [0.5, "rgba(130,85,200,0.10)"], [1, "rgba(130,85,200,0)"]] },
      // Dark left column
      { type: "linear", coords: [-0.04, 0.5, 0.30, 0.5],
        stops: [[0, "rgba(38,12,90,0.38)"], [0.5, "rgba(38,12,90,0.15)"], [1, "rgba(38,12,90,0)"]] },
      // Lavender wash right
      { type: "linear", coords: [0.4, 0.1, 1.04, 0.45],
        stops: [[0, "rgba(175,145,230,0)"], [0.4, "rgba(175,145,230,0.05)"], [0.8, "rgba(175,145,230,0.25)"], [1, "rgba(175,145,230,0.42)"]] },
      // Warm pink undertone bottom
      { type: "linear", coords: [0.1, 0.55, 0.5, 0.86],
        stops: [[0, "rgba(85,35,130,0)"], [0.4, "rgba(85,35,130,0.12)"], [1, "rgba(85,35,130,0.35)"]] },
      // Dark diagonal slash
      { type: "linear", coords: [0.5, 0.5, 0.60, 0.86],
        stops: [[0, "rgba(30,8,75,0)"], [0.3, "rgba(30,8,75,0.18)"], [1, "rgba(30,8,75,0.40)"]] },
      // Iridescent highlight top-right
      { type: "linear", coords: [0.55, -0.06, 1.0, 0.18],
        stops: [[0, "rgba(155,120,220,0)"], [0.4, "rgba(155,120,220,0.18)"], [0.7, "rgba(155,120,220,0.12)"], [1, "rgba(155,120,220,0)"]] },
      // Subtle magenta accent
      { type: "radial", coords: [0.35, 0.65, 0.20],
        stops: [[0, "rgba(120,45,160,0.22)"], [1, "rgba(120,45,160,0)"]] },
    ],
    holes: [
      { x: 0.55, y: 0.06, w: 0.21, h: 0.21, radius: 0.20 },
      { x: 0.18, y: 0.45, w: 0.25, h: 0.27, radius: 0.14 },
    ],
    blurCenter: [0.42, 0.32],
  },

  // ── 5. SAND (warm ochre/gold) ─────────────────────────────────
  {
    name: "SAND",
    bg: "#f0ece4",
    path: [
      [-0.04, -0.04], [0.64, -0.04], [0.64, 0.22],
      [1.04, 0.22], [1.04, 0.78], [0.72, 0.78],
      [0.72, 0.56], [0.38, 0.56], [0.22, 0.82],
      [-0.04, 0.82],
    ],
    baseFill: "rgba(175, 135, 60, 0.45)",
    gradients: [
      // Deep amber upper-left
      { type: "radial", coords: [0.15, 0.15, 0.42],
        stops: [[0, "rgba(130,85,15,0.55)"], [0.5, "rgba(130,85,15,0.20)"], [1, "rgba(130,85,15,0)"]] },
      // Warm glow center
      { type: "radial", coords: [0.50, 0.40, 0.30],
        stops: [[0, "rgba(200,155,65,0.30)"], [0.6, "rgba(200,155,65,0.08)"], [1, "rgba(200,155,65,0)"]] },
      // Burnt edge left
      { type: "linear", coords: [-0.04, 0.4, 0.22, 0.4],
        stops: [[0, "rgba(110,68,8,0.35)"], [0.5, "rgba(110,68,8,0.12)"], [1, "rgba(110,68,8,0)"]] },
      // Golden light right
      { type: "linear", coords: [0.5, 0.2, 1.04, 0.65],
        stops: [[0, "rgba(220,190,115,0)"], [0.4, "rgba(220,190,115,0.05)"], [0.8, "rgba(220,190,115,0.28)"], [1, "rgba(220,190,115,0.45)"]] },
      // Dark earth bottom
      { type: "linear", coords: [0.05, 0.5, 0.25, 0.82],
        stops: [[0, "rgba(85,52,5,0)"], [0.4, "rgba(85,52,5,0.15)"], [1, "rgba(85,52,5,0.38)"]] },
      // Honey highlight top
      { type: "linear", coords: [0.1, -0.04, 0.4, 0.12],
        stops: [[0, "rgba(210,170,80,0.22)"], [0.5, "rgba(210,170,80,0.10)"], [1, "rgba(210,170,80,0)"]] },
      // Cool shadow bottom-right
      { type: "radial", coords: [0.85, 0.68, 0.18],
        stops: [[0, "rgba(95,60,10,0.25)"], [1, "rgba(95,60,10,0)"]] },
      // Subtle rust accent
      { type: "linear", coords: [0.3, 0.35, 0.55, 0.56],
        stops: [[0, "rgba(160,100,30,0)"], [0.5, "rgba(160,100,30,0.15)"], [1, "rgba(160,100,30,0)"]] },
    ],
    holes: [
      { x: 0.14, y: 0.06, w: 0.22, h: 0.22, radius: 0.18 },
      { x: 0.52, y: 0.38, w: 0.25, h: 0.24, radius: 0.16 },
    ],
    blurCenter: [0.36, 0.30],
  },

  // ── 6. INK (near-black, moody) ────────────────────────────────
  {
    name: "INK",
    bg: "#e0e2e8",
    path: [
      [0.14, -0.06], [0.92, -0.06], [0.92, 0.36],
      [1.04, 0.36], [1.04, 0.70], [0.64, 0.70],
      [0.50, 0.88], [0.24, 0.88], [0.24, 0.62],
      [-0.04, 0.62], [-0.04, 0.14], [0.14, 0.14],
    ],
    baseFill: "rgba(30, 30, 55, 0.50)",
    gradients: [
      // Void upper-left
      { type: "radial", coords: [0.22, 0.15, 0.42],
        stops: [[0, "rgba(5,5,25,0.58)"], [0.5, "rgba(5,5,25,0.22)"], [1, "rgba(5,5,25,0)"]] },
      // Subtle warmth center
      { type: "radial", coords: [0.50, 0.40, 0.28],
        stops: [[0, "rgba(50,50,85,0.28)"], [0.6, "rgba(50,50,85,0.08)"], [1, "rgba(50,50,85,0)"]] },
      // Deep left edge
      { type: "linear", coords: [-0.04, 0.4, 0.22, 0.4],
        stops: [[0, "rgba(3,3,18,0.40)"], [0.5, "rgba(3,3,18,0.15)"], [1, "rgba(3,3,18,0)"]] },
      // Steel highlight right
      { type: "linear", coords: [0.4, 0.2, 1.04, 0.6],
        stops: [[0, "rgba(85,88,125,0)"], [0.4, "rgba(85,88,125,0.05)"], [0.8, "rgba(85,88,125,0.22)"], [1, "rgba(85,88,125,0.38)"]] },
      // Abyss bottom
      { type: "linear", coords: [0.2, 0.55, 0.52, 0.88],
        stops: [[0, "rgba(2,2,12,0)"], [0.3, "rgba(2,2,12,0.15)"], [1, "rgba(2,2,12,0.38)"]] },
      // Cool blue accent top-right
      { type: "linear", coords: [0.5, -0.06, 0.92, 0.15],
        stops: [[0, "rgba(45,50,90,0)"], [0.4, "rgba(45,50,90,0.15)"], [1, "rgba(45,50,90,0)"]] },
      // Subtle indigo pulse
      { type: "radial", coords: [0.70, 0.50, 0.20],
        stops: [[0, "rgba(35,35,70,0.22)"], [1, "rgba(35,35,70,0)"]] },
      // Bottom-left shadow
      { type: "linear", coords: [-0.04, 0.45, 0.24, 0.62],
        stops: [[0, "rgba(5,5,20,0.25)"], [0.6, "rgba(5,5,20,0.10)"], [1, "rgba(5,5,20,0)"]] },
    ],
    holes: [
      { x: 0.56, y: 0.05, w: 0.20, h: 0.20, radius: 0.20 },
      { x: 0.08, y: 0.30, w: 0.24, h: 0.22, radius: 0.16 },
    ],
    blurCenter: [0.38, 0.32],
  },

  // ── 7. CORAL (soft pink/salmon) ───────────────────────────────
  {
    name: "CORAL",
    bg: "#f0e6e6",
    path: [
      [-0.04, -0.05], [0.56, -0.05], [0.56, 0.18],
      [0.82, 0.18], [1.04, 0.40], [1.04, 0.72],
      [0.44, 0.72], [0.44, 0.86], [-0.04, 0.86],
    ],
    baseFill: "rgba(200, 105, 115, 0.42)",
    gradients: [
      // Deep rose upper-left
      { type: "radial", coords: [0.15, 0.20, 0.42],
        stops: [[0, "rgba(165,42,55,0.55)"], [0.5, "rgba(165,42,55,0.20)"], [1, "rgba(165,42,55,0)"]] },
      // Warm blush center
      { type: "radial", coords: [0.48, 0.45, 0.30],
        stops: [[0, "rgba(220,130,125,0.28)"], [0.6, "rgba(220,130,125,0.08)"], [1, "rgba(220,130,125,0)"]] },
      // Dark left
      { type: "linear", coords: [-0.04, 0.4, 0.22, 0.4],
        stops: [[0, "rgba(140,32,42,0.35)"], [0.5, "rgba(140,32,42,0.12)"], [1, "rgba(140,32,42,0)"]] },
      // Peach light right
      { type: "linear", coords: [0.4, 0.15, 1.04, 0.55],
        stops: [[0, "rgba(240,185,175,0)"], [0.4, "rgba(240,185,175,0.05)"], [0.8, "rgba(240,185,175,0.25)"], [1, "rgba(240,185,175,0.42)"]] },
      // Deep bottom
      { type: "linear", coords: [0.1, 0.55, 0.35, 0.86],
        stops: [[0, "rgba(120,25,35,0)"], [0.4, "rgba(120,25,35,0.12)"], [1, "rgba(120,25,35,0.32)"]] },
      // Warm highlight top band
      { type: "linear", coords: [0.0, -0.05, 0.0, 0.12],
        stops: [[0, "rgba(235,160,155,0.22)"], [0.6, "rgba(235,160,155,0.08)"], [1, "rgba(235,160,155,0)"]] },
      // Diagonal slash
      { type: "linear", coords: [0.55, 0.18, 1.0, 0.42],
        stops: [[0, "rgba(180,65,70,0)"], [0.3, "rgba(180,65,70,0.15)"], [0.7, "rgba(180,65,70,0.22)"], [1, "rgba(180,65,70,0)"]] },
      // Cool shadow bottom-right
      { type: "radial", coords: [0.80, 0.62, 0.18],
        stops: [[0, "rgba(130,35,45,0.22)"], [1, "rgba(130,35,45,0)"]] },
    ],
    holes: [
      { x: 0.10, y: 0.06, w: 0.22, h: 0.22, radius: 0.18 },
      { x: 0.52, y: 0.40, w: 0.26, h: 0.22, radius: 0.15 },
    ],
    blurCenter: [0.35, 0.35],
  },

  // ── 8. SLATE (cool grey-blue) ─────────────────────────────────
  {
    name: "SLATE",
    bg: "#e4e6ea",
    path: [
      [0.06, -0.05], [0.78, -0.05], [0.78, 0.28],
      [1.04, 0.28], [1.04, 0.60], [0.82, 0.76],
      [0.42, 0.76], [0.42, 0.56], [-0.04, 0.56],
      [-0.04, 0.10], [0.06, 0.10],
    ],
    baseFill: "rgba(80, 95, 120, 0.45)",
    gradients: [
      // Storm upper-left
      { type: "radial", coords: [0.18, 0.12, 0.42],
        stops: [[0, "rgba(35,45,72,0.55)"], [0.5, "rgba(35,45,72,0.20)"], [1, "rgba(35,45,72,0)"]] },
      // Silver center
      { type: "radial", coords: [0.48, 0.35, 0.28],
        stops: [[0, "rgba(110,120,145,0.25)"], [0.6, "rgba(110,120,145,0.06)"], [1, "rgba(110,120,145,0)"]] },
      // Dark left
      { type: "linear", coords: [-0.04, 0.35, 0.22, 0.35],
        stops: [[0, "rgba(28,35,55,0.38)"], [0.5, "rgba(28,35,55,0.14)"], [1, "rgba(28,35,55,0)"]] },
      // Mist wash right
      { type: "linear", coords: [0.35, 0.15, 1.04, 0.55],
        stops: [[0, "rgba(145,158,185,0)"], [0.4, "rgba(145,158,185,0.05)"], [0.8, "rgba(145,158,185,0.22)"], [1, "rgba(145,158,185,0.40)"]] },
      // Deep shadow diagonal
      { type: "linear", coords: [0.55, 0.48, 0.85, 0.76],
        stops: [[0, "rgba(22,30,48,0)"], [0.3, "rgba(22,30,48,0.15)"], [1, "rgba(22,30,48,0.38)"]] },
      // Cool top highlight
      { type: "linear", coords: [0.2, -0.05, 0.6, 0.10],
        stops: [[0, "rgba(100,115,145,0)"], [0.4, "rgba(100,115,145,0.15)"], [1, "rgba(100,115,145,0)"]] },
      // Bottom edge deepening
      { type: "linear", coords: [-0.04, 0.40, 0.42, 0.56],
        stops: [[0, "rgba(25,32,50,0.25)"], [0.6, "rgba(25,32,50,0.10)"], [1, "rgba(25,32,50,0)"]] },
      // Subtle warmth
      { type: "radial", coords: [0.65, 0.42, 0.18],
        stops: [[0, "rgba(95,100,130,0.18)"], [1, "rgba(95,100,130,0)"]] },
    ],
    holes: [
      { x: 0.48, y: 0.04, w: 0.20, h: 0.20, radius: 0.20 },
      { x: 0.10, y: 0.28, w: 0.24, h: 0.22, radius: 0.16 },
    ],
    blurCenter: [0.38, 0.28],
  },
];
BlurCanvas.tsx461 lines
"use client";

import { useEffect, useRef, useState, useCallback } from "react";
import { motion } from "framer-motion";
import ScrambleLink from "../ScrambleLink";
import {
  compositions,
  type BlurComposition,
  type GradientLayer,
} from "./compositions";

type Pt = [number, number];

// ---------------------------------------------------------------------------
// Path normalisation — pad every path to the same vertex count so we can lerp
// ---------------------------------------------------------------------------

const MAX_PTS = Math.max(...compositions.map((c) => c.path.length));

function padPath(path: Pt[], target: number): Pt[] {
  const result: Pt[] = path.map((p) => [...p] as Pt);
  while (result.length < target) {
    // find longest edge and bisect it
    let best = 0;
    let bestLen = 0;
    for (let i = 0; i < result.length; i++) {
      const ni = (i + 1) % result.length;
      const dx = result[ni][0] - result[i][0];
      const dy = result[ni][1] - result[i][1];
      const len = dx * dx + dy * dy;
      if (len > bestLen) {
        bestLen = len;
        best = i;
      }
    }
    const ni = (best + 1) % result.length;
    const mid: Pt = [
      (result[best][0] + result[ni][0]) / 2,
      (result[best][1] + result[ni][1]) / 2,
    ];
    result.splice(best + 1, 0, mid);
  }
  return result;
}

const normalizedPaths = compositions.map((c) => padPath(c.path, MAX_PTS));

// ---------------------------------------------------------------------------
// Colour helpers
// ---------------------------------------------------------------------------

function parseRGBA(c: string): [number, number, number, number] {
  if (c.startsWith("#")) {
    const h = c.slice(1);
    return [
      parseInt(h.slice(0, 2), 16),
      parseInt(h.slice(2, 4), 16),
      parseInt(h.slice(4, 6), 16),
      1,
    ];
  }
  const m = c.match(
    /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)/
  );
  return m
    ? [+m[1], +m[2], +m[3], m[4] != null ? +m[4] : 1]
    : [0, 0, 0, 1];
}

function lerpColor(a: string, b: string, t: number): string {
  const [r1, g1, b1, a1] = parseRGBA(a);
  const [r2, g2, b2, a2] = parseRGBA(b);
  return `rgba(${Math.round(r1 + (r2 - r1) * t)}, ${Math.round(
    g1 + (g2 - g1) * t
  )}, ${Math.round(b1 + (b2 - b1) * t)}, ${(a1 + (a2 - a1) * t).toFixed(
    3
  )})`;
}

function scaleAlpha(color: string, factor: number): string {
  const [r, g, b, a] = parseRGBA(color);
  return `rgba(${r}, ${g}, ${b}, ${(a * factor).toFixed(3)})`;
}

function lerpHex(a: string, b: string, t: number): string {
  const [r1, g1, b1] = parseRGBA(a);
  const [r2, g2, b2] = parseRGBA(b);
  const rr = Math.round(r1 + (r2 - r1) * t);
  const gg = Math.round(g1 + (g2 - g1) * t);
  const bb = Math.round(b1 + (b2 - b1) * t);
  return `#${rr.toString(16).padStart(2, "0")}${gg.toString(16).padStart(2, "0")}${bb.toString(16).padStart(2, "0")}`;
}

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

function easeInOutCubic(t: number): number {
  return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}

// ---------------------------------------------------------------------------
// Interpolate two compositions into a transient BlurComposition
// ---------------------------------------------------------------------------

function interpolateComp(
  from: BlurComposition,
  fromPath: Pt[],
  to: BlurComposition,
  toPath: Pt[],
  rawT: number
): BlurComposition {
  const t = easeInOutCubic(rawT);

  return {
    name: rawT < 0.5 ? from.name : to.name,
    bg: lerpHex(from.bg, to.bg, t),
    path: fromPath.map(
      (p, i) =>
        [
          p[0] + (toPath[i][0] - p[0]) * t,
          p[1] + (toPath[i][1] - p[1]) * t,
        ] as Pt
    ),
    baseFill: lerpColor(from.baseFill, to.baseFill, t),
    // Cross-fade: both gradient sets render, alphas reciprocally scaled
    gradients: [
      ...from.gradients.map((g) => ({
        ...g,
        stops: g.stops.map(
          ([pos, color]) =>
            [pos, scaleAlpha(color, 1 - t)] as [number, string]
        ),
      })),
      ...to.gradients.map((g) => ({
        ...g,
        stops: g.stops.map(
          ([pos, color]) => [pos, scaleAlpha(color, t)] as [number, string]
        ),
      })),
    ],
    holes: from.holes.map((h, i) => ({
      x: h.x + (to.holes[i].x - h.x) * t,
      y: h.y + (to.holes[i].y - h.y) * t,
      w: h.w + (to.holes[i].w - h.w) * t,
      h: h.h + (to.holes[i].h - h.h) * t,
      radius: h.radius + (to.holes[i].radius - h.radius) * t,
    })),
    blurCenter: [
      from.blurCenter[0] + (to.blurCenter[0] - from.blurCenter[0]) * t,
      from.blurCenter[1] + (to.blurCenter[1] - from.blurCenter[1]) * t,
    ],
  };
}

// ---------------------------------------------------------------------------
// Grain overlay — generated once, reused every frame
// ---------------------------------------------------------------------------

let grainCanvas: HTMLCanvasElement | null = null;
let grainW = 0;
let grainH = 0;

function getGrainOverlay(w: number, h: number): HTMLCanvasElement {
  if (grainCanvas && grainW === w && grainH === h) return grainCanvas;

  grainCanvas = document.createElement("canvas");
  grainCanvas.width = w;
  grainCanvas.height = h;
  const ctx = grainCanvas.getContext("2d")!;
  const imageData = ctx.createImageData(w, h);
  const data = imageData.data;
  const intensity = 30;

  for (let i = 0; i < data.length; i += 4) {
    const noise = ((Math.random() - 0.5) * intensity * 2) | 0;
    // Store noise as grey with alpha so we can overlay it
    // Using a mid-grey + offset approach: 128 + noise, then composite with soft-light
    // Simpler: store as RGBA noise offsets via alpha channel trick
    data[i] = noise > 0 ? 255 : 0;
    data[i + 1] = noise > 0 ? 255 : 0;
    data[i + 2] = noise > 0 ? 255 : 0;
    data[i + 3] = Math.abs(noise);
  }

  ctx.putImageData(imageData, 0, 0);
  grainW = w;
  grainH = h;
  return grainCanvas;
}

// ---------------------------------------------------------------------------
// Canvas renderer
// ---------------------------------------------------------------------------

function renderComposition(
  canvas: HTMLCanvasElement,
  comp: BlurComposition,
) {
  const dpr = window.devicePixelRatio || 1;
  const vw = window.innerWidth;
  const vh = window.innerHeight;
  const unit = Math.min(vw, vh);

  canvas.width = vw * dpr;
  canvas.height = vh * dpr;
  canvas.style.width = `${vw}px`;
  canvas.style.height = `${vh}px`;

  const ctx = canvas.getContext("2d")!;
  ctx.scale(dpr, dpr);

  // 1. Background
  ctx.fillStyle = comp.bg;
  ctx.fillRect(0, 0, vw, vh);

  // 2. Build shape on offscreen canvas
  const off = document.createElement("canvas");
  off.width = vw * dpr;
  off.height = vh * dpr;
  const oc = off.getContext("2d")!;
  oc.scale(dpr, dpr);

  function tracePath(c: CanvasRenderingContext2D) {
    c.beginPath();
    const pts = comp.path;
    c.moveTo(pts[0][0] * vw, pts[0][1] * vh);
    for (let i = 1; i < pts.length; i++) {
      c.lineTo(pts[i][0] * vw, pts[i][1] * vh);
    }
    c.closePath();
  }

  tracePath(oc);
  oc.save();
  oc.clip();

  oc.fillStyle = comp.baseFill;
  oc.fillRect(0, 0, vw, vh);

  for (const gl of comp.gradients) {
    let grad: CanvasGradient;
    if (gl.type === "radial") {
      const [cx, cy, r] = gl.coords;
      grad = oc.createRadialGradient(
        cx * vw,
        cy * vh,
        0,
        cx * vw,
        cy * vh,
        r * unit
      );
    } else {
      const [x0, y0, x1, y1] = gl.coords;
      grad = oc.createLinearGradient(x0 * vw, y0 * vh, x1 * vw, y1 * vh);
    }
    for (const [pos, color] of gl.stops) {
      grad.addColorStop(pos, color);
    }
    oc.fillStyle = grad;
    oc.fillRect(0, 0, vw, vh);
  }

  oc.restore();

  // Punch holes
  oc.globalCompositeOperation = "destination-out";
  oc.fillStyle = "#000";
  for (const h of comp.holes) {
    const hw = h.w * unit;
    const hh = h.h * unit;
    oc.beginPath();
    oc.roundRect(h.x * vw, h.y * vh, hw, hh, Math.min(hw, hh) * h.radius);
    oc.fill();
  }
  oc.globalCompositeOperation = "source-over";

  // 3. Progressive blur
  const pw = off.width;
  const ph = off.height;
  const maxBlur = unit * 0.1 * dpr;

  const sharp = document.createElement("canvas");
  sharp.width = pw;
  sharp.height = ph;
  sharp.getContext("2d")!.drawImage(off, 0, 0);

  const bcx = comp.blurCenter[0] * vw * dpr;
  const bcy = comp.blurCenter[1] * vh * dpr;
  const bR = unit * 0.75 * dpr;

  oc.setTransform(1, 0, 0, 1, 0, 0);
  oc.clearRect(0, 0, pw, ph);

  oc.filter = `blur(${maxBlur}px)`;
  oc.drawImage(sharp, 0, 0);
  oc.filter = "none";

  const blurSteps = [
    { blur: 0.7, radiusFrac: 0.9, innerAlpha: 1, midAlpha: 0.7, outerAlpha: 0 },
    { blur: 0.45, radiusFrac: 0.7, innerAlpha: 1, midAlpha: 0.65, outerAlpha: 0 },
    { blur: 0.22, radiusFrac: 0.5, innerAlpha: 1, midAlpha: 0.6, outerAlpha: 0 },
    { blur: 0, radiusFrac: 0.3, innerAlpha: 1, midAlpha: 0.55, outerAlpha: 0 },
  ];

  for (const step of blurSteps) {
    const tmp = document.createElement("canvas");
    tmp.width = pw;
    tmp.height = ph;
    const tc = tmp.getContext("2d")!;
    if (step.blur > 0) {
      tc.filter = `blur(${maxBlur * step.blur}px)`;
    }
    tc.drawImage(sharp, 0, 0);
    tc.filter = "none";
    tc.globalCompositeOperation = "destination-in";
    const rg = tc.createRadialGradient(
      bcx, bcy, 0,
      bcx, bcy, bR * step.radiusFrac
    );
    rg.addColorStop(0, `rgba(0,0,0,${step.innerAlpha})`);
    rg.addColorStop(0.45, `rgba(0,0,0,${step.midAlpha})`);
    rg.addColorStop(1, `rgba(0,0,0,${step.outerAlpha})`);
    tc.fillStyle = rg;
    tc.fillRect(0, 0, pw, ph);
    oc.drawImage(tmp, 0, 0);
  }

  // 4. Composite onto background
  ctx.save();
  ctx.setTransform(1, 0, 0, 1, 0, 0);
  ctx.drawImage(off, 0, 0);
  ctx.restore();

  // 5. Grain — pre-generated random overlay, same texture every frame
  ctx.save();
  ctx.setTransform(1, 0, 0, 1, 0, 0);
  ctx.drawImage(getGrainOverlay(vw * dpr, vh * dpr), 0, 0);
  ctx.restore();
}

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

export default function BlurCanvas() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [index, setIndex] = useState(0);
  const animatingRef = useRef(false);
  const rafRef = useRef<number>(0);

  const comp = compositions[index];

  // Initial render
  useEffect(() => {
    if (canvasRef.current) renderComposition(canvasRef.current, comp);
  }, []); // eslint-disable-line react-hooks/exhaustive-deps

  // Resize handler
  useEffect(() => {
    function onResize() {
      grainCanvas = null; // regenerate grain at new size
      if (!animatingRef.current && canvasRef.current) {
        renderComposition(canvasRef.current, compositions[index]);
      }
    }
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, [index]);

  const handleClick = useCallback(() => {
    if (animatingRef.current) return;
    animatingRef.current = true;

    const fromIdx = index;
    const toIdx = (fromIdx + 1) % compositions.length;
    const duration = 1400;
    const start = performance.now();

    function step(now: number) {
      const rawT = Math.min((now - start) / duration, 1);

      const interp = interpolateComp(
        compositions[fromIdx],
        normalizedPaths[fromIdx],
        compositions[toIdx],
        normalizedPaths[toIdx],
        rawT
      );

      if (canvasRef.current) {
        renderComposition(canvasRef.current, interp);
      }

      if (rawT < 1) {
        rafRef.current = requestAnimationFrame(step);
      } else {
        // Final frame with grain
        if (canvasRef.current) {
          renderComposition(canvasRef.current, compositions[toIdx]);
        }
        setIndex(toIdx);
        animatingRef.current = false;
      }
    }

    rafRef.current = requestAnimationFrame(step);
  }, [index]);

  useEffect(() => {
    return () => cancelAnimationFrame(rafRef.current);
  }, []);

  return (
    <div
      className="fixed inset-0 cursor-pointer select-none"
      onClick={handleClick}
    >
      <canvas ref={canvasRef} className="absolute inset-0" />

      <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 }}
        >
          <p className="text-[12px] tracking-[0.08em] text-foreground/50 uppercase">
            {comp.name}
          </p>
          <p className="text-[12px] tracking-[0.2em] text-foreground/30 mt-1">
            {index + 1} / {compositions.length}
          </p>
        </motion.div>
      </div>

      <div
        className="fixed bottom-8 right-8 pointer-events-none z-10 font-[family-name:var(--font-flux)]"
        style={{ fontVariationSettings: "'wght' 300, 'SRIF' 100" }}
      >
        <p className="text-[12px] tracking-[0.2em] text-foreground/30 uppercase">
          Click anywhere
        </p>
      </div>

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

NEMAWASHI — Kotaro Abe