02到着した粒子数の値が満たす群れ収束ローダー
ランダムに散らばった数十個の粒子のうち中心に到着した数を全体数で割った比率を進行度とし、到着した粒子が増えるほど中心の円が明るくなる。
使いどころAI応答待機画面・ダッシュボードのデータ集計ローダー
createRadialGradientglobalAlphaPAPER_FADE

<main class="stage">
<div class="ld" id="ld">
<p class="ld__hint">
<svg class="ld__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 56v56a8 8 0 0 1-16 0V91.3l-25.6 25.7A80 80 0 1 1 174 60a8 8 0 1 1-9 13.2A64 64 0 1 0 179.7 128H216a8 8 0 0 1 0 16h-56a8 8 0 0 1-8-8V80a8 8 0 0 1 16 0v20.7L192.7 76A80.1 80.1 0 0 0 64 128a8 8 0 0 1-16 0a96 96 0 0 1 163.6-68.3L224 71.3V56a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 48a8 8 0 0 0-8 8v15.3l-11.7-11.6a96 96 0 1 0 27.6 51.6a8 8 0 0 0-15.8 2.5a80 80 0 1 1-23-43l-25.7 25.7A8 8 0 0 0 173 112h56a8 8 0 0 0 8-8V56a8 8 0 0 0-13-8"/></g></svg>
<span>クリックするかスペースバーを押すと一時停止または再開します</span>
</p>
<div class="ld__field" id="field" role="application" aria-label="粒子群収束テストパネル" tabindex="0">
<canvas class="ld__cv" id="cv"></canvas>
<b class="ld__badge" aria-hidden="true"><span class="ld__badgeNum" id="pct">0%</span><span class="ld__badgeUnit">収束</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 입자 군집 수렴 로더 — 흩어진 입자 중 중심에 도착한 개수/전체 개수가 진행률이다.
// 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판·배지만 맡는다(@keyframes 0개 — 077 선례와 같은 이유).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-yellow);
.ld {
--paper: #{$subject-cream};
--paper-fade: #{rgba($subject-cream, .16)};
--ink: #{$subject-ink};
--dot: #{rgba($subject-ink, .38)};
--core: #{$subject-lilac};
--line: #{rgba($subject-lilac, .3)};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ld__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;
}
.ld__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-blue-deep; }
.ld__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $subject-cream;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
cursor: pointer;
}
.ld__field:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 2px; }
.ld__cv { display: block; width: 100%; height: 100%; }
.ld__badge {
position: absolute; right: $sp-2; top: $sp-2;
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($subject-ink, .78);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.ld__badgeNum { font-size: 13px; color: $stage-yellow; }
.ld__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.ld { gap: $sp-1; }
.ld__field { height: 124px; }
.ld__badge { font-size: 10px; padding: 0 $sp-1; }
.ld__badgeNum { font-size: 11px; }
}
// reduce — 루프를 시작하지 않고 진행률 62% 언저리에서 멈춘 한 장만 그린다.
@media (prefers-reduced-motion: reduce) {
.ld.is-still .ld__field { cursor: default !important; }
.ld.is-still .ld__badge { opacity: .92 !important; }
}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);
}
.ld {
--paper: #fff7e6;
--paper-fade: rgba(255, 247, 230, 0.16);
--ink: #17141a;
--dot: rgba(23, 20, 26, 0.38);
--core: #b9a0ff;
--line: rgba(185, 160, 255, 0.3);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ld__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;
}
.ld__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #0a4fe8;
}
.ld__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: #fff7e6;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
cursor: pointer;
}
.ld__field:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.ld__cv {
display: block;
width: 100%;
height: 100%;
}
.ld__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.ld__badgeNum {
font-size: 13px;
color: #ffd23f;
}
.ld__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.ld {
gap: 4px;
}
.ld__field {
height: 124px;
}
.ld__badge {
font-size: 10px;
padding: 0 4px;
}
.ld__badgeNum {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.ld.is-still .ld__field {
cursor: default !important;
}
.ld.is-still .ld__badge {
opacity: 0.92 !important;
}
}// 입자 군집 収束 로더 — 무작위로 흩어진 입자 중 중심에 도착한 개수를 전체 개수로 나눈 비율이 진행률이다.
// 흩어진 시작 위치·도착 순서는 고정 シード LCG 로 정해 항상 같은 그림이 나온다(Math.random 0).
var root = document.getElementById('ld');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var pctEl = document.getElementById('pct');
var css = getComputedStyle(root);
var PAPER = css.getPropertyValue('--paper').trim();
var PAPER_FADE = css.getPropertyValue('--paper-fade').trim();
var INK = css.getPropertyValue('--ink').trim();
var DOT = css.getPropertyValue('--dot').trim();
var CORE = css.getPropertyValue('--core').trim();
var LINE = css.getPropertyValue('--line').trim();
function makeRng(seed) {
var s = seed >>> 0;
return function () {
s = (1103515245 * s + 12345) >>> 0;
return (s >>> 8) / 16777216;
};
}
var N = 42;
var LOOP = 1.9;
var rng = makeRng(20260923);
var order = [];
for (var oi = 0; oi < N; oi++) order.push(oi);
for (var oj = N - 1; oj > 0; oj--) { // Fisher-Yates — 도착 순서를 고정 시드로 뒤섞는다
var ok = Math.floor(rng() * (oj + 1));
var tmp = order[oj]; order[oj] = order[ok]; order[ok] = tmp;
}
var particles = [];
for (var i = 0; i < N; i++) {
var ang = rng() * Math.PI * 2;
var rad = 0.34 + rng() * 0.62; // 정규화 반지름(0.34~0.96) — 가장자리 쪽에 흩어진다
particles.push({
hx: 0.5 + Math.cos(ang) * rad * 0.5, // 흩어진 자리(정규화 0~1)
hy: 0.5 + Math.sin(ang) * rad * 0.5,
px: 0, py: 0,
rank: order[i],
jf: 0.6 + rng() * 1.1, // 각자 다른 흔들림 진동수
jp: rng() * Math.PI * 2
});
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1;
var paused = false;
var t0 = 0;
var raf = 0;
var inited = false;
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);
if (!inited) {
for (var i = 0; i < N; i++) { particles[i].px = particles[i].hx * W; particles[i].py = particles[i].hy * H; }
inited = true;
}
}
function draw(sec) {
// 완전히 지우지 않고 옅은 판 색을 덧칠한다 — 입자가 지나간 자리에 꼬리가 남아
// 군집이 살아 움직이는 느낌을 준다(궤적 잔상, 매 프레임 픽셀이 크게 바뀐다).
ctx.fillStyle = PAPER_FADE;
ctx.fillRect(0, 0, W, H);
var t = sec % LOOP;
var p = t / LOOP; // 0→1 진행률 — 도착 입자 비율의 목표값
var arrived = Math.floor(p * N);
var cx = W * 0.5, cy = H * 0.5;
for (var i = 0; i < N; i++) {
var q = particles[i];
var tx, ty;
if (q.rank < arrived) {
var orbit = 5 + 3 * Math.sin(sec * q.jf + q.jp);
tx = cx + Math.cos(sec * 1.4 + q.jp) * orbit;
ty = cy + Math.sin(sec * 1.4 + q.jp) * orbit;
} else {
tx = q.hx * W + Math.sin(sec * q.jf + q.jp) * 4;
ty = q.hy * H + Math.cos(sec * q.jf * 0.8 + q.jp) * 4;
}
q.px += (tx - q.px) * 0.14;
q.py += (ty - q.py) * 0.14;
}
// 도착한 입자에서 중심으로 옅은 실선
ctx.strokeStyle = LINE;
ctx.lineWidth = 1;
ctx.beginPath();
for (var j = 0; j < N; j++) {
var qj = particles[j];
if (qj.rank < arrived) { ctx.moveTo(qj.px, qj.py); ctx.lineTo(cx, cy); }
}
ctx.stroke();
// 중심 원 — 도착 비율이 밝기·반지름을 함께 키운다
var frac = arrived / N;
var coreR = 10 + frac * 22;
var glow = ctx.createRadialGradient(cx, cy, 0, cx, cy, coreR * 1.8);
glow.addColorStop(0, CORE);
glow.addColorStop(1, 'rgba(0,0,0,0)');
ctx.globalAlpha = 0.35 + frac * 0.5;
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(cx, cy, coreR * 1.8, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
ctx.fillStyle = CORE;
ctx.beginPath();
ctx.arc(cx, cy, coreR, 0, Math.PI * 2);
ctx.fill();
// 입자
for (var k = 0; k < N; k++) {
var qk = particles[k];
ctx.fillStyle = qk.rank < arrived ? CORE : DOT;
ctx.beginPath();
ctx.arc(qk.px, qk.py, qk.rank < arrived ? 4 : 3.2, 0, Math.PI * 2);
ctx.fill();
}
var pct = Math.round(frac * 100);
pctEl.textContent = pct + '%';
}
function togglePause() {
paused = !paused;
root.classList.toggle('is-paused', paused);
}
function restart() {
t0 = performance.now();
draw(0);
}
field.addEventListener('click', function () { togglePause(); });
field.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); togglePause(); }
else if (e.key === 'r' || e.key === 'R') { e.preventDefault(); restart(); }
});
window.addEventListener('resize', function () {
fit();
if (paused || reduce) draw(((performance.now() - t0) / 1000));
});
function frame(now) {
if (!paused) draw((now - t0) / 1000);
raf = requestAnimationFrame(frame);
}
t0 = performance.now();
fit();
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
if (reduce) {
root.classList.add('is-still');
draw(LOOP * 0.62);
} else {
raf = requestAnimationFrame(frame);
}// ParticleSwarm — vanilla 판(run/395/demo/02-canvas-loader-particle-swarm-converge-count/index.html) 의 React 포트.
// 흩어진 입자 중 중심에 도착한 개수/전체 개수가 진행률이다. 시작 위치·도착 순서는 고정 시드 LCG.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./ParticleSwarm.module.scss";
export type ParticleSwarmProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
type Particle = { hx: number; hy: number; px: number; py: number; rank: number; jf: number; jp: number };
const N = 42;
const LOOP = 1.9;
const FIELD_LABEL = "입자 군집 수렴 실험판";
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function () {
s = (1103515245 * s + 12345) >>> 0;
return (s >>> 8) / 16777216;
};
}
function makeParticles(): Particle[] {
const rng = makeRng(20260923);
const order: number[] = [];
for (let i = 0; i < N; i++) order.push(i);
for (let j = N - 1; j > 0; j--) {
const k = Math.floor(rng() * (j + 1));
const tmp = order[j]; order[j] = order[k]; order[k] = tmp;
}
const list: Particle[] = [];
for (let i = 0; i < N; i++) {
const ang = rng() * Math.PI * 2;
const rad = 0.34 + rng() * 0.62;
list.push({
hx: 0.5 + Math.cos(ang) * rad * 0.5,
hy: 0.5 + Math.sin(ang) * rad * 0.5,
px: 0, py: 0,
rank: order[i],
jf: 0.6 + rng() * 1.1,
jp: rng() * Math.PI * 2,
});
}
return list;
}
export default function ParticleSwarm({
duration = "1.9s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b9a0ff", // vanilla $color: $subject-lilac
}: ParticleSwarmProps) {
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 pctRef = useRef<HTMLSpanElement | null>(null);
const sizeRef = useRef({ w: 0, h: 0, dpr: 1, inited: false }) as Box<{ w: number; h: number; dpr: number; inited: boolean }>;
const particlesRef = useRef(makeParticles()) as Box<Particle[]>;
const pausedRef = useRef(false) as Box<boolean>;
const t0Ref = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const size = sizeRef.current;
size.w = r.width; size.h = r.height; size.dpr = dpr;
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);
if (!size.inited) {
const list = particlesRef.current;
for (let i = 0; i < N; i++) { list[i].px = list[i].hx * r.width; list[i].py = list[i].hy * r.height; }
size.inited = true;
}
}
function draw(sec: number): void {
const ctx = ctxRef.current;
const root = rootRef.current;
if (!ctx || !root) return;
const W = sizeRef.current.w, H = sizeRef.current.h;
const cssv = (n: string) => getComputedStyle(root).getPropertyValue(n).trim();
const PAPER_FADE = cssv("--paper-fade"), DOT = cssv("--dot"), CORE = cssv("--core"), LINE = cssv("--line");
// 완전히 지우지 않고 옅은 판 색을 덧칠한다 — 입자 궤적 잔상이 남는다
ctx.fillStyle = PAPER_FADE;
ctx.fillRect(0, 0, W, H);
const t = sec % LOOP;
const p = t / LOOP;
const arrived = Math.floor(p * N);
const cx = W * 0.5, cy = H * 0.5;
const list = particlesRef.current;
for (let i = 0; i < N; i++) {
const q = list[i];
let tx: number, ty: number;
if (q.rank < arrived) {
const orbit = 5 + 3 * Math.sin(sec * q.jf + q.jp);
tx = cx + Math.cos(sec * 1.4 + q.jp) * orbit;
ty = cy + Math.sin(sec * 1.4 + q.jp) * orbit;
} else {
tx = q.hx * W + Math.sin(sec * q.jf + q.jp) * 4;
ty = q.hy * H + Math.cos(sec * q.jf * 0.8 + q.jp) * 4;
}
q.px += (tx - q.px) * 0.14;
q.py += (ty - q.py) * 0.14;
}
ctx.strokeStyle = LINE;
ctx.lineWidth = 1;
ctx.beginPath();
for (let j = 0; j < N; j++) {
const qj = list[j];
if (qj.rank < arrived) { ctx.moveTo(qj.px, qj.py); ctx.lineTo(cx, cy); }
}
ctx.stroke();
const frac = arrived / N;
const coreR = 10 + frac * 22;
const glow = ctx.createRadialGradient(cx, cy, 0, cx, cy, coreR * 1.8);
glow.addColorStop(0, CORE);
glow.addColorStop(1, "rgba(0,0,0,0)");
ctx.globalAlpha = 0.35 + frac * 0.5;
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(cx, cy, coreR * 1.8, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
ctx.fillStyle = CORE;
ctx.beginPath();
ctx.arc(cx, cy, coreR, 0, Math.PI * 2);
ctx.fill();
for (let k = 0; k < N; k++) {
const qk = list[k];
ctx.fillStyle = qk.rank < arrived ? CORE : DOT;
ctx.beginPath();
ctx.arc(qk.px, qk.py, qk.rank < arrived ? 4 : 3.2, 0, Math.PI * 2);
ctx.fill();
}
const pct = Math.round(frac * 100);
if (pctRef.current) pctRef.current.textContent = pct + "%";
}
function togglePause(): void {
pausedRef.current = !pausedRef.current;
const root = rootRef.current;
if (root) root.classList.toggle("is-paused", pausedRef.current);
}
function restart(): void {
t0Ref.current = performance.now();
draw(0);
}
function onKey(e: KeyEvt): void {
if (e.key === " " || e.key === "Enter") { e.preventDefault(); togglePause(); }
else if (e.key === "r" || e.key === "R") { e.preventDefault(); restart(); }
}
function onClick(): void {
togglePause();
}
function onResize(): void {
fit();
if (pausedRef.current || reduced()) draw((performance.now() - t0Ref.current) / 1000);
}
function frame(now: number): void {
if (!pausedRef.current) draw((now - t0Ref.current) / 1000);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const cv = cvRef.current;
if (!cv) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
t0Ref.current = performance.now();
fit();
const root0 = rootRef.current;
if (root0) {
const paper = getComputedStyle(root0).getPropertyValue("--paper").trim();
ctxRef.current.fillStyle = paper;
ctxRef.current.fillRect(0, 0, sizeRef.current.w, sizeRef.current.h);
}
window.addEventListener("resize", onResize);
if (reduced()) {
const root = rootRef.current;
if (root) root.classList.add("is-still");
draw(LOOP * 0.62);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.root} 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="M232 56v56a8 8 0 0 1-16 0V91.3l-25.6 25.7A80 80 0 1 1 174 60a8 8 0 1 1-9 13.2A64 64 0 1 0 179.7 128H216a8 8 0 0 1 0 16h-56a8 8 0 0 1-8-8V80a8 8 0 0 1 16 0v20.7L192.7 76A80.1 80.1 0 0 0 64 128a8 8 0 0 1-16 0a96 96 0 0 1 163.6-68.3L224 71.3V56a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 48a8 8 0 0 0-8 8v15.3l-11.7-11.6a96 96 0 1 0 27.6 51.6a8 8 0 0 0-15.8 2.5a80 80 0 1 1-23-43l-25.7 25.7A8 8 0 0 0 173 112h56a8 8 0 0 0 8-8V56a8 8 0 0 0-13-8"/></g></svg>
<span>클릭하거나 스페이스바를 누르면 멈추거나 다시 갑니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onClick={onClick}
onKeyDown={onKey}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum} ref={pctRef}>0%</span><span className={styles.badgeUnit}>수렴</span></b>
</div>
</div>
</main>
);
}02 到着した粒子数の値が満たす群れ収束ローダー — 計測 累積面積 15.628% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · ローディング アニメーション js 9パターン — 物理演算で作る
このデモが属する記事
ローディング アニメーション js 9パターン — 物理演算で作るローディング アニメーション js の一つの姿: 減衰定数0.92が重力ローダーの跳ね上がる高さを縮め、セルオートマトンローダーは一つの生存ルール値から世代ごとに満たされるセルの割合を計算し、磁場ローダーは軌道半径の値が0に近づいた瞬間に終 この記事のデモをすべて見る

