NEMAWASHI LAB

Colours

FM

An Artemis-II-inspired palette of 16 colours is shuffled once per mount and dealt into 10 procedural compositions -- single circle, cluster, spiral, wreath, scattered squares, grid, diagonal strip, spectrum bar, diamonds, and Gaussian cloud. Each click spring-animates all 100 shape slots (stiffness 70, damping 18, staggered by index) to the next layout; shapes not used by a composition collapse to size 0 with visibility hidden. Border-radius toggles between circles and squares per composition.

ORIGIN

1 / 10

Click anywhere

Source

compositions.ts333 lines
// Artemis II inspired color palette
export const COLORS = [
  "#4A6FA5", // blue
  "#C1272D", // red
  "#E87D2F", // orange
  "#D4A76A", // tan
  "#8B6914", // dark gold
  "#A8B88C", // sage green
  "#2C2C2C", // near black
  "#7B8794", // grey
  "#5B7553", // olive
  "#8FBCE6", // light blue
  "#1B2B5A", // navy
  "#C4A882", // khaki
  "#D4785C", // terracotta
  "#FFFFFF", // white
  "#3D3D3D", // dark grey
  "#B8472A", // dark red
];

export interface ShapeState {
  x: number;
  y: number;
  size: number;
  rotation: number;
  borderRadius: number; // 50 = circle, 0 = square
  color: string;
}

// Max shapes across all compositions — excess ones collapse to size 0
export const MAX_SHAPES = 100;

type CompositionFn = (
  width: number,
  height: number,
  colors: string[]
) => ShapeState[];

// Base unit — all sizes scale from this
const unit = (width: number, height: number) => Math.min(width, height) * 0.01;

// Helper: pad a shape array to MAX_SHAPES, collapsing extras to center at size 0
function padToMax(
  shapes: ShapeState[],
  width: number,
  height: number
): ShapeState[] {
  const cx = width / 2;
  const cy = height / 2;
  const result = [...shapes];
  while (result.length < MAX_SHAPES) {
    result.push({
      x: cx,
      y: cy,
      size: 0,
      rotation: 0,
      borderRadius: 50,
      color: shapes[result.length % shapes.length]?.color ?? "#FFFFFF",
    });
  }
  return result;
}

// Composition 0: All shapes collapsed to center — only first has size
const singleCircle: CompositionFn = (width, height) => {
  const cx = width / 2;
  const cy = height / 2;
  const u = unit(width, height);

  return Array.from({ length: MAX_SHAPES }, (_, i) => ({
    x: cx,
    y: cy,
    size: i === 0 ? u * 18 : 0,
    rotation: 0,
    borderRadius: 50,
    color: i === 0 ? "#FFFFFF" : COLORS[i % COLORS.length],
  }));
};

// Composition 1: Clustered circles — 16 shapes
const clusteredCircles: CompositionFn = (width, height, colors) => {
  const count = 16;
  const cx = width / 2;
  const cy = height / 2;
  const u = unit(width, height);
  const spread = u * 22;

  const shapes = Array.from({ length: count }, (_, i) => {
    const angle = (i / count) * Math.PI * 2 + Math.random() * 0.5;
    const dist = Math.random() * spread;
    const size = u * (6 + Math.random() * 22);
    return {
      x: cx + Math.cos(angle) * dist,
      y: cy + Math.sin(angle) * dist,
      size,
      rotation: 0,
      borderRadius: 50,
      color: colors[i % colors.length],
    };
  });
  return padToMax(shapes, width, height);
};

// Composition 2: Spiral — 60 shapes, arc-length spaced
const spiral: CompositionFn = (width, height, colors) => {
  const count = 60;
  const cx = width / 2;
  const cy = height / 2;
  const u = unit(width, height);
  // Pre-compute sizes: small in center, large on outside
  const sizes = Array.from({ length: count }, (_, i) => {
    const t = i / (count - 1);
    return u * (2.5 + t * 10);
  });

  // Walk the spiral, spacing circles along the arm with slight overlap.
  // Growth rate increases with angle so outer revolutions are spaced wider apart.
  const shapes: ShapeState[] = [];
  let angle = 0;

  for (let i = 0; i < count; i++) {
    const size = sizes[i];
    const halfSize = size / 2;
    // Spiral radius grows faster as angle increases (quadratic term)
    const spiralR = u * 1.0 * angle + u * 0.06 * angle * angle;

    shapes.push({
      x: cx + Math.cos(angle) * spiralR,
      y: cy + Math.sin(angle) * spiralR,
      size,
      rotation: 0,
      borderRadius: 50,
      color: colors[i % colors.length],
    });

    // Advance angle: step along arc by ~75% of combined radii (slight overlap)
    const nextSize = sizes[Math.min(i + 1, count - 1)];
    const step = (halfSize + nextSize / 2) * 0.75;
    const r = Math.max(spiralR, u * 4);
    angle += step / r;
  }

  return padToMax(shapes, width, height);
};

// Composition 3: Wreath / ring of circles — 80 shapes
const wreath: CompositionFn = (width, height, colors) => {
  const count = 80;
  const cx = width / 2;
  const cy = height / 2;
  const u = unit(width, height);
  const ringRadius = u * 30;
  const ringThickness = u * 12;

  const shapes = Array.from({ length: count }, (_, i) => {
    const angle = (i / count) * Math.PI * 2;
    const radiusJitter = (Math.random() - 0.5) * ringThickness;
    const dist = ringRadius + radiusJitter;
    const size = u * (3 + Math.random() * 9);
    return {
      x: cx + Math.cos(angle) * dist,
      y: cy + Math.sin(angle) * dist,
      size,
      rotation: 0,
      borderRadius: 50,
      color: colors[i % colors.length],
    };
  });
  return padToMax(shapes, width, height);
};

// Composition 4: Scattered squares — 70 shapes
const scatteredSquares: CompositionFn = (width, height, colors) => {
  const count = 70;
  const u = unit(width, height);
  const margin = u * 8;
  const shapes = Array.from({ length: count }, (_, i) => {
    const size = u * (1.5 + Math.random() * 7);
    return {
      x: margin + Math.random() * (width - margin * 2),
      y: margin + Math.random() * (height - margin * 2),
      size,
      rotation: Math.random() * 45,
      borderRadius: 0,
      color: colors[i % colors.length],
    };
  });
  return padToMax(shapes, width, height);
};

// Composition 5: Grid of rectangles — 12 shapes
const gridRectangles: CompositionFn = (width, height, colors) => {
  const cols = 4;
  const rows = 3;
  const count = cols * rows;
  const u = unit(width, height);
  const gridW = u * 75;
  const gridH = u * 58;
  const cellW = gridW / cols;
  const cellH = gridH / rows;
  const offsetX = (width - gridW) / 2;
  const offsetY = (height - gridH) / 2;

  const shapes = Array.from({ length: count }, (_, i) => {
    const col = i % cols;
    const row = Math.floor(i / cols);
    return {
      x: offsetX + col * cellW + cellW / 2,
      y: offsetY + row * cellH + cellH / 2,
      size: Math.min(cellW, cellH) * 0.92,
      rotation: 0,
      borderRadius: 0,
      color: colors[i % colors.length],
    };
  });
  return padToMax(shapes, width, height);
};

// Composition 6: Diagonal strips — 24 shapes
const diagonalStrips: CompositionFn = (width, height, colors) => {
  const count = 24;
  const u = unit(width, height);
  const shapes = Array.from({ length: count }, (_, i) => {
    const t = i / count;
    const size = u * (3 + Math.random() * 14);
    return {
      x: width * 0.1 + t * width * 0.8,
      y: height * 0.1 + t * height * 0.8 + (Math.random() - 0.5) * u * 16,
      size,
      rotation: -45 + Math.random() * 10,
      borderRadius: 0,
      color: colors[i % colors.length],
    };
  });
  return padToMax(shapes, width, height);
};

// Composition 7: Horizontal color bar — 16 shapes
const colorBar: CompositionFn = (width, height, colors) => {
  const count = 16;
  const u = unit(width, height);
  const barWidth = u * 80;
  const barHeight = u * 14;
  const segmentWidth = barWidth / count;
  const startX = (width - barWidth) / 2;
  const cy = height / 2;

  const shapes = Array.from({ length: count }, (_, i) => ({
    x: startX + i * segmentWidth + segmentWidth / 2,
    y: cy,
    size: Math.min(segmentWidth * 1.05, barHeight),
    rotation: 0,
    borderRadius: 0,
    color: colors[i % colors.length],
  }));
  return padToMax(shapes, width, height);
};

// Composition 8: Overlapping diamonds — 20 shapes
const diamonds: CompositionFn = (width, height, colors) => {
  const count = 20;
  const cx = width / 2;
  const cy = height / 2;
  const u = unit(width, height);

  const shapes = Array.from({ length: count }, (_, i) => {
    const angle = (i / count) * Math.PI * 2;
    const dist = u * (8 + Math.random() * 26);
    const size = u * (5 + Math.random() * 16);
    return {
      x: cx + Math.cos(angle) * dist,
      y: cy + Math.sin(angle) * dist,
      size,
      rotation: 45,
      borderRadius: 0,
      color: colors[i % colors.length],
    };
  });
  return padToMax(shapes, width, height);
};

// Composition 9: Dense cloud — 100 shapes
const cloud: CompositionFn = (width, height, colors) => {
  const count = 100;
  const cx = width / 2;
  const cy = height / 2;
  const u = unit(width, height);
  const spread = u * 30;

  const shapes = Array.from({ length: count }, (_, i) => {
    const r1 = Math.random();
    const r2 = Math.random();
    const gaussX = Math.sqrt(-2 * Math.log(r1)) * Math.cos(2 * Math.PI * r2);
    const gaussY = Math.sqrt(-2 * Math.log(r1)) * Math.sin(2 * Math.PI * r2);
    const size = u * (2 + Math.random() * 9);
    return {
      x: cx + gaussX * spread * 0.4,
      y: cy + gaussY * spread * 0.4,
      size,
      rotation: 0,
      borderRadius: 50,
      color: colors[i % colors.length],
    };
  });
  return padToMax(shapes, width, height);
};

export const compositions: CompositionFn[] = [
  singleCircle,
  clusteredCircles,
  spiral,
  wreath,
  scatteredSquares,
  gridRectangles,
  diagonalStrips,
  colorBar,
  diamonds,
  cloud,
];

export const compositionNames = [
  "ORIGIN",
  "CLUSTER",
  "SPIRAL",
  "WREATH",
  "SCATTER",
  "GRID",
  "DIAGONAL",
  "SPECTRUM",
  "DIAMOND",
  "CLOUD",
];
Playground.tsx122 lines
"use client";

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

export default function Playground() {
  const [compositionIndex, setCompositionIndex] = useState(0);
  const [isTransitioning, setIsTransitioning] = useState(false);
  const [dimensions, setDimensions] = useState<{
    width: number;
    height: number;
  } | null>(null);
  const [seed] = useState(() => Math.random());
  const stageRef = useRef<HTMLDivElement>(null);

  // Measure the stage itself, not the window — inside the detail-page frame
  // (or the HQ desktop) the fixed inset-0 root is contained to a smaller box.
  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 shapes = useMemo(() => {
    if (!dimensions) return [];
    const seededColors = [...COLORS].sort(
      () => Math.sin(seed * 1000) - 0.5
    );
    const compositionFn = compositions[compositionIndex];
    return compositionFn(dimensions.width, dimensions.height, seededColors);
  }, [compositionIndex, dimensions, seed]);

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

  return (
    <div
      ref={stageRef}
      className="fixed inset-0 cursor-pointer select-none"
      onClick={handleClick}
    >
      {shapes.map((shape, i) => (
        <motion.div
          key={`shape-${i}`}
          initial={false}
          animate={{
            left: shape.x,
            top: shape.y,
            width: shape.size,
            height: shape.size,
            rotate: shape.rotation,
            borderRadius: `${shape.borderRadius}%`,
          }}
          transition={{
            type: "spring",
            stiffness: 70,
            damping: 18,
            mass: 0.8 + (i % 20) * 0.05,
            delay: (i / MAX_SHAPES) * 0.5,
          }}
          className="absolute"
          style={{
            backgroundColor: shape.color,
            x: "-50%",
            y: "-50%",
            visibility: shape.size === 0 ? "hidden" : "visible",
          }}
        />
      ))}

      {/* UI overlay */}
      <div className="fixed bottom-8 left-8 pointer-events-none font-[family-name:var(--font-flux)]" style={{ fontVariationSettings: "'wght' 300, 'SRIF' 100" }}>
        <motion.div
          key={compositionNames[compositionIndex]}
          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">
            {compositionNames[compositionIndex]}
          </p>
          <p className="text-[12px] tracking-[0.2em] text-foreground/25 mt-1">
            {compositionIndex + 1} / {compositions.length}
          </p>
        </motion.div>
      </div>

      <div className="fixed bottom-8 right-8 pointer-events-none 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