05テスラアーク — 2つの電極間を毎フレーム引き直す持続放電
固定された2点間の中点変位経路を毎フレーム新しい乱数シードで再計算して描くと、位置はそのままで経路だけが絶え間なく震える持続アーク放電になります。
使いどころSFテーマサイトの装飾要素、ゲームボスパターン背景
lineToglobalCompositeOperationrequestAnimationFrame

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<svg class="cd__ico" viewBox="0 0 256 256" aria-hidden="true"><path fill="currentColor" d="M134 12a10 10 0 0 1 10 10v66a12 12 0 1 1-24 0V22a10 10 0 0 1 10-10h4zM62 96a10 10 0 0 1 10 10v52a12 12 0 1 1-24 0v-52a10 10 0 0 1 10-10h4zm132 0a10 10 0 0 1 10 10v52a12 12 0 1 1-24 0v-52a10 10 0 0 1 10-10h4zM128 108a14 14 0 0 1 8 25v27h-12v-27a14 14 0 0 1 4-25zM78 168a8 8 0 0 1 0 16H54a8 8 0 0 1 0-16h24zm50 0a8 8 0 0 1 0 16h-24a8 8 0 0 1 0-16h24zm50 0a8 8 0 0 1 0 16h-24a8 8 0 0 1 0-16h24zM128 200a8 8 0 0 1 0 16h-6v-16h6zm-6 0v16h-6a8 8 0 0 1 0-16h6z" opacity=".95"/><circle cx="128" cy="18" r="18" fill="currentColor" opacity=".25"/></svg>
<span>2つの電極間の中点変位経路を毎フレーム新しいシードで計算し直します — 両端は固定のまま経路だけが絶えず揺れ続ける持続放電です</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="テスラアーク放電 実験パネル" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta">電圧 <b class="cd__val" id="cdVolt"></b>kV・枝 <b class="cd__val" id="cdFork"></b></span>
<span class="cd__meta cd__meta--dim">キャンバス・lineTo・globalCompositeOperation</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 테슬라 아크(05). @keyframes 를 아예 두지 않는다 — 움직임은 전부 캔버스 rAF 루프
// (매 프레임 중점변위 재계산 + lighter 컴포짓)가 만든다.
// 무대는 어스름 잉크. 파스텔 팔레트를 --bolt-* 로 편다(편 316 공통 규격).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$bolt-ink: #1b1e26; // 어스름 무대
$bolt-dusk: #3d4358; // 슬레이트
$bolt-core: #f6e7ff; // 번개 코어 — 밝은 라벤다 화이트
$bolt-halo: #b9a0ff; // 헤일로 라일락
$bolt-mint: #b9ddd1; // 민트 보조(곁가지)
$bolt-pink: #ffb9c8; // 핑크 보조(곁가지)
$bolt-mist: #d8dce6; // 안개 회색빛
.cd {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --bolt-* 를 받아 쓴다
--bolt-ink: #{$bolt-ink};
--bolt-dusk: #{$bolt-dusk};
--bolt-core: #{$bolt-core};
--bolt-halo: #{$bolt-halo};
--bolt-mint: #{$bolt-mint};
--bolt-pink: #{$bolt-pink};
--bolt-mist: #{$bolt-mist};
// 항목 반경 토큰(01~09 공통 — 실사용은 $r-* SCSS 토큰, 표기 참조용)
--radius-sm: 4px;
--radius-md: 10px;
--radius-lg: 20px;
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__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: $bolt-mist; // 잉크 무대 위 — 안개 톤 글자
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $bolt-halo; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $bolt-ink;
box-shadow: inset 0 0 0 1px rgba($bolt-mist, .18);
}
.cd__field:focus-visible { outline: 2px solid $bolt-halo; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($bolt-mist, .72); }
.cd__val { color: $bolt-halo; font-weight: 700; } // 잉크 위 라일락 라벨
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 방전이 붙은 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--bolt-ink: #1b1e26;
--bolt-dusk: #3d4358;
--bolt-core: #f6e7ff;
--bolt-halo: #b9a0ff;
--bolt-mint: #b9ddd1;
--bolt-pink: #ffb9c8;
--bolt-mist: #d8dce6;
--radius-sm: 4px;
--radius-md: 10px;
--radius-lg: 20px;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__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: #d8dce6;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #b9a0ff;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #1b1e26;
box-shadow: inset 0 0 0 1px rgba(216, 220, 230, 0.18);
}
.cd__field:focus-visible {
outline: 2px solid #b9a0ff;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 220, 230, 0.72);
}
.cd__val {
color: #b9a0ff;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 테슬라 아크(05). 좌우 고정 전극(구 스탠드) 사이의 아크를 매 프레임 새 난수로 중점변위 재계산해 그린다.
// 밝기 겹침은 lighter 컴포짓(가산혼합)으로, 떨림 세기는 시간 반주기로 붙였다 뗐다 한다.
// 곁가지는 아크 경로의 무작위 지점에서 위쪽으로 짧게 갈라진다(매 프레임 새 지점 — 아크가 살아 있는 인상).
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var VOLT = 240; // 표시 電圧(kV) — 변형 "電圧" 축(240 → 480)
var JITTER = 9; // 변위 깊이 — 클수록 경로가 잘게 떨린다(9 → 12)
var FORK_N = 3; // 곁가지 수 — 변형 "방전 가지" 축(3 → 6)
// ────────────────────────────────────────────────────
// 잔광 拡散(04 와 같은 기법) — 아크가 지나간 자리의 밝기 버퍼 lum 을 매 프레임
// 이웃 4픽셀 平均 쪽으로 섞고(SWARM) 냉각(DIE_OUT)해 식힌다. 전극 사이 무대에
// 방전의 잔상이 번지다 사라진다 — 라인 두께를 키우지 않고도 화면에 남는 빛의 면적이 커진다.
var SWARM = 0.50; // 拡散 계수
var DIE_OUT = 0.985; // 냉각 계수 — 잔상이 한 동안 남아 누적면적을 채운다
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
document.getElementById('cdVolt').textContent = String(VOLT);
document.getElementById('cdFork').textContent = String(FORK_N);
// 시드 고정 PRNG — mulberry32. 미리보기가 항상 같은 떨림으로 시작한다
var SEED = 0x3160505;
var prngState = SEED >>> 0;
function rand() {
prngState = (prngState + 0x6D2B79F5) >>> 0;
var t = prngState;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
// 색은 CSS 커스텀 프로퍼티(--bolt-*)에서 getComputedStyle 로 읽는다 — 파스텔 토큰이 scss 한 곳에
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--bolt-ink');
var C_CORE = tokRGB('--bolt-core');
var C_HALO = tokRGB('--bolt-halo');
var C_MINT = tokRGB('--bolt-mint');
var C_PINK = tokRGB('--bolt-pink');
var C_MIST = tokRGB('--bolt-mist');
function rgb(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
var W = 0, H = 0;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1, prev = 0;
var STILL_T = 1.6; // reduce 에서 그리는 시각 — 방전이 붙은 한 장
// 중점변위 — 선분을 depth 번 반분하며 중점을 법선 방향으로 무작위 오프셋
function genBolt(ax, ay, bx, by, mag, depth) {
var pts = [{ x: ax, y: ay }, { x: bx, y: by }];
var dx = bx - ax, dy = by - ay;
var len = Math.hypot(dx, dy) || 1;
var nx = -dy / len, ny = dx / len;
var off = mag;
for (var d = 0; d < depth; d++) {
var next = [pts[0]];
for (var i = 0; i < pts.length - 1; i++) {
var p = pts[i], q = pts[i + 1];
var r = (rand() * 2 - 1) * off;
next.push({ x: (p.x + q.x) * 0.5 + nx * r, y: (p.y + q.y) * 0.5 + ny * r }, q);
}
pts = next;
off *= 0.55;
}
return pts;
}
// ── 전극 위치(핏에서 계산) ──
var elL = { x: 0, y: 0 }, elR = { x: 0, y: 0 };
// ── 잔광 버퍼(04 拡散 기법) — fit 에서 크기를 잡는다 ──
var glow = null, glow2 = null, glowCv = null, glowCtx = null, glowImg = null;
var HALO_LUT = new Uint8ClampedArray(256);
(function () { // 밝기 t → 불투명도. 라일락 헤일로 색으로 뿌린다
for (var i = 0; i < 256; i++) {
var t = i / 255;
HALO_LUT[i] = Math.round(255 * Math.pow(t, 0.78) * 0.5);
}
})();
function glowFit() {
glow = new Float32Array(W * H);
glow2 = new Float32Array(W * H);
glowCv = document.createElement('canvas');
glowCv.width = W; glowCv.height = H;
glowCtx = glowCv.getContext('2d');
glowImg = glowCtx.createImageData(W, H);
var d = glowImg.data;
for (var i = 3; i < d.length; i += 4) d[i] = 255;
}
// 아크 경로를 밝기 버퍼에 도장 — 코어는 1.2, 둘레는 거리 비례 감쇠(04 stampPoint 와 동일 규격)
function glowStamp(px, py, radius) {
var cx = Math.round(px), cy = Math.round(py);
for (var yy = cy - radius; yy <= cy + radius; yy++) {
if (yy < 0 || yy >= H) continue;
for (var xx = cx - radius; xx <= cx + radius; xx++) {
if (xx < 0 || xx >= W) continue;
var dxx = xx - cx, dyy = yy - cy;
var d = Math.sqrt(dxx * dxx + dyy * dyy);
if (d > radius) continue;
var v = d <= 1 ? 1.2 : (1 - d / radius) * 0.6;
var idx = yy * W + xx;
if (v > glow[idx]) glow[idx] = v;
}
}
}
function glowStampPath(pts, radius) {
for (var i = 0; i < pts.length - 1; i++) {
var p = pts[i], q = pts[i + 1];
var seg = Math.max(1, Math.ceil(Math.hypot(q.x - p.x, q.y - p.y) / 2));
for (var s = 0; s < seg; s++) {
var f = s / seg;
glowStamp(p.x + (q.x - p.x) * f, p.y + (q.y - p.y) * f, radius);
}
}
}
// 拡散 — 이웃 4픽셀 平均 쪽으로 섞고 냉각. 이중 버퍼 교체
function glowDiffuse() {
for (var y = 0; y < H; y++) {
var row = y * W;
var up = (y > 0 ? y - 1 : y) * W;
var dn = (y < H - 1 ? y + 1 : y) * W;
for (var x = 0; x < W; x++) {
var i = row + x;
var l = glow[i];
var av = (glow[row + (x > 0 ? x - 1 : x)] + glow[row + (x < W - 1 ? x + 1 : x)] +
glow[up + x] + glow[dn + x]) * 0.25;
glow2[i] = (l + SWARM * (av - l)) * DIE_OUT;
}
}
var tmp = glow; glow = glow2; glow2 = tmp;
}
// 버퍼 → 라일락 헤일로 반투명 레이어. draw 초반에 lighter 로 깔면 아크·전극이 그 위에 산다
function glowCompose() {
var d = glowImg.data, n = W * H;
for (var i = 0; i < n; i++) {
var v = glow[i];
var li = v >= 1 ? 255 : (v <= 0 ? 0 : (v * 255) | 0);
var a = HALO_LUT[li];
var j = i << 2;
d[j] = C_HALO[0]; d[j + 1] = C_HALO[1]; d[j + 2] = C_HALO[2]; d[j + 3] = a;
}
glowCtx.putImageData(glowImg, 0, 0);
}
// 아크 한 겹 그리기 — 두께·알파가 다른 같은 경로 3번 겹치기(헤일로·몸통·코어)
function strokePath(pts, width, color) {
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (var i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.stroke();
}
function draw(tSec) {
// 무대 — 어스름 잉크. 아크 세기(throb)에 맞춰 무대 전체가 호흡처럼 밝아졌다 어두워진다
// (전면 라이팅 — 배경 픽셀 자체가 변해 누적면적으로 직결. 방전의 방 안 밝기 변화)
var throb0 = 0.62 + 0.38 * Math.abs(Math.sin(tSec * 2.1));
var roomA = 0.04 + 0.42 * throb0; // 잉크 위에 깔리는 라일락 라이팅의 알파 — 방전 세기를 무대가 그대로 받는다
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = rgb(C_INK, 1);
ctx.fillRect(0, 0, W, H);
var room = ctx.createRadialGradient((elL.x + elR.x) / 2, H * 0.42, 0, (elL.x + elR.x) / 2, H * 0.42, W * 0.62);
room.addColorStop(0, rgb(C_HALO, roomA * 0.9));
room.addColorStop(1, rgb(C_HALO, 0));
ctx.fillStyle = room;
ctx.fillRect(0, 0, W, H);
// 떨림 세기 — 시간 반주기 진동. 아크가 붙었다 약해지는 호흡. 진폭을 넓혀 화면을 더 크게 쓸게 한다
var throb = throb0;
var mag = H * 0.38 * throb;
var main = genBolt(elL.x, elL.y, elR.x, elR.y, mag, JITTER);
// 잔광 拡散 버퍼 — 이번 프레임 아크를 도장한 뒤 번지게 하고 무대 위에 깐다(반경 확대로 광륜을 키운다)
glowStampPath(main, 14);
glowDiffuse();
glowCompose();
ctx.globalCompositeOperation = 'lighter';
ctx.drawImage(glowCv, 0, 0);
// 가산혼합 — 아크의 겹친 빛
ctx.globalCompositeOperation = 'lighter';
strokePath(main, 10, rgb(C_HALO, 0.17 * throb + 0.06));
strokePath(main, 3.2, rgb(C_HALO, 0.5));
strokePath(main, 1.4, rgb(C_CORE, 0.95));
// 곁가지 — 경로의 무작위 지점에서 위쪽으로 짧게 갈라진다. 매 프레임 새 지점·새 경로
for (var k = 0; k < FORK_N; k++) {
var t = 0.2 + rand() * 0.6;
var idx = Math.min(main.length - 2, Math.floor(t * (main.length - 1)));
var p = main[idx];
var bl = H * (0.18 + rand() * 0.22);
var side = rand() < 0.5 ? -1 : 1;
var br = genBolt(p.x, p.y, p.x + side * W * 0.07, p.y - bl, bl * 0.42, 4);
glowStampPath(br, 9); // 곁가지도 잔광에 남긴다
var fc = k % 2 === 0 ? C_MINT : C_PINK;
strokePath(br, 3.0, rgb(fc, 0.2));
strokePath(br, 1.0, rgb(fc, 0.6));
}
// 전극 — 구(전구) + 스탠드. source-over 로 되돌려 또렷하게
ctx.globalCompositeOperation = 'source-over';
drawElectrode(elL, tSec);
drawElectrode(elR, tSec);
}
// 전극 한 쪽 — 받침대·봉·구(구체 하이라이트 포함). 구는 아크 쪽을 향해 뜨겁게 밝는다
function drawElectrode(e, tSec) {
var r = Math.max(5, H * 0.055);
var baseY = H - 6;
// 받침 — 어두운 슬레이트
ctx.fillStyle = rgb(C_INK[0] + 26, C_INK[1] + 26, C_INK[2] + 34, 1);
roundRect(e.x - r * 1.7, baseY - 3, r * 3.4, 5, 2);
// 봉
ctx.strokeStyle = rgb(C_MIST, 0.4);
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(e.x, baseY - 3);
ctx.lineTo(e.x, e.y + r);
ctx.stroke();
// 구 — 코어 채움 + 라일락 테두리 + 반대편 하이라이트 점
var glow = 0.55 + 0.45 * Math.abs(Math.sin(tSec * 2.1 + 1.2));
ctx.globalCompositeOperation = 'lighter';
ctx.fillStyle = rgb(C_HALO, 0.3 * glow);
ctx.beginPath(); ctx.arc(e.x, e.y, r * 1.9, 0, Math.PI * 2); ctx.fill();
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = rgb(C_CORE, 0.9);
ctx.beginPath(); ctx.arc(e.x, e.y, r, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = rgb(C_HALO, 0.85);
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(e.x, e.y, r, 0, Math.PI * 2); ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,0.85)';
ctx.beginPath(); ctx.arc(e.x - r * 0.35, e.y - r * 0.35, r * 0.2, 0, Math.PI * 2); ctx.fill();
}
function roundRect(x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.fill();
}
function fit() {
var r = field.getBoundingClientRect();
W = Math.max(2, Math.round(r.width));
H = Math.max(2, Math.round(r.height));
cv.width = W; cv.height = H;
prngState = SEED >>> 0;
glowFit();
// 전극 — 좌우 안쪽으로 살짝 들어간 구 스탠드, 수직 중앙보다 약간 위
var m = Math.max(34, W * 0.14);
elL.x = m; elL.y = H * 0.42;
elR.x = W - m; elR.y = H * 0.42;
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
prev = now;
draw(t);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () { fit(); if (reduce) draw(STILL_T); });
fit();
if (reduce) {
draw(STILL_T);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// TeslaArc — vanilla 판(run/316/demo/05-lightning-tesla-arc-electrode/index.html) 의 React 포트.
// 좌우 고정 전극 사이의 중점변위 경로를 매 프레임 새 난수로 재계산해 그린다(끝점 고정·경로만 떨림).
// 밝기 겹침은 lighter 컴포짓, 곁가지는 경로의 무작위 지점에서 매 프레임 새로 갈라진다.
// 잔광 확산(04 와 같은 기법) — 아크가 지나간 자리의 밝기 버퍼를 오프스크린 캔버스로 번지게 해 무대 위에 깐다.
// 전면 라이팅 — 아크 세기(throb)에 맞춰 무대 전체가 호흡처럼 밝아졌다 어두워진다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TeslaArc.module.scss";
export type TeslaArcProps = {
duration?: string;
easing?: string;
color?: string;
volt?: number; // 표시 전압(kV) — 변형 "전압" 축(240 → 480)
jitter?: number; // 변위 깊이 — 클수록 경로가 잘게 떨린다(9 → 12)
forkN?: number; // 곁가지 수 — 변형 "방전 가지" 축(3 → 6)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Pt = { x: number; y: number };
type Electrode = { x: number; y: number };
type Colors = { ink: RGB; core: RGB; halo: RGB; mint: RGB; pink: RGB; mist: RGB };
type Glow = {
buf: Float32Array;
buf2: Float32Array;
cv: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
img: ImageData;
};
const FIELD_LABEL = "테슬라 아크 방전 실험판";
const STILL_T = 1.6;
const SWARM = 0.50; // 잔광 확산 계수
const DIE_OUT = 0.985; // 잔광 냉각 계수 — 잔상이 한 동안 남아 누적면적을 채운다
// 밝기 t → 불투명도 LUT. 라일락 헤일로 색으로 뿌린다
const HALO_LUT = new Uint8ClampedArray(256);
for (let i = 0; i < 256; i++) {
const t = i / 255;
HALO_LUT[i] = Math.round(255 * Math.pow(t, 0.78) * 0.5);
}
function makePRNG(seed: number): () => number {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function TeslaArc({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b9a0ff",
volt = 240,
jitter = 9,
forkN = 3,
}: TeslaArcProps) {
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 sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const colRef = useRef<Colors | null>(null);
const jitterRef = useRef(jitter) as Box<number>;
const forkRef = useRef(forkN) as Box<number>;
const elLRef = useRef<Electrode>({ x: 0, y: 0 }) as Box<Electrode>;
const elRRef = useRef<Electrode>({ x: 0, y: 0 }) as Box<Electrode>;
const glowRef = useRef<Glow | null>(null);
const randRef = useRef<() => number>(makePRNG(0x3160505)) as Box<() => number>;
const rafRef = useRef(0) as Box<number>;
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 tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function rgbStr(c: RGB, a: number): string {
return `rgba(${c[0]},${c[1]},${c[2]},${a})`;
}
function genBolt(ax: number, ay: number, bx: number, by: number, mag: number, depth: number): Pt[] {
const rand = randRef.current;
let pts: Pt[] = [{ x: ax, y: ay }, { x: bx, y: by }];
const dx = bx - ax, dy = by - ay;
const len = Math.hypot(dx, dy) || 1;
const nx = -dy / len, ny = dx / len;
let off = mag;
for (let d = 0; d < depth; d++) {
const next: Pt[] = [pts[0]];
for (let i = 0; i < pts.length - 1; i++) {
const p = pts[i], q = pts[i + 1];
const r = (rand() * 2 - 1) * off;
next.push({ x: (p.x + q.x) * 0.5 + nx * r, y: (p.y + q.y) * 0.5 + ny * r }, q);
}
pts = next;
off *= 0.55;
}
return pts;
}
// ── 잔광 버퍼(04 확산 기법) ──
function glowFit(): void {
const { w: W, h: H } = sizeRef.current;
const cv = document.createElement("canvas");
cv.width = W; cv.height = H;
const gctx = cv.getContext("2d");
if (!gctx) return;
const img = gctx.createImageData(W, H);
const d = img.data;
for (let i = 3; i < d.length; i += 4) d[i] = 255;
glowRef.current = { buf: new Float32Array(W * H), buf2: new Float32Array(W * H), cv, ctx: gctx, img };
}
// 아크 경로를 밝기 버퍼에 도장 — 코어는 1.2, 둘레는 거리 비례 감쇠
function glowStamp(px: number, py: number, radius: number): void {
const g = glowRef.current;
const { w: W, h: H } = sizeRef.current;
if (!g) return;
const cx = Math.round(px), cy = Math.round(py);
for (let yy = cy - radius; yy <= cy + radius; yy++) {
if (yy < 0 || yy >= H) continue;
for (let xx = cx - radius; xx <= cx + radius; xx++) {
if (xx < 0 || xx >= W) continue;
const dxx = xx - cx, dyy = yy - cy;
const d = Math.sqrt(dxx * dxx + dyy * dyy);
if (d > radius) continue;
const v = d <= 1 ? 1.2 : (1 - d / radius) * 0.6;
const idx = yy * W + xx;
if (v > g.buf[idx]) g.buf[idx] = v;
}
}
}
function glowStampPath(pts: Pt[], radius: number): void {
for (let i = 0; i < pts.length - 1; i++) {
const p = pts[i], q = pts[i + 1];
const seg = Math.max(1, Math.ceil(Math.hypot(q.x - p.x, q.y - p.y) / 2));
for (let s = 0; s < seg; s++) {
const f = s / seg;
glowStamp(p.x + (q.x - p.x) * f, p.y + (q.y - p.y) * f, radius);
}
}
}
// 확산 — 이웃 4픽셀 평균 쪽으로 섞고 냉각. 이중 버퍼 교체
function glowDiffuse(): void {
const g = glowRef.current;
const { w: W, h: H } = sizeRef.current;
if (!g) return;
const buf = g.buf, buf2 = g.buf2;
for (let y = 0; y < H; y++) {
const row = y * W;
const up = (y > 0 ? y - 1 : y) * W;
const dn = (y < H - 1 ? y + 1 : y) * W;
for (let x = 0; x < W; x++) {
const i = row + x;
const l = buf[i];
const av = (buf[row + (x > 0 ? x - 1 : x)] + buf[row + (x < W - 1 ? x + 1 : x)] + buf[up + x] + buf[dn + x]) * 0.25;
buf2[i] = (l + SWARM * (av - l)) * DIE_OUT;
}
}
g.buf = buf2;
g.buf2 = buf;
}
// 버퍼 → 라일락 헤일로 반투명 레이어
function glowCompose(): void {
const g = glowRef.current;
const col = colRef.current;
const { w: W, h: H } = sizeRef.current;
if (!g || !col) return;
const d = g.img.data, n = W * H;
for (let i = 0; i < n; i++) {
const v = g.buf[i];
const li = v >= 1 ? 255 : v <= 0 ? 0 : (v * 255) | 0;
const a = HALO_LUT[li];
const j = i << 2;
d[j] = col.halo[0]; d[j + 1] = col.halo[1]; d[j + 2] = col.halo[2]; d[j + 3] = a;
}
g.ctx.putImageData(g.img, 0, 0);
}
function strokePath(pts: Pt[], width: number, color2: string): void {
const ctx = ctxRef.current;
if (!ctx) return;
ctx.strokeStyle = color2;
ctx.lineWidth = width;
ctx.lineJoin = "round";
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.stroke();
}
function roundRect(x: number, y: number, w: number, h: number, r: number): void {
const ctx = ctxRef.current;
if (!ctx) return;
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.fill();
}
function drawElectrode(e: Electrode, tSec: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const { h: H } = sizeRef.current;
if (!ctx || !col) return;
const r = Math.max(5, H * 0.055);
const baseY = H - 6;
ctx.fillStyle = rgbStr([col.ink[0] + 26, col.ink[1] + 26, col.ink[2] + 34], 1);
roundRect(e.x - r * 1.7, baseY - 3, r * 3.4, 5, 2);
ctx.strokeStyle = rgbStr(col.mist, 0.4);
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(e.x, baseY - 3);
ctx.lineTo(e.x, e.y + r);
ctx.stroke();
const glow = 0.55 + 0.45 * Math.abs(Math.sin(tSec * 2.1 + 1.2));
ctx.globalCompositeOperation = "lighter";
ctx.fillStyle = rgbStr(col.halo, 0.3 * glow);
ctx.beginPath(); ctx.arc(e.x, e.y, r * 1.9, 0, Math.PI * 2); ctx.fill();
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = rgbStr(col.core, 0.9);
ctx.beginPath(); ctx.arc(e.x, e.y, r, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = rgbStr(col.halo, 0.85);
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(e.x, e.y, r, 0, Math.PI * 2); ctx.stroke();
ctx.fillStyle = "rgba(255,255,255,0.85)";
ctx.beginPath(); ctx.arc(e.x - r * 0.35, e.y - r * 0.35, r * 0.2, 0, Math.PI * 2); ctx.fill();
}
function draw(tSec: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const g = glowRef.current;
const { w: W, h: H } = sizeRef.current;
if (!ctx || !col || !g) return;
const elL = elLRef.current, elR = elRRef.current;
// 무대 — 어스름 잉크 + 전면 라이팅(아크 세기에 맞춰 방 안 밝기가 호흡)
const throb = 0.62 + 0.38 * Math.abs(Math.sin(tSec * 2.1));
const roomA = 0.04 + 0.42 * throb; // 라일락 라이팅 알파 — 방전 세기를 무대가 그대로 받는다
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = rgbStr(col.ink, 1);
ctx.fillRect(0, 0, W, H);
const midX = (elL.x + elR.x) / 2;
const room = ctx.createRadialGradient(midX, H * 0.42, 0, midX, H * 0.42, W * 0.62);
room.addColorStop(0, rgbStr(col.halo, roomA * 0.9));
room.addColorStop(1, rgbStr(col.halo, 0));
ctx.fillStyle = room;
ctx.fillRect(0, 0, W, H);
const mag = H * 0.16 * throb;
const main = genBolt(elL.x, elL.y, elR.x, elR.y, mag, jitterRef.current);
// 잔광 확산 버퍼 — 이번 프레임 아크를 도장한 뒤 번지게 하고 무대 위에 깐다
glowStampPath(main, 14);
glowDiffuse();
glowCompose();
ctx.globalCompositeOperation = "lighter";
ctx.drawImage(g.cv, 0, 0);
// 가산혼합 — 아크의 겹친 빛
strokePath(main, 10, rgbStr(col.halo, 0.17 * throb + 0.06));
strokePath(main, 3.2, rgbStr(col.halo, 0.5));
strokePath(main, 1.4, rgbStr(col.core, 0.95));
const rand = randRef.current;
for (let k = 0; k < forkRef.current; k++) {
const t = 0.2 + rand() * 0.6;
const idx = Math.min(main.length - 2, Math.floor(t * (main.length - 1)));
const p = main[idx];
const bl = H * (0.18 + rand() * 0.22);
const side = rand() < 0.5 ? -1 : 1;
const br = genBolt(p.x, p.y, p.x + side * W * 0.07, p.y - bl, bl * 0.42, 4);
glowStampPath(br, 9);
const fc = k % 2 === 0 ? col.mint : col.pink;
strokePath(br, 3.0, rgbStr(fc, 0.2));
strokePath(br, 1.0, rgbStr(fc, 0.6));
}
ctx.globalCompositeOperation = "source-over";
drawElectrode(elL, tSec);
drawElectrode(elR, tSec);
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const r = field.getBoundingClientRect();
const W = Math.max(2, Math.round(r.width));
const H = Math.max(2, Math.round(r.height));
sizeRef.current.w = W;
sizeRef.current.h = H;
cv.width = W; cv.height = H;
ctxRef.current = ctx;
randRef.current = makePRNG(0x3160505);
glowFit();
const m = Math.max(34, W * 0.14);
elLRef.current = { x: m, y: H * 0.42 };
elRRef.current = { x: W - m, y: H * 0.42 };
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
draw(t);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
jitterRef.current = jitter;
forkRef.current = forkN;
colRef.current = {
ink: tokRGB(root, "--bolt-ink"),
core: tokRGB(root, "--bolt-core"),
halo: tokRGB(root, "--bolt-halo"),
mint: tokRGB(root, "--bolt-mint"),
pink: tokRGB(root, "--bolt-pink"),
mist: tokRGB(root, "--bolt-mist"),
};
fit();
function onResize(): void {
fit();
if (reduced()) draw(STILL_T);
}
window.addEventListener("resize", onResize);
if (reduced()) {
draw(STILL_T);
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [volt, jitter, forkN]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><path fill="currentColor" opacity=".95" d="M134 12a10 10 0 0 1 10 10v66a12 12 0 1 1-24 0V22a10 10 0 0 1 10-10h4zM62 96a10 10 0 0 1 10 10v52a12 12 0 1 1-24 0v-52a10 10 0 0 1 10-10h4zm132 0a10 10 0 0 1 10 10v52a12 12 0 1 1-24 0v-52a10 10 0 0 1 10-10h4zM128 108a14 14 0 0 1 8 25v27h-12v-27a14 14 0 0 1 4-25zM78 168a8 8 0 0 1 0 16H54a8 8 0 0 1 0-16h24zm50 0a8 8 0 0 1 0 16h-24a8 8 0 0 1 0-16h24zm50 0a8 8 0 0 1 0 16h-24a8 8 0 0 1 0-16h24zM128 200a8 8 0 0 1 0 16h-6v-16h6zm-6 0v16h-6a8 8 0 0 1 0-16h6z"/><circle cx="128" cy="18" r="18" fill="currentColor" opacity=".25"/></svg>
<span>두 전극 사이의 중점변위 경로를 매 프레임 새 시드로 다시 계산합니다 — 끝점은 고정인 채 경로만 끊임없이 떨리는 지속 방전</span>
</p>
<div className={styles.field} ref={fieldRef} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>전압 <b className={styles.val}>{volt}</b>kV · 가지 <b className={styles.val}>{forkN}</b></span>
<span className={styles.metaDim}>캔버스 · lineTo · globalCompositeOperation</span>
</div>
</div>
</main>
);
}05 テスラアーク — 2つの電極間を毎フレーム引き直す持続放電 — 計測 累積面積 32.122% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · キャンバス 稲妻 エフェクト 作り方9種 — フラクタル中点変位で落ちる雷
このデモが属する記事
キャンバス 稲妻 エフェクト 作り方9種 — フラクタル中点変位で落ちる雷稲妻 エフェクト 作り方とは、雷の画像を貼る仕事ではなく、まっすぐな線分を再帰的に割っていく計算のことです。ライブラリ·WebGL なしで線分を再帰的に割る中点変位ひとつで稲妻の枝が自ら分岐します。始点と終点を結ぶ線の中点を垂直方向へ乱数の この記事のデモをすべて見る


