NEMAWASHI LAB

Rectangles

FM

A grid where every column width and row height is a function -- sin wave, pinch toward centre, exponential funnel, uniform -- not a constant. Clicking cycles through seven distortion presets; each cell spring-animates to its new size (stiffness 60, damping 18) with a staggered delay proportional to its index, so the layout ripples rather than snaps. The filled/empty checkerboard and the distortion function together make it feel like a single typographic block stretching and breathing.

EXPAND

1 / 7

Click anywhere

Source

RectGrid.tsx243 lines
"use client";

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

interface Distortion {
  name: string;
  color: string;
  cols: number;
  rows: number;
  colWidth: (col: number, total: number) => number;
  rowHeight: (row: number, total: number) => number;
}

const distortions: Distortion[] = [
  {
    name: "EXPAND",
    color: "#2A7FD4",
    cols: 16,
    rows: 12,
    colWidth: (col, total) => 0.3 + (col / total) * 1.7,
    rowHeight: (row, total) => 1.6 - (row / total) * 1.2,
  },
  {
    name: "PINCH",
    color: "#C1272D",
    cols: 14,
    rows: 14,
    colWidth: (col, total) => {
      const t = Math.abs(col - total / 2) / (total / 2);
      return 0.4 + t * 1.2;
    },
    rowHeight: (row, total) => {
      const t = Math.abs(row - total / 2) / (total / 2);
      return 0.4 + t * 1.2;
    },
  },
  {
    name: "WAVE",
    color: "#2C2C2C",
    cols: 18,
    rows: 12,
    colWidth: (col, total) =>
      0.6 + Math.sin((col / total) * Math.PI * 2) * 0.5,
    rowHeight: (row, total) =>
      0.6 + Math.cos((row / total) * Math.PI * 2) * 0.5,
  },
  {
    name: "COMPRESS",
    color: "#5B7553",
    cols: 20,
    rows: 10,
    colWidth: (col, total) => 1.8 - (col / total) * 1.5,
    rowHeight: (_row, _total) => 1,
  },
  {
    name: "FUNNEL",
    color: "#E87D2F",
    cols: 16,
    rows: 16,
    colWidth: (col, total) => {
      const t = col / total;
      return 0.3 + t * t * 2;
    },
    rowHeight: (row, total) => {
      const t = row / total;
      return 2 - t * t * 1.5;
    },
  },
  {
    name: "BREATHE",
    color: "#4A6FA5",
    cols: 14,
    rows: 14,
    colWidth: (col, total) => {
      const t = col / total;
      return 0.5 + Math.sin(t * Math.PI) * 1.2;
    },
    rowHeight: (row, total) => {
      const t = row / total;
      return 0.5 + Math.sin(t * Math.PI) * 1.2;
    },
  },
  {
    name: "UNIFORM",
    color: "#1B2B5A",
    cols: 12,
    rows: 12,
    colWidth: () => 1,
    rowHeight: () => 1,
  },
];

interface Cell {
  x: number;
  y: number;
  w: number;
  h: number;
  filled: boolean;
}

export default function RectGrid() {
  const [distIndex, setDistIndex] = useState(0);
  const [isTransitioning, setIsTransitioning] = useState(false);
  const [dimensions, setDimensions] = useState<{
    width: number;
    height: number;
  } | null>(null);
  const stageRef = useRef<HTMLDivElement>(null);

  // Size against the stage container (the viewport standalone, the window
  // when embedded in the GIRAGIRA HQ desktop).
  useEffect(() => {
    const el = stageRef.current;
    if (!el) return;
    const update = () =>
      setDimensions({ width: el.clientWidth, height: el.clientHeight });
    update();
    const ro = new ResizeObserver(update);
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  const dist = distortions[distIndex];

  const cells = useMemo(() => {
    if (!dimensions) return [];
    const { width, height } = dimensions;
    const padding = Math.min(width, height) * 0.08;
    const availW = width - padding * 2;
    const availH = height - padding * 2;

    // Compute raw widths and heights
    const rawColWidths = Array.from({ length: dist.cols }, (_, i) =>
      dist.colWidth(i, dist.cols)
    );
    const rawRowHeights = Array.from({ length: dist.rows }, (_, i) =>
      dist.rowHeight(i, dist.rows)
    );

    // Normalize to fit available space
    const totalRawW = rawColWidths.reduce((a, b) => a + b, 0);
    const totalRawH = rawRowHeights.reduce((a, b) => a + b, 0);
    const colWidths = rawColWidths.map((w) => (w / totalRawW) * availW);
    const rowHeights = rawRowHeights.map((h) => (h / totalRawH) * availH);

    // Build cells
    const result: Cell[] = [];
    let y = padding;
    for (let row = 0; row < dist.rows; row++) {
      let x = padding;
      for (let col = 0; col < dist.cols; col++) {
        result.push({
          x,
          y,
          w: colWidths[col],
          h: rowHeights[row],
          filled: (row + col) % 2 === 0,
        });
        x += colWidths[col];
      }
      y += rowHeights[row];
    }
    return result;
  }, [dist, dimensions]);

  const handleClick = useCallback(() => {
    if (isTransitioning) return;
    setIsTransitioning(true);
    setDistIndex((prev) => (prev + 1) % distortions.length);
    setTimeout(() => setIsTransitioning(false), 1000);
  }, [isTransitioning]);

  // The stage div must always mount — the ResizeObserver effect measures it,
  // so returning null before `dimensions` exists would deadlock the measure.
  return (
    <div
      ref={stageRef}
      className="fixed inset-0 cursor-pointer select-none"
      style={{ backgroundColor: "#f0eeeb" }}
      onClick={handleClick}
    >
      {cells.map((cell, i) => (
        <motion.div
          key={i}
          initial={false}
          animate={{
            left: cell.x,
            top: cell.y,
            width: cell.w,
            height: cell.h,
          }}
          transition={{
            type: "spring",
            stiffness: 60,
            damping: 18,
            mass: 0.6,
            delay: (i / cells.length) * 0.3,
          }}
          className="absolute"
          style={{
            backgroundColor: cell.filled ? dist.color : "#f0eeeb",
          }}
        />
      ))}

      {/* UI overlay */}
      <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={dist.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/40 uppercase">
            {dist.name}
          </p>
          <p className="text-[12px] tracking-[0.2em] text-foreground/25 mt-1">
            {distIndex + 1} / {distortions.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/25 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