NEMAWASHI LAB

Mosaic

SVG

Typed text is sampled through sampleInkGrid and rebuilt as a grid of SVG rectangles or circles, each with fill-opacity proportional to the ink density at that cell after an adjustable contrast curve (applyContrast). An SVG feTurbulence + overlay grain filter is composited on top, stitching fractal noise at 0.85 baseFrequency with slope/intercept controls. The combination reads like a risograph tile print.

Mosaic

0 tiles

Controls
Columns88
Font size320
Gutter1.0
Contrast0.40
Grain0.30
Text
Font
Tile
Ink
Background

Source

Mosaic.tsx134 lines
"use client";

import { useEffect, useId, useState } from "react";
import ScrambleLink from "../ScrambleLink";
import DraggablePanel from "../DraggablePanel";
import { PanelColor, PanelSelect, PanelText } from "../_kansei/controls";
import { sampleInkGrid } from "../_kansei/sample-ink-grid";
import { applyContrast } from "../_kansei/helpers";
import type { FontChoice } from "../_kansei/fonts";

const W = 560;
const H = 320;
type Tile = { x: number; y: number; w: number; h: number; cx: number; cy: number; r: number; opacity: number; circle: boolean };

export default function Mosaic() {
  const [text, setText] = useState("Aa");
  const [fontSize, setFontSize] = useState(320);
  const [font, setFont] = useState<FontChoice>("sans");
  const [cols, setCols] = useState(88);
  const [gutter, setGutter] = useState(1);
  const [tile, setTile] = useState<"rect" | "circle">("rect");
  const [grain, setGrain] = useState(0.3);
  const [contrast, setContrast] = useState(0.4);
  const [inkColor, setInkColor] = useState("#1a1a1a");
  const [bgColor, setBgColor] = useState("#f4efe6");
  const [tiles, setTiles] = useState<Tile[]>([]);
  const filterId = `mosaic-grain-${useId().replace(/:/g, "")}`;

  useEffect(() => {
    let cancelled = false;
    document.fonts.ready.then(() => {
      if (cancelled) return;
      const { grid, rows } = sampleInkGrid(text, font, fontSize, cols, W * 2, H * 2, 0);
      const slotW = W / cols;
      const slotH = H / rows;
      const g = Math.max(0, gutter);
      const out: Tile[] = [];
      for (let j = 0; j < rows; j++) {
        for (let i = 0; i < cols; i++) {
          const level = applyContrast(grid[j * cols + i], contrast);
          if (level < 0.05) continue;
          const px = i * slotW + g / 2;
          const py = j * slotH + g / 2;
          const tw = slotW - g;
          const th = slotH - g;
          out.push({
            x: px, y: py, w: tw, h: th,
            cx: px + tw / 2, cy: py + th / 2,
            r: (Math.min(tw, th) / 2) * level,
            opacity: level,
            circle: tile === "circle",
          });
        }
      }
      setTiles(out);
    });
    return () => {
      cancelled = true;
    };
  }, [text, fontSize, font, cols, gutter, tile, contrast]);

  const grainSlope = grain * 2;
  const grainIntercept = 0.5 * (1 - grainSlope);

  return (
    <div className="fixed inset-0 flex items-center justify-center select-none" style={{ background: bgColor }}>
      <svg
        viewBox={`0 0 ${W} ${H}`}
        className="w-[90vw] max-w-[1000px] h-auto z-[1]"
        xmlns="http://www.w3.org/2000/svg"
        filter={grain > 0.01 ? `url(#${filterId})` : undefined}
      >
        {grain > 0.01 && (
          <defs>
            <filter id={filterId} x="0%" y="0%" width="100%" height="100%" colorInterpolationFilters="sRGB">
              <feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="4" stitchTiles="stitch" result="noise" />
              <feColorMatrix in="noise" type="saturate" values="0" result="mono" />
              <feComponentTransfer in="mono" result="scaled">
                <feFuncR type="linear" slope={grainSlope} intercept={grainIntercept} />
                <feFuncG type="linear" slope={grainSlope} intercept={grainIntercept} />
                <feFuncB type="linear" slope={grainSlope} intercept={grainIntercept} />
              </feComponentTransfer>
              <feBlend in="SourceGraphic" in2="scaled" mode="overlay" result="blended" />
              <feComposite in="blended" in2="SourceGraphic" operator="in" />
            </filter>
          </defs>
        )}
        {tiles.map((t, idx) =>
          t.circle ? (
            <circle key={idx} cx={t.cx} cy={t.cy} r={t.r} fill={inkColor} fillOpacity={t.opacity} />
          ) : (
            <rect key={idx} x={t.x} y={t.y} width={t.w} height={t.h} fill={inkColor} fillOpacity={t.opacity} />
          ),
        )}
      </svg>

      <div
        className="fixed bottom-8 left-8 pointer-events-none z-10 font-[family-name:var(--font-flux)]"
        style={{ fontVariationSettings: "'wght' 300, 'SRIF' 100" }}
      >
        <p className="text-[12px] tracking-[0.08em] uppercase text-foreground/50">Mosaic</p>
        <p className="text-[12px] tracking-[0.08em] mt-1 text-foreground/30">{tiles.length} tiles</p>
      </div>

      <DraggablePanel
        isLight={false}
        controls={[
          { label: "Columns", value: cols, set: (v) => setCols(Math.round(v)), min: 16, max: 140, step: 1 },
          { label: "Font size", value: fontSize, set: (v) => setFontSize(Math.round(v)), min: 80, max: 480, step: 1 },
          { label: "Gutter", value: gutter, set: setGutter, min: 0, max: 4, step: 0.5 },
          { label: "Contrast", value: contrast, set: setContrast, min: 0, max: 1.5, step: 0.05 },
          { label: "Grain", value: grain, set: setGrain, min: 0, max: 1, step: 0.02 },
        ]}
      >
        <PanelText label="Text" value={text} set={setText} placeholder="Aa" isLight={false} />
        <PanelSelect label="Font" value={font} options={["display", "sans", "noto"] as const} set={setFont} isLight={false} />
        <PanelSelect label="Tile" value={tile} options={["rect", "circle"] as const} set={setTile} isLight={false} />
        <PanelColor label="Ink" value={inkColor} set={setInkColor} isLight={false} />
        <PanelColor label="Background" value={bgColor} set={setBgColor} isLight={false} />
      </DraggablePanel>

      <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)] text-foreground/60"
          style={{ fontVariationSettings: "'wght' 600, 'SRIF' 400" }}
        />
      </div>
    </div>
  );
}

NEMAWASHI — Kotaro Abe