09再集合 — ポインタがかき分けた文字が再び集まる
各粒子は目標ピクセルへの復元力を持ち、ポインタ周辺の半径内では押しのける力が勝ちます。手が通り過ぎた場所の文字は押しのけられ、また元の場所へ再集合します。012の放出軸ではなくサンプリング目標点の復元が核心です。
使いどころインタラクティブヒーロー・展示インスタレーション
getImageDataarcrequestAnimationFrame
<main class="stage">
<div class="gr" id="gr">
<p class="gr__hint">
<svg class="gr__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="none" stroke="currentColor" stroke-width="12" stroke-linecap="round"><path d="M104 64l72 64-72 64" opacity=".9"/><path d="M64 64l72 64-72 64" opacity=".5"/></g></svg>
<span>盤の上で指を動かすと押しのけられ、また集まります</span>
</p>
<div class="gr__field" id="field" role="application" aria-label="再集合の実験盤" tabindex="0">
<canvas class="gr__cv" id="cv"></canvas>
<b class="gr__badge" aria-hidden="true" id="badge">0 pts</b>
</div>
<div class="gr__bar">
<label class="gr__dial">単語
<input type="text" id="word" value="TOUCH" maxlength="12" spellcheck="false" aria-label="単語">
</label>
<label class="gr__dial">シード
<input type="number" id="seed" min="1" max="9999" value="7" aria-label="シード">
</label>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 재집합 — 움직임은 전부 캔버스 rAF 루프 안에 있다(@keyframes 0개 — 077-03 패턴).
// 입자색은 파스텔 변수를 커스텀 프로퍼티로 내려 JS 가 읽어 쓴다. 리터럴 hex 는 없다(BD26).
$glyph-ink: $subject-ink; // 글릿 본색 — 잉크
$glyph-sky: mix($subject-sky, $stage-paper, 42%); // 파스텔 하늘
$glyph-mint: mix($subject-mint, $stage-paper, 42%); // 파스텔 민트
$glyph-peach: mix($subject-orange-deep, $stage-paper, 30%); // 파스텔 복숭아
@include stage($stage-paper);
.gr {
--g0: #{$glyph-ink};
--g1: #{$glyph-sky};
--g2: #{$glyph-mint};
--g3: #{$glyph-peach};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.gr__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;
}
.gr__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $glyph-peach; }
.gr__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $stage-paper;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .12);
cursor: none;
touch-action: none;
}
.gr__field:focus-visible { outline: 2px solid $glyph-sky; outline-offset: 2px; }
.gr__cv { display: block; width: 100%; height: 100%; }
// 목표점(=샘플 픽셀) 수를 화면에 적는다 — 런타임 샘플링이 이 데모의 요점이다
.gr__badge {
position: absolute; right: $sp-2; top: $sp-2;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($subject-ink, .78);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.gr__bar { display: flex; align-items: flex-end; gap: $sp-3; }
.gr__dial {
display: flex; flex-direction: column; gap: $sp-1;
font-weight: 700; font-size: 11px; color: $subject-ink;
}
.gr__dial input {
width: 120px; margin: 0; padding: $sp-1 $sp-2;
border: 1px solid rgba($subject-ink, .2); border-radius: $r-sm;
background: $stage-paper; color: $subject-ink;
font-weight: 700; font-size: 12px;
}
.gr__dial input[type="number"] { width: 72px; }
.gr__dial input:focus-visible { outline: 2px solid $glyph-sky; outline-offset: 1px; }
@media (max-width: 340px) {
.gr { gap: $sp-1; }
.gr__field { height: 100px; }
.gr__badge { font-size: 10px; padding: 0 $sp-1; }
.gr__dial input { width: 88px; }
.gr__dial input[type="number"] { width: 56px; }
.gr__hint { font-size: 10px; }
}
// reduce — 루프가 아예 안 돈다. 목표로 수렴한 정지 포즈 한 장이 남는다.
@media (prefers-reduced-motion: reduce) {
.gr.is-still .gr__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gr {
--g0: #17141a;
--g1: rgb(76.1176470588%, 90.9411764706%, 100%);
--g2: rgb(70.5176470588%, 92.9176470588%, 85.3411764706%);
--g3: rgb(93.6470588235%, 75.6470588235%, 70%);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.gr__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;
}
.gr__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: rgb(93.6470588235%, 75.6470588235%, 70%);
}
.gr__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.12);
cursor: none;
touch-action: none;
}
.gr__field:focus-visible {
outline: 2px solid rgb(76.1176470588%, 90.9411764706%, 100%);
outline-offset: 2px;
}
.gr__cv {
display: block;
width: 100%;
height: 100%;
}
.gr__badge {
position: absolute;
right: 8px;
top: 8px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.gr__bar {
display: flex;
align-items: flex-end;
gap: 12px;
}
.gr__dial {
display: flex;
flex-direction: column;
gap: 4px;
font-weight: 700;
font-size: 11px;
color: #17141a;
}
.gr__dial input {
width: 120px;
margin: 0;
padding: 4px 8px;
border: 1px solid rgba(23, 20, 26, 0.2);
border-radius: 8px;
background: #ffffff;
color: #17141a;
font-weight: 700;
font-size: 12px;
}
.gr__dial input[type=number] {
width: 72px;
}
.gr__dial input:focus-visible {
outline: 2px solid rgb(76.1176470588%, 90.9411764706%, 100%);
outline-offset: 1px;
}
@media (max-width: 340px) {
.gr {
gap: 4px;
}
.gr__field {
height: 100px;
}
.gr__badge {
font-size: 10px;
padding: 0 4px;
}
.gr__dial input {
width: 88px;
}
.gr__dial input[type=number] {
width: 56px;
}
.gr__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.gr.is-still .gr__field {
cursor: default !important;
}
}// 재집합 — 글자를 픽셀로 뜯어 포인터가 헤치면 밀리고 제자리로 되돌아오게 한다.
// 1) 오프스크린 캔버스에 fillText 로 단어를 그려 getImageData 로 알파 마스크를 뽑는다
// 2) 격자 간격 STEP 로 걸어 알파>128 인 칸의 (x,y) 를 목표점 목록으로 만든다
// 3) 입자마다 목표로 돌아가는 복원 스프링(속도 감쇠 0.86 · 복원 0.012)을 두고,
// 포인터(마우스·터치) 반경 RADIUS 안에서는 밀어내는 힘이 이긴다.
// 포인터가 없을 때는 가상 포인터가 사인으로 흘러 판이 항상 살아 움직인다(자동 모드).
// 실제 포인터가 들어오면 즉시 그 위치를 따라간다.
// シード(mulberry32) 가 입자의 시작 위치·반경·색을 정하므로 같은 시드는 같은 그림이 나온다.
var root = document.getElementById('gr');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var wordIn = document.getElementById('word');
var seedIn = document.getElementById('seed');
var badge = document.getElementById('badge');
var SW = 252, SH = 108; // 샘플 해상도 — 판 비율과 같다
var STEP = 2; // 샘플 간격(px) — 좁힐수록 입자 수가 늘어난다
var RADIUS = 46; // 포인터 반발 반경(샘플 px)
var PUSH = 2.9; // 반발 가속 — 복원보다 세게
var DAMP = 0.86; // 속도 감쇠 — 스프링의 점성
var SPRING = 0.012; // 목표 복원 계수
var AUTO_R = 62; // 자동 모드 가상 포인터 반경(샘플 px) — 글자를 가로질러 흐른다
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0;
var parts = [];
var t0 = 0, raf = 0;
var ptr = { x: -999, y: -999, on: false }; // 실제 포인터(샘플 좌표계)
// 색 — scss 가 파스텔 커스텀 프로퍼티(--g0..--g3)로 내려준 넉 색을 읽어 쓴다
var css = getComputedStyle(root);
function hexOf(name) {
var v = css.getPropertyValue(name).trim().replace('#', '');
if (v.length === 3) v = v.charAt(0) + v.charAt(0) + v.charAt(1) + v.charAt(1) + v.charAt(2) + v.charAt(2);
return '#' + v;
}
var cols = [hexOf('--g0'), hexOf('--g1'), hexOf('--g2'), hexOf('--g3')];
// 결정적 난수 — シード 하나가 입자의 크기·색 전부를 정한다
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;
};
}
// ── 1) 글자 → 알파 마스크 ────────────────────────────────────────
function buildMask(word) {
var off = document.createElement('canvas');
off.width = SW; off.height = SH;
var oc = off.getContext('2d', { willReadFrequently: true });
oc.textAlign = 'center'; oc.textBaseline = 'middle';
var fs = 200;
oc.font = '900 ' + fs + 'px Arial';
var w = oc.measureText(word).width || 1;
fs = Math.min(fs * ((SW - 8) / w), SH - 6); // 폭·높이 안에서 제일 큰 글자
oc.font = '900 ' + Math.round(fs) + 'px Arial';
oc.fillStyle = '#fff';
oc.fillText(word, SW / 2, SH / 2);
return oc.getImageData(0, 0, SW, SH).data;
}
// ── 2) 마스크 → 목표점 목록 ─────────────────────────────────────
function samplePoints(data) {
var pts = [];
for (var y = 0; y < SH; y += STEP) {
for (var x = 0; x < SW; x += STEP) {
if (data[(y * SW + x) * 4 + 3] > 128) pts.push({ x: x, y: y });
}
}
return pts;
}
function pickColor(r) {
if (r < 0.55) return 0; // 잉크 다수
if (r < 0.73) return 1; // 하늘
if (r < 0.88) return 2; // 민트
return 3; // 복숭아
}
// ── 3) 単語·시드가 바뀌면 목표와 입자 수가 다시 계산된다 ──────────
var word = 'TOUCH', seed = 7;
function resample(nextWord, nextSeed) {
var w = String(nextWord == null ? word : nextWord).trim().toUpperCase();
if (w) word = w; // 빈 문자열이면 직전 값을 유지
var s = Math.floor(Number(nextSeed));
if (s >= 1) seed = s;
var pts = samplePoints(buildMask(word));
var rnd = mulberry32(seed);
parts = [];
for (var i = 0; i < pts.length; i++) {
var a = rnd() * 6.2832, rr = rnd();
// 시작 위치 = 판 가장자리 링 — 흩어진 상태에서 모여드는 등장 시퀀스
var edge = 0.5 + 0.5 * Math.sin(a * 3.0 + rr * 6.2832);
parts.push({
x: pts[i].x + Math.cos(a) * (46 + rr * 58) * edge, // 흩어짐 출발
y: pts[i].y + Math.sin(a) * (30 + rr * 38) * edge,
vx: 0, vy: 0,
ox: pts[i].x, oy: pts[i].y, // 목표 = 샘플 픽셀 (복원 기준점)
sz: 2.4 + rr * 2.1, ci: pickColor(rnd())
});
}
window.__sampleInfo = { targets: pts.length, particles: parts.length };
badge.textContent = pts.length + ' pts';
if (reduce) {
step(0); // t=0 — 스프링 몇 걸음만 둔 포즈
for (var k = 0; k < 90; k++) step(1 / 60); // 안정 상태로 수렴시킨 한 장
draw(); // 수렴한 위치·색을 즉시 화면에 반영한다
} else {
t0 = performance.now();
}
}
function fit() {
var r = field.getBoundingClientRect();
var d = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * d); cv.height = Math.round(H * d);
ctx.setTransform(d, 0, 0, d, 0, 0);
}
// 물리 한 걸음 — 포인터(실제 또는 가상) 반발 + 목표 복원 스프링
function step(dt) {
// 가상 포인터 — 실제 포인터가 없을 때 사인으로 판을 가로질러 흐른다(자동 모드)
var px, py;
if (ptr.on) {
px = ptr.x; py = ptr.y;
} else {
var w = tNow * 0.9; // 자동 흐름 위상
px = SW / 2 + Math.sin(w) * (SW * 0.36);
py = SH / 2 + Math.sin(w * 2.3 + 1.2) * (SH * 0.30);
}
for (var i = 0; i < parts.length; i++) {
var p = parts[i];
var dx = p.x - px, dy = p.y - py;
var d2 = dx * dx + dy * dy;
var R = ptr.on ? RADIUS : AUTO_R; // 자동 모드는 더 넓게 훑는다
if (d2 < R * R && d2 > 0.01) {
var d = Math.sqrt(d2);
var f = (1 - d / R) * PUSH; // 가까울수록 세게 민다
p.vx += (dx / d) * f;
p.vy += (dy / d) * f;
}
p.vx += (p.ox - p.x) * SPRING; // 목표 복원
p.vy += (p.oy - p.y) * SPRING;
p.vx *= DAMP; p.vy *= DAMP;
p.x += p.vx; p.y += p.vy;
}
}
var tNow = 0;
function paint(now) {
tNow = now;
step(1 / 60);
ctx.clearRect(0, 0, W, H);
var sx = W / SW, sy = H / SH;
for (var i = 0; i < parts.length; i++) {
var p = parts[i];
ctx.fillStyle = cols[p.ci];
ctx.beginPath();
ctx.arc(p.x * sx, p.y * sy, p.sz * sx * 0.85, 0, 6.2832);
ctx.fill();
}
}
function frame(now) {
paint((now - t0) / 1000);
raf = requestAnimationFrame(frame);
}
// 실제 포인터 — 마우스·터치. 들어오면 자동 모드를 즉시 내어준다
function toSample(e) {
var r = cv.getBoundingClientRect();
var cx = (e.touches ? e.touches[0].clientX : e.clientX) - r.left;
var cy = (e.touches ? e.touches[0].clientY : e.clientY) - r.top;
return { x: cx / r.width * SW, y: cy / r.height * SH };
}
field.addEventListener('pointermove', function (e) {
var s = toSample(e); ptr.x = s.x; ptr.y = s.y; ptr.on = true;
});
field.addEventListener('pointerleave', function () { ptr.on = false; });
field.addEventListener('pointerdown', function (e) {
var s = toSample(e); ptr.x = s.x; ptr.y = s.y; ptr.on = true;
});
field.addEventListener('pointerup', function () { ptr.on = false; });
wordIn.addEventListener('input', function () { resample(wordIn.value, seedIn.value); });
seedIn.addEventListener('input', function () { resample(wordIn.value, seedIn.value); });
window.addEventListener('resize', function () { fit(); if (reduce) { for (var k = 0; k < 90; k++) step(1 / 60); draw(); } });
function draw() {
ctx.clearRect(0, 0, W, H);
var sx = W / SW, sy = H / SH;
for (var i = 0; i < parts.length; i++) {
var p = parts[i];
ctx.fillStyle = cols[p.ci];
ctx.beginPath();
ctx.arc(p.x * sx, p.y * sy, p.sz * sx * 0.85, 0, 6.2832);
ctx.fill();
}
}
fit();
resample(wordIn.value, seedIn.value);
if (reduce) {
root.classList.add('is-still'); // 루프 없이 수렴 포즈 한 장
draw();
} else {
raf = requestAnimationFrame(frame);
}// GlyphScatterReassemblePointer — vanilla 판(run/310/demo/09-glyph-scatter-reassemble-pointer/index.html) 의 React 포트.
// 오프스크린 캔버스에 fillText 로 단어를 그려 getImageData 로 알파 마스크를 뽑고,
// 격자 간격 STEP 로 걸어 알파>128 칸의 (x,y) 를 목표점 목록으로 만든다. 입자마다 목표로
// 돌아가는 복원 스프링(속도 감쇠 0.86 · 복원 0.012)을 두고, 포인터(마우스·터치) 반경
// RADIUS 안에서는 밀어내는 힘이 이긴다. 포인터가 없을 때는 가상 포인터가 사인으로 흘러
// 판이 항상 살아 움직인다(자동 모드). 실제 포인터가 들어오면 즉시 그 위치를 따라간다.
// 시드(mulberry32) 가 입자의 시작 위치·반경·색을 정하므로 같은 시드는 같은 그림이 나온다.
// 움직임은 캔버스 rAF 루프가 전부 그리므로 @keyframes 는 없다 — props 는 CSS 변수로 내려간다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GlyphScatterReassemblePointer.module.scss";
export type GlyphScatterReassemblePointerProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type InputEvt = { target: { value: string } };
type PtrEvt = { clientX: number; clientY: number };
const SW = 252, SH = 108; // 샘플 해상도 — 판 비율과 같다
const STEP = 2; // 샘플 간격(px) — 좁힐수록 입자 수가 늘어난다
const RADIUS = 46; // 포인터 반발 반경(샘플 px)
const PUSH = 2.9; // 반발 가속 — 복원보다 세게
const DAMP = 0.86; // 속도 감쇠 — 스프링의 점성
const SPRING = 0.012; // 목표 복원 계수
const AUTO_R = 62; // 자동 모드 가상 포인터 반경(샘플 px) — 글자를 가로질러 흐른다
const FIELD_LABEL = "재집합 실험판";
// 결정적 난수 — mulberry32. 같은 시드면 같은 수열을 돌려준다
function mulberry32(a: number): () => number {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
const t = Math.imul(a ^ (a >>> 15), 1 | a);
const u = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((u ^ (u >>> 14)) >>> 0) / 4294967296;
};
}
type Point = { x: number; y: number };
type Particle = {
x: number; y: number; vx: number; vy: number;
ox: number; oy: number; sz: number; ci: number;
};
// 오프스크린 fillText → 알파 마스크 픽셀 배열
function buildMask(word: string): Uint8ClampedArray {
const off = document.createElement("canvas");
off.width = SW; off.height = SH;
const oc = off.getContext("2d", { willReadFrequently: true }) as CanvasRenderingContext2D;
oc.textAlign = "center"; oc.textBaseline = "middle";
let fs = 200;
oc.font = "900 " + fs + "px Arial";
const w = oc.measureText(word).width || 1;
fs = Math.min(fs * ((SW - 8) / w), SH - 6); // 폭·높이 안에서 제일 큰 글자
oc.font = "900 " + Math.round(fs) + "px Arial";
oc.fillStyle = "#fff";
oc.fillText(word, SW / 2, SH / 2);
return oc.getImageData(0, 0, SW, SH).data;
}
// 마스크 → 목표점 목록 — 격자 간격 STEP 로 걸어 알파>128 인 칸만
function samplePoints(data: Uint8ClampedArray): Point[] {
const pts: Point[] = [];
for (let y = 0; y < SH; y += STEP) {
for (let x = 0; x < SW; x += STEP) {
if (data[(y * SW + x) * 4 + 3] > 128) pts.push({ x, y });
}
}
return pts;
}
function pickColor(r: number): number {
if (r < 0.55) return 0; // 잉크 다수
if (r < 0.73) return 1; // 하늘
if (r < 0.88) return 2; // 민트
return 3; // 복숭아
}
export default function GlyphScatterReassemblePointer({
duration = "2s", // vanilla $duration: $dur-loop
easing = "cubic-bezier(.2, .8, .2, 1)", // vanilla $easing: $ease-spring
color = "#17141a", // vanilla $glyph-ink
}: GlyphScatterReassemblePointerProps) {
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 partsRef = useRef<Particle[]>([]) as Box<Particle[]>;
const colsRef = useRef<string[]>([]) as Box<string[]>;
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const stateRef = useRef({ word: "TOUCH", seed: 7, t0: 0 }) as Box<{ word: string; seed: number; t0: number }>;
const ptrRef = useRef({ x: -999, y: -999, on: false }) as Box<{ x: number; y: number; on: boolean }>;
const tNowRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [count, setCount] = useState(0);
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 colors(): string[] {
const root = rootRef.current;
if (!root) return ["#17141a", "#17141a", "#17141a", "#17141a"];
const one = (n: string) => hex3to6(getComputedStyle(root).getPropertyValue(n).trim());
return [one("--g0"), one("--g1"), one("--g2"), one("--g3")];
}
// 고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);
}
function draw(): void {
const ctx = ctxRef.current;
const parts = partsRef.current;
const cols = colsRef.current;
if (!ctx) return;
const W = sizeRef.current.w, H = sizeRef.current.h;
ctx.clearRect(0, 0, W, H);
const sx = W / SW, sy = H / SH;
for (let i = 0; i < parts.length; i++) {
const p = parts[i];
ctx.fillStyle = cols[p.ci] || "#17141a";
ctx.beginPath();
ctx.arc(p.x * sx, p.y * sy, p.sz * sx * 0.85, 0, 6.2832);
ctx.fill();
}
}
// 물리 한 걸음 — 포인터(실제 또는 가상) 반발 + 목표 복원 스프링
function step(dt: number): void {
const parts = partsRef.current;
const ptr = ptrRef.current;
let px: number, py: number;
if (ptr.on) {
px = ptr.x; py = ptr.y;
} else {
// 가상 포인터 — 실제 포인터가 없을 때 사인으로 판을 가로질러 흐른다(자동 모드)
const w = tNowRef.current * 0.9; // 자동 흐름 위상
px = SW / 2 + Math.sin(w) * (SW * 0.36);
py = SH / 2 + Math.sin(w * 2.3 + 1.2) * (SH * 0.30);
}
for (let i = 0; i < parts.length; i++) {
const p = parts[i];
const dx = p.x - px, dy = p.y - py;
const d2 = dx * dx + dy * dy;
const R = ptr.on ? RADIUS : AUTO_R; // 자동 모드는 더 넓게 훑는다
if (d2 < R * R && d2 > 0.01) {
const d = Math.sqrt(d2);
const f = (1 - d / R) * PUSH; // 가까울수록 세게 민다
p.vx += (dx / d) * f;
p.vy += (dy / d) * f;
}
p.vx += (p.ox - p.x) * SPRING; // 목표 복원
p.vy += (p.oy - p.y) * SPRING;
p.vx *= DAMP; p.vy *= DAMP;
p.x += p.vx; p.y += p.vy;
}
}
function paint(now: number): void {
tNowRef.current = now;
step(1 / 60);
draw();
}
function frame(now: number): void {
paint((now - stateRef.current.t0) / 1000);
rafRef.current = requestAnimationFrame(frame);
}
// 단어·시드가 바뀌면 목표와 입자 수가 다시 계산된다(런타임 샘플링)
function resample(nextWord: string, nextSeed: number): void {
const st = stateRef.current;
const w = String(nextWord == null ? st.word : nextWord).trim().toUpperCase();
if (w) st.word = w;
if (nextSeed >= 1) st.seed = Math.floor(nextSeed);
const pts = samplePoints(buildMask(st.word));
const rnd = mulberry32(st.seed);
const parts: Particle[] = [];
for (let i = 0; i < pts.length; i++) {
const a = rnd() * 6.2832, rr = rnd();
// 시작 위치 = 판 가장자리 링 — 흩어진 상태에서 모여드는 등장 시퀀스
const edge = 0.5 + 0.5 * Math.sin(a * 3.0 + rr * 6.2832);
parts.push({
x: pts[i].x + Math.cos(a) * (46 + rr * 58) * edge, // 흩어짐 출발
y: pts[i].y + Math.sin(a) * (30 + rr * 38) * edge,
vx: 0, vy: 0,
ox: pts[i].x, oy: pts[i].y, // 목표 = 샘플 픽셀 (복원 기준점)
sz: 2.4 + rr * 2.1, ci: pickColor(rnd()),
});
}
partsRef.current = parts;
setCount(pts.length);
if (reduced()) {
tNowRef.current = 0;
step(0); // t=0 — 스프링 몇 걸음만 둔 포즈
for (let k = 0; k < 90; k++) step(1 / 60); // 안정 상태로 수렴시킨 한 장
draw(); // 수렴한 위치·색을 즉시 화면에 반영한다
} else {
st.t0 = performance.now();
}
}
// 실제 포인터 — 마우스·터치. 들어오면 자동 모드를 즉시 내어준다
function toSample(e: PtrEvt): Point {
const cv = cvRef.current;
if (!cv) return { x: -999, y: -999 };
const r = cv.getBoundingClientRect();
return {
x: ((e.clientX - r.left) / r.width) * SW,
y: ((e.clientY - r.top) / r.height) * SH,
};
}
function onPoint(e: PtrEvt): void {
const s = toSample(e);
ptrRef.current.x = s.x; ptrRef.current.y = s.y; ptrRef.current.on = true;
}
function onPointerLeave(): void {
ptrRef.current.on = false;
}
function onPointerUp(): void {
ptrRef.current.on = false;
}
function onWord(e: InputEvt): void {
resample(e.target.value, stateRef.current.seed);
}
function onSeed(e: InputEvt): void {
const v = Math.floor(Number(e.target.value)) || 1;
resample(stateRef.current.word, v);
}
function onResize(): void {
fit();
if (reduced()) {
for (let k = 0; k < 90; k++) step(1 / 60);
draw();
}
}
useEffect(() => {
const cv = cvRef.current;
if (!cv) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
colsRef.current = colors();
fit();
resample("TOUCH", 7);
window.addEventListener("resize", onResize);
if (reduced()) {
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="none" stroke="currentColor" stroke-width="12" stroke-linecap="round"><path d="M104 64l72 64-72 64" opacity=".9"/><path d="M64 64l72 64-64 64" opacity=".5"/></g></svg>
<span>판 위로 손가락을 움직이면 밀렸다가 다시 뭉칩니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onPoint}
onPointerDown={onPoint}
onPointerLeave={onPointerLeave}
onPointerUp={onPointerUp}
>
<canvas className={styles.cv} ref={cvRef} id="cv"></canvas>
<b className={styles.badge} aria-hidden="true">{count} pts</b>
</div>
<div className={styles.bar}>
<label className={styles.dial}>단어
<input type="text" id="word" defaultValue="TOUCH" maxLength={12} spellCheck={false} aria-label="단어" onInput={onWord} />
</label>
<label className={styles.dial}>시드
<input type="number" id="seed" defaultValue={7} min={1} max={9999} aria-label="시드" onInput={onSeed} />
</label>
</div>
</div>
</main>
);
}09 再集合 — ポインタがかき分けた文字が再び集まる — 計測 累積面積 35.76% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · テキスト アニメーション css 9選 — 文字をキャンバスのピクセルから作り直す


