01Spot array — dots rise where reaction wins
A Gray-Scott grid seeded with a few U drops in a uniform V field grows a spot wherever the reaction survives, and the spots push each other into a regular array.
Best forLiving dot texture for hero backgrounds, cover illustration seeds
ImageDataputImageDatarequestAnimationFrame

<main class="stage">
<div class="tsa" id="tsa">
<p class="tsa__hint">
<svg class="tsa__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M140 24a12 12 0 1 1 12 12a12 12 0 0 1-12-12M84 52a12 12 0 1 0 12 12a12 12 0 0 0-12-12m104 4a12 12 0 1 1 12 12a12 12 0 0 1-12-12M52 100a12 12 0 1 0 12 12a12 12 0 0 0-12-12m136 8a12 12 0 1 1 12 12a12 12 0 0 1-12-12" opacity=".2"/><path d="M192 112a16 16 0 1 1 16 16a16 16 0 0 1-16-16m-64-64a24 24 0 1 0 24 24a24 24 0 0 0-24-24M72 72a24 24 0 1 0 24 24a24 24 0 0 0-24-24m112 8a24 24 0 1 1 24 24a24 24 0 0 1-24-24M40 128a24 24 0 1 0 24 24a24 24 0 0 0-24-24m136 0a24 24 0 1 1 24 24a24 24 0 0 1-24-24"/></g></svg>
<span>Spots sprout wherever reaction wins — a new seed regrows the whole layout</span>
</p>
<div class="tsa__field" id="field">
<canvas class="tsa__cv" id="cv"></canvas>
</div>
<div class="tsa__bar">
<label class="tsa__dial">Seed
<input type="number" id="seed" min="1" max="9999" step="1" value="7" aria-label="Pattern seed">
</label>
<label class="tsa__dial tsa__grow">Feed rate F
<input type="range" id="fk" min="0.010" max="0.070" step="0.001" value="0.030" aria-label="Feed rate F">
</label>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 점 무늬 — Gray-Scott 반응-확산. 균일한 U 밭에 뿌린 V 씨앗 몇 점이
// 자라며 서로 밀어내 규칙적 점 배열로 정착하는 축 (01).
// 파스텔 — mix() 로 옅게 섞은 로컬 변수만 쓴다. 리터럴 hex 0개(BD26).
$tsa-field: mix($subject-sky, $stage-paper, 30%); // 밭 바탕 — 옅은 하늘색
$tsa-mint: mix($subject-mint, $stage-paper, 42%); // 점 주위 옅은 무늬
$tsa-lilac: $subject-lilac; // 점 본체 — 라일락 파스텔
$tsa-inkend: mix($subject-lilac, $subject-ink, 30%); // 가장 진한 끝단
$duration: $dur-loop;
$easing: $ease-out;
$color: $tsa-lilac;
@include stage($stage-yellow);
.tsa {
// V 농도 → 색 5단계 LUT (JS 가 getComputedStyle 로 읽는다)
--tsa-c0: #{$stage-paper};
--tsa-c1: #{$tsa-field};
--tsa-c2: #{$tsa-mint};
--tsa-c3: #{$tsa-lilac};
--tsa-c4: #{$tsa-inkend};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.tsa__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.tsa__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-ink; }
.tsa__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $stage-paper;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
}
.tsa__cv { display: block; width: 100%; height: 100%; }
.tsa__bar { display: flex; align-items: center; gap: $sp-3; flex-wrap: wrap; }
.tsa__dial {
display: flex; align-items: center; gap: $sp-2;
font-weight: 700; font-size: 11px; color: $subject-ink;
}
.tsa__grow { flex: 1 1 auto; min-width: 120px; }
.tsa__grow input { flex: 1 1 auto; }
.tsa__dial input {
width: 72px; margin: 0;
padding: $sp-1 $sp-2; border: 0; border-radius: $r-sm;
background: rgba($subject-ink, .08); color: $subject-ink;
font-weight: 700; font-size: 12px; font-family: inherit;
}
.tsa__dial input[type="range"] {
width: auto; padding: 0; accent-color: $tsa-lilac;
}
.tsa__dial input:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
@media (max-width: 340px) {
.tsa { gap: $sp-1; }
.tsa__field { height: 100px; }
.tsa__dial input { width: 56px; }
.tsa__hint { font-size: 10px; }
}
// reduce — 루프 없이 성숙 상태 한 장만 남긴다
@media (prefers-reduced-motion: reduce) {
.tsa.is-still .tsa__field { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tsa {
--tsa-c0: #ffffff;
--tsa-c1: rgb(82.9411764706%, 93.5294117647%, 100%);
--tsa-c2: rgb(70.5176470588%, 92.9176470588%, 85.3411764706%);
--tsa-c3: #b9a0ff;
--tsa-c4: rgb(28.0784313725%, 24.3137254902%, 37.137254902%);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.tsa__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.tsa__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #17141a;
}
.tsa__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
}
.tsa__cv {
display: block;
width: 100%;
height: 100%;
}
.tsa__bar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.tsa__dial {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: 11px;
color: #17141a;
}
.tsa__grow {
flex: 1 1 auto;
min-width: 120px;
}
.tsa__grow input {
flex: 1 1 auto;
}
.tsa__dial input {
width: 72px;
margin: 0;
padding: 4px 8px;
border: 0;
border-radius: 8px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
font-weight: 700;
font-size: 12px;
font-family: inherit;
}
.tsa__dial input[type=range] {
width: auto;
padding: 0;
accent-color: #b9a0ff;
}
.tsa__dial input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
@media (max-width: 340px) {
.tsa {
gap: 4px;
}
.tsa__field {
height: 100px;
}
.tsa__dial input {
width: 56px;
}
.tsa__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.tsa.is-still .tsa__field {
cursor: default;
}
}// 점 무늬 — Gray-Scott 반응-확산. U(사료)·V(촉매) 두 격자를 64x40 셀에 두고
// 매 프레임 8스텝씩 라플라스 확산 + 반응항을 계산해 ImageData 로 화소를 박는다.
// F/K 를 점이 낱개로 남는 구간(솔리톤)으로 잡으면, 시드가 자라 서로 밀며
// 규칙적인 점 배열로 정착한다 — 표범·물고기 점 무늬의 규칙과 같은 축이다.
// 렌더: 셀 V 값을 파스텔 LUT 로 색을 정해 저해상 버퍼에 putImageData, 그 버퍼를
// drawImage 로 확대한다(imageSmoothingEnabled — 부드럽게).
var root = document.getElementById('tsa');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var seedIn = document.getElementById('seed');
var fkIn = document.getElementById('fk');
// 결정적 난수 — mulberry32. 같은 시드면 같은 수열을 돌려준다
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var GW = 64, GH = 40; // 격자 셀 수 (480x300 비율)
var DU = 0.16, DV = 0.08; // 확산 계수 (고정 — 5점 라플라스 dt=1 안정 스케일)
var F = 0.030, K = 0.0615; // 사료율·제거율 (fk 슬라이더가 F 를 움직인다)
var STEPS = 8; // 프레임당 시뮬레이션 스텝
var PRE = 1500; // 시작 시 미리 성숙시키는 스텝 — 점 배열이 정착한 직후
var WOBBLE = 0.005; // 성숙 뒤에도 계속 주는 미세 U 주입량 — 점이 살아 움직인다
var u = new Float32Array(GW * GH);
var v = new Float32Array(GW * GH);
var u2 = new Float32Array(GW * GH);
var v2 = new Float32Array(GW * GH);
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1;
var raf = 0, phase = 0;
// ── 색 — scss 가 내려준 파스텔 사용자속성을 읽어 5단계 LUT 를 미리 편다 ──
var css = getComputedStyle(root);
var LUT = new Uint8Array(256 * 3);
function hex6(s) {
s = String(s).trim().replace('#', '');
if (s.length === 3) s = s.charAt(0) + s.charAt(0) + s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2);
return [parseInt(s.slice(0, 2), 16) || 0, parseInt(s.slice(2, 4), 16) || 0, parseInt(s.slice(4, 6), 16) || 0];
}
function buildLut() {
var stops = [hex6(tok('--tsa-c0')), hex6(tok('--tsa-c1')), hex6(tok('--tsa-c2')), hex6(tok('--tsa-c3')), hex6(tok('--tsa-c4'))];
for (var q = 0; q < 256; q++) {
var t = q / 255 * (stops.length - 1);
var k = Math.min(stops.length - 2, Math.floor(t));
var f = t - k;
for (var c = 0; c < 3; c++) LUT[q * 3 + c] = stops[k][c] + (stops[k + 1][c] - stops[k][c]) * f;
}
}
function tok(name) { return css.getPropertyValue(name); }
// ── Seed — U=1 밭에 V=0.5 원형 씨앗 6개. 좌표를 rng 로 뽑는다 ──
function seedField(sd) {
var rng = mulberry32(sd >>> 0);
u.fill(1); v.fill(0);
var n = 6;
for (var s = 0; s < n; s++) {
var cx = 6 + rng() * (GW - 12), cy = 4 + rng() * (GH - 8);
var rr = 2 + rng() * 1.5;
var x0 = Math.max(0, Math.floor(cx - rr - 1)), x1 = Math.min(GW - 1, Math.ceil(cx + rr + 1));
var y0 = Math.max(0, Math.floor(cy - rr - 1)), y1 = Math.min(GH - 1, Math.ceil(cy + rr + 1));
for (var y = y0; y <= y1; y++) {
for (var x = x0; x <= x1; x++) {
var dx = x - cx, dy = y - cy;
if (dx * dx + dy * dy <= rr * rr) { v[y * GW + x] = 0.5; u[y * GW + x] = 0.25; }
}
}
}
}
// ── 한 스텝 — 5점 라플라스(토러스 경계) + Gray-Scott 반응항 ──
function step() {
for (var y = 0; y < GH; y++) {
var yT = ((y - 1 + GH) % GH) * GW, yB = ((y + 1) % GH) * GW, yC = y * GW;
for (var x = 0; x < GW; x++) {
var xL = (x - 1 + GW) % GW, xR = (x + 1) % GW;
var i = yC + x;
var uc = u[i], vc = v[i];
var lapU = u[yC + xL] + u[yC + xR] + u[yT + x] + u[yB + x] - 4 * uc;
var lapV = v[yC + xL] + v[yC + xR] + v[yT + x] + v[yB + x] - 4 * vc;
var uvv = uc * vc * vc;
var nu = uc + DU * lapU - uvv + F * (1 - uc);
var nv = vc + DV * lapV + uvv - (F + K) * vc;
u2[i] = nu < 0 ? 0 : (nu > 1 ? 1 : nu);
v2[i] = nv < 0 ? 0 : (nv > 1 ? 1 : nv);
}
}
var tu = u; u = u2; u2 = tu;
var tv = v; v = v2; v2 = tv;
}
var buf = null, small = null, sctx = null;
function setupBuffers() {
small = document.createElement('canvas');
small.width = GW; small.height = GH;
sctx = small.getContext('2d');
buf = sctx.createImageData(GW, GH);
}
function paint() {
var px = buf.data, i4 = 0;
for (var p = 0; p < GW * GH; p++) {
// V 농도 0(종이) → 1(잉크끼 파스텔) — LUT 로 색 단계
var q = Math.max(0, Math.min(255, (v[p] * 340) | 0));
px[i4] = LUT[q * 3];
px[i4 + 1] = LUT[q * 3 + 1];
px[i4 + 2] = LUT[q * 3 + 2];
px[i4 + 3] = 255;
i4 += 4;
}
sctx.putImageData(buf, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(small, 0, 0, GW, GH, 0, 0, W, H);
}
// 미세 U 주입 — 사인 리본이 시간에 흐르며 아주 조금씩 U 를 채운다.
// 정착한 점 배열이 이 흔들림에 반응해 느리게 재배치된다(살아 있는 무늬).
function wobble(tt) {
var ph = phase;
for (var y = 0; y < GH; y++) {
for (var x = 0; x < GW; x++) {
var wob = 0.5 + 0.5 * Math.sin(x * 0.18 + y * 0.12 + tt + ph);
var i = y * GW + x;
var nu = u[i] + wob * WOBBLE;
u[i] = nu > 1 ? 1 : nu;
}
}
}
function frame() {
for (var s = 0; s < STEPS; s++) { wobble(phase + s * 0.02); step(); }
phase += STEPS * 0.02;
paint();
raf = requestAnimationFrame(frame);
}
function fit() {
var r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// Seed 재생성 → 미리성숙(PRE 스텝 동기) → 즉시 다시 그리기 (reduce 경로에서도)
function regrow(sd) {
phase = sd * 0.7;
seedField(sd);
for (var s = 0; s < PRE; s++) step();
paint();
}
function onSeed() {
var val = Math.max(1, Math.min(9999, Math.floor(Number(seedIn.value)) || 1));
regrow(val);
}
function onFk() {
F = Number(fkIn.value);
// reduce 경로에서도 프레임이 즉시 반응하게 — 새 F 로 짧게 진행해 다시 그린다
if (reduce) { for (var s = 0; s < 60; s++) step(); paint(); }
}
seedIn.addEventListener('input', onSeed);
seedIn.addEventListener('change', onSeed);
fkIn.addEventListener('input', onFk);
fkIn.addEventListener('change', onFk);
window.addEventListener('resize', function () { fit(); paint(); });
buildLut();
setupBuffers();
fit();
regrow(Number(seedIn.value) || 7);
if (reduce) {
// reduce — 루프를 돌지 않고 성숙 상태 한 장만 그린다
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}// SpotArray — vanilla 판(run/311/demo/01-turing-spot-array/index.html) 의 React 포트.
// Gray-Scott 반응-확산을 64x40 격자에서 계산해 ImageData 로 화소를 박고
// imageSmoothingEnabled 를 켠 채 drawImage 로 늘려 화면을 채운다.
// 움직임은 캔버스 rAF 루프가 전부 그리므로 @keyframes 가 없다 — props 는 CSS 변수로 내려간다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SpotArray.module.scss";
export type SpotArrayProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
// React 이벤트 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type InputEvt = { target: { value: string } };
const GW = 64, GH = 40; // 격자 셀 수 — 화면이 커져도 이 값은 그대로다
const DU = 0.16, DV = 0.08; // 확산 계수 (5점 라플라스 dt=1 안정 스케일)
const STEPS = 8; // 프레임당 시뮬레이션 스텝
const PRE = 1500; // 시작 시 미리 성숙시키는 스텝 — 점 배열이 정착한 직후
const WOBBLE = 0.005; // 성숙 뒤에도 계속 주는 미세 U 주입량
const FIELD_LABEL = "점 무늬 반응 실험판";
// 결정적 난수 — mulberry32. 같은 시드면 같은 수열을 돌려준다
function mulberry32(a: number): () => number {
return function () {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 토큰 다섯 단계를 256칸 표로 편다 — 프레임마다 색을 다시 섞지 않게 하기 위해서다
function buildLut(stops: number[][]): Uint8Array {
const lut = new Uint8Array(256 * 3);
for (let q = 0; q < 256; q++) {
const t = (q / 255) * (stops.length - 1);
const k = Math.min(stops.length - 2, Math.floor(t));
const f = t - k;
for (let c = 0; c < 3; c++) lut[q * 3 + c] = stops[k][c] + (stops[k + 1][c] - stops[k][c]) * f;
}
return lut;
}
// Gray-Scott 상태 한 덩어리 — u·v 두 격자와 다음 프레임용 버퍼
type Grid = { u: Float32Array; v: Float32Array; u2: Float32Array; v2: Float32Array };
// U=1 밭에 V=0.5 원형 씨앗 6개 — 좌표를 rng 로 뽑아 시드가 배치를 결정한다
function seedField(sd: number): Grid {
const rng = mulberry32(sd >>> 0);
const g: Grid = {
u: new Float32Array(GW * GH).fill(1),
v: new Float32Array(GW * GH),
u2: new Float32Array(GW * GH),
v2: new Float32Array(GW * GH),
};
for (let s = 0; s < 6; s++) {
const cx = 6 + rng() * (GW - 12), cy = 4 + rng() * (GH - 8);
const rr = 2 + rng() * 1.5;
const x0 = Math.max(0, Math.floor(cx - rr - 1)), x1 = Math.min(GW - 1, Math.ceil(cx + rr + 1));
const y0 = Math.max(0, Math.floor(cy - rr - 1)), y1 = Math.min(GH - 1, Math.ceil(cy + rr + 1));
for (let y = y0; y <= y1; y++) {
for (let x = x0; x <= x1; x++) {
const dx = x - cx, dy = y - cy;
if (dx * dx + dy * dy <= rr * rr) { g.v[y * GW + x] = 0.5; g.u[y * GW + x] = 0.25; }
}
}
}
return g;
}
export default function SpotArray({
duration = "2s", // vanilla $duration: $dur-loop
easing = "ease-out", // vanilla $easing: $ease-out
color = "#b9a0ff", // vanilla $color: $tsa-lilac
}: SpotArrayProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const smallRef = useRef<HTMLCanvasElement | null>(null);
const sctxRef = useRef<CanvasRenderingContext2D | null>(null);
const bufRef = useRef<ImageData | null>(null);
const lutRef = useRef<Uint8Array | null>(null);
const gridRef = useRef<Grid | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const fkRef = useRef(0.03) as Box<number>;
const phaseRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [seed, setSeed] = useState(7);
const [fk, setFk] = useState(0.03);
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function hex3to6(v: string): string {
if (v.length === 3) return v.charAt(0) + v.charAt(0) + v.charAt(1) + v.charAt(1) + v.charAt(2) + v.charAt(2);
return v;
}
function stops(): number[][] {
const root = rootRef.current;
if (!root) return [[0, 0, 0], [255, 255, 255]];
const one = (n: string) => {
const v = hex3to6(getComputedStyle(root).getPropertyValue(n).trim().replace("#", ""));
return [parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16)];
};
return [one("--tsa-c0"), one("--tsa-c1"), one("--tsa-c2"), one("--tsa-c3"), one("--tsa-c4")];
}
// 고DPI 대응 — 배킹 스토어는 devicePixelRatio 배, 그리기 좌표계는 CSS 픽셀 그대로
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
const ctx = ctxRef.current;
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 한 스텝 — 5점 라플라스(토러스 경계) + Gray-Scott 반응항
function step(): void {
const g = gridRef.current;
if (!g) return;
const F = fkRef.current, K = 0.0615;
for (let y = 0; y < GH; y++) {
const yT = ((y - 1 + GH) % GH) * GW, yB = ((y + 1) % GH) * GW, yC = y * GW;
for (let x = 0; x < GW; x++) {
const xL = (x - 1 + GW) % GW, xR = (x + 1) % GW;
const i = yC + x;
const uc = g.u[i], vc = g.v[i];
const lapU = g.u[yC + xL] + g.u[yC + xR] + g.u[yT + x] + g.u[yB + x] - 4 * uc;
const lapV = g.v[yC + xL] + g.v[yC + xR] + g.v[yT + x] + g.v[yB + x] - 4 * vc;
const uvv = uc * vc * vc;
const nu = uc + DU * lapU - uvv + F * (1 - uc);
const nv = vc + DV * lapV + uvv - (F + K) * vc;
g.u2[i] = nu < 0 ? 0 : nu > 1 ? 1 : nu;
g.v2[i] = nv < 0 ? 0 : nv > 1 ? 1 : nv;
}
}
const tu = g.u; g.u = g.u2; g.u2 = tu;
const tv = g.v; g.v = g.v2; g.v2 = tv;
}
// 미세 U 주입 — 사인 리본이 시간에 흐르며 아주 조금씩 U 를 채운다
function wobble(tt: number): void {
const g = gridRef.current;
if (!g) return;
const ph = phaseRef.current;
for (let y = 0; y < GH; y++) {
for (let x = 0; x < GW; x++) {
const wob = 0.5 + 0.5 * Math.sin(x * 0.18 + y * 0.12 + tt + ph);
const i = y * GW + x;
const nu = g.u[i] + wob * WOBBLE;
g.u[i] = nu > 1 ? 1 : nu;
}
}
}
function paint(): void {
const ctx = ctxRef.current, sctx = sctxRef.current, g = gridRef.current;
const buf = bufRef.current, lut = lutRef.current;
if (!ctx || !sctx || !g || !buf || !lut) return;
const W = sizeRef.current.w, H = sizeRef.current.h;
const px = buf.data;
let i4 = 0;
for (let p = 0; p < GW * GH; p++) {
// V 농도 0(종이) → 1(잉크끼 파스텔) — LUT 로 색 단계
const q = Math.max(0, Math.min(255, (g.v[p] * 340) | 0));
px[i4] = lut[q * 3];
px[i4 + 1] = lut[q * 3 + 1];
px[i4 + 2] = lut[q * 3 + 2];
px[i4 + 3] = 255;
i4 += 4;
}
sctx.putImageData(buf, 0, 0);
// 확대 보간을 켜 둔 채로 늘린다 — 이 한 줄이 셀 덩어리를 부드러운 무늬로 바꾼다
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(sctx.canvas, 0, 0, GW, GH, 0, 0, W, H);
}
function frame(): void {
for (let s = 0; s < STEPS; s++) { wobble(phaseRef.current + s * 0.02); step(); }
phaseRef.current += STEPS * 0.02;
paint();
rafRef.current = requestAnimationFrame(frame);
}
// 시드 재생성 → 미리성숙(PRE 스텝 동기) → 즉시 다시 그리기 (reduce 경로에서도)
function regrow(sd: number): void {
phaseRef.current = sd * 0.7;
gridRef.current = seedField(sd);
for (let s = 0; s < PRE; s++) step();
paint();
}
function onSeedInput(e: InputEvt): void {
const v = Math.max(1, Math.min(9999, Math.floor(Number(e.target.value)) || 1));
setSeed(v);
regrow(v);
}
function onFkInput(e: InputEvt): void {
const v = Number(e.target.value);
fkRef.current = v;
setFk(v);
// reduce 경로에서도 프레임이 즉시 반응하게 — 새 F 로 짧게 진행해 다시 그린다
if (reduced()) { for (let s = 0; s < 60; s++) step(); paint(); }
}
function onResize(): void {
fit();
paint();
}
useEffect(() => {
const cv = cvRef.current;
if (!cv) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
const small = document.createElement("canvas");
small.width = GW;
small.height = GH;
smallRef.current = small;
sctxRef.current = small.getContext("2d");
if (!sctxRef.current) return;
bufRef.current = sctxRef.current.createImageData(GW, NH0()); // 한 번만 만들어 두고 프레임마다 같은 배열을 덮어쓴다
lutRef.current = buildLut(stops());
fit();
regrow(seed);
window.addEventListener("resize", onResize);
if (reduced()) {
// reduce — 루프를 시작하지 않고 성숙 상태 한 장만 그린다
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
const cls = [styles.root, still ? styles.isStill : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M140 24a12 12 0 1 1 12 12a12 12 0 0 1-12-12M84 52a12 12 0 1 0 12 12a12 12 0 0 0-12-12m104 4a12 12 0 1 1 12 12a12 12 0 0 1-12-12M52 100a12 12 0 1 0 12 12a12 12 0 0 0-12-12m136 8a12 12 0 1 1 12 12a12 12 0 0 1-12-12" opacity=".2"/><path d="M192 112a16 16 0 1 1 16 16a16 16 0 0 1-16-16m-64-64a24 24 0 1 0 24 24a24 24 0 0 0-24-24M72 72a24 24 0 1 0 24 24a24 24 0 0 0-24-24m112 8a24 24 0 1 1 24 24a24 24 0 0 1-24-24M40 128a24 24 0 1 0 24 24a24 24 0 0 0-24-24m136 0a24 24 0 1 1 24 24a24 24 0 0 1-24-24"/></g></svg>
<span>반응이 이기는 자리마다 점이 돋아 서로 밉니다 — 시드를 바꾸면 배치가 다시 태어납니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true">64 × 40</b>
</div>
<div className={styles.bar}>
<label className={styles.dial}>시드
<input
type="number"
min={1}
max={9999}
step={1}
value={seed}
aria-label="패턴 시드"
onChange={onSeedInput}
/>
</label>
<label className={styles.dial + " " + styles.grow}>사료율 F
<input
type="range"
min={0.01}
max={0.07}
step={0.001}
value={fk}
aria-label="사료율 F"
onChange={onFkInput}
/>
<b className={styles.val}>{fk.toFixed(3)}</b>
</label>
</div>
</div>
</main>
);
}
// ImageData 버퍼 높이 — GW·GH 상수와 같은 값 (한 줄 함수로 두면 타입 검사가 짧다)
function NH0(): number {
return GH;
}01 Spot array — dots rise where reaction wins — Measured area 52.878% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · 9 Reaction Diffusion Simulations, No Library
The post this demo lives in
9 Reaction Diffusion Simulations, No LibraryA reaction diffusion simulation computes two chemicals on a grid until spots, stripes and mazes emerge on their own. The See all demos in this post
If you like this effect - similar demos
Creative UI07 Acid dissolve — a reaction rule that erases the material it meetsFalling Sand Simulator, 9 Ways (No Library)
Creative UI01 The basic hex cluster — one layer of DLA, random-walk particles sticking where they touchSnowflake Generator, 9 Ways (DLA, No Library)
Creative UI01 One sphere — the first 3D a minimal marcher makes9 Ray Marching Scenes in Canvas (No WebGL)