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
Source
"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>
);
}