01기본 낙뢰 — 선분을 반으로 쪼개는 중점변위 한 겹
시작점과 끝점을 잇는 직선을 반으로 나누고, 그 중점을 수직 방향으로 무작위만큼 어긋내는 걸 재귀로 반복하면 곧은 선이 들쭉날쭉한 번개 줄기가 됩니다.
쓰임히어로 배경의 순간 낙뢰, 로딩 화면의 번쩍임
moveTolineTorequestAnimationFrame

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<span>시작 점과 끝 점을 잇는 선분을 반으로 쪼개 중점을 수직으로 밀기를 6 단계 반복하면 직선이 번개가 됩니다. 낙뢰는 화면을 골고루 오가며 칠 때마다 경로를 다시 뽑아 깜빡입니다</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="cdN">6</b>단계 중점변위 · 기본 낙뢰</span>
<span class="cd__meta cd__meta--dim">canvas · moveTo lineTo · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 중점변위 기본 낙뢰(01). @keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(재귀 중점변위 + 재점등 + 플래시)가 만든다.
// 무대는 어스름 잉크. 파스텔 고정 팔레트를 --bolt-* 로 편 공통 규격(01~09 동일).
$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-lilac: #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-lilac: #{$bolt-lilac};
--bolt-mint: #{$bolt-mint};
--bolt-pink: #{$bolt-pink};
--bolt-mist: #{$bolt-mist};
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__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-lilac; 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-lilac; 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-lilac: #b9a0ff;
--bolt-mint: #b9ddd1;
--bolt-pink: #ffb9c8;
--bolt-mist: #d8dce6;
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__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;
}
}// 중점변위 기본 낙뢰(01). 선분 (a, b) 를 반으로 나눠 중점을 선분에 수직인 방향으로 무작위 오프셋만큼 밀고
// 이 쪼개기를 깊이 DEPTH 까지 재귀로 반복하면 직선이 들쭉날쭉한 번개 줄기가 된다.
// 낙뢰는 정해진 순서로 화면을 골고루 오가고(누적 면적), 살아 있는 동안 60 밀리초마다 경로를 다시 뽑아
// 깜빡인다(실제 번개의 재점등). 낙뢰 순간 하늘 전체가 잠깐 밝아지는 플래시와 지면 착지 블룸이 붙는다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var DEPTH = 6; // 재귀 분할 깊이 — 5 는 굵직한 굴곡, 7 은 잔잔한 지그재그
var ROUGH = 0.42; // 중점변위 거칠기 — 중점을 선분 길이의 몇 배까지 미는지
var FLASH = 0.85; // 하늘 플래시 비율 — 낙뢰 순간 하늘이 밝아지는 정도(전면 라이팅)
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var nOut = document.getElementById('cdN');
// 시드 고정 PRNG — mulberry32. 미리보기가 항상 같은 낙뢰 순서로 흐른다
function makeRand(seed) {
var st = seed >>> 0;
return function () {
st = (st + 0x6D2B79F5) >>> 0;
var t = st;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = makeRand(0x316A01);
// 색은 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_DUSK = tokRGB('--bolt-dusk');
var C_CORE = tokRGB('--bolt-core');
var C_LILA = tokRGB('--bolt-lilac');
var C_MINT = tokRGB('--bolt-mint');
var C_PINK = tokRGB('--bolt-pink');
var C_MIST = tokRGB('--bolt-mist');
var TAU = Math.PI * 2;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0;
var raf = 0, t0 = -1;
var GROUND_H = 14;
// ── 중점변위 — 번개의 전부 ──
function displace(ax, ay, bx, by, depth, out) {
if (depth === 0) { out.push([bx, by]); return; }
var mx = (ax + bx) / 2, my = (ay + by) / 2;
var dx = bx - ax, dy = by - ay;
var len = Math.hypot(dx, dy) || 1;
var nx = -dy / len, ny = dx / len; // 선분에 수직인 법선
var off = (rand() * 2 - 1) * len * ROUGH * 0.5; // 선분 길이에 비례하는 무작위 변위
mx += nx * off; my += ny * off;
displace(ax, ay, mx, my, depth - 1, out);
displace(mx, my, bx, by, depth - 1, out);
}
function makePath(sx, ex) {
var pts = [[sx, -8]];
displace(sx, -8, ex, H + 6, DEPTH, pts);
return pts;
}
// ── 낙뢰 스케줄 — 화면을 골고루 쓸는 정해진 순서 ──
var strikes = [];
var nextAt = 0.08;
var cycle = 0;
var flash = 0.3;
var ANCHORS = [0.18, 0.55, 0.86, 0.34, 0.7, 0.1, 0.92, 0.46];
// 밤하늘 먼지 — 낙뢰 사이 구간에도 명도가 흔들리는 안전판
var specks = [];
function buildSpecks() {
specks = [];
for (var i = 0; i < 22; i++) {
specks.push({ rx: rand(), ry: rand() * 0.82, ph: rand() * TAU, spd: 1.1 + rand() * 1.7 });
}
}
function spawn(t) {
var ax = ANCHORS[cycle % ANCHORS.length] + (rand() - 0.5) * 0.1;
cycle++;
var sx = Math.max(0.05, Math.min(0.95, ax)) * W;
var ex = sx + (rand() - 0.5) * W * 0.26;
strikes.push({ sx: sx, ex: ex, pts: makePath(sx, ex), born: t, life: 0.62, flickAt: t });
flash = Math.min(1, flash + 0.95);
}
// 낙뢰 밝기 포락선 — 감쇠하다 두 번 다시 밝아지는 실제 번개의 재점등
function env(s, t) {
var u = (t - s.born) / s.life;
if (u >= 1 || u < 0) return 0;
var b = Math.pow(1 - u, 1.5);
if (u > 0.3 && u < 0.37) b = Math.max(b, 0.82);
if (u > 0.55 && u < 0.61) b = Math.max(b, 0.5);
return b;
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
function mix(c1, c2, k) {
return [c1[0] + (c2[0] - c1[0]) * k, c1[1] + (c2[1] - c1[1]) * k, c1[2] + (c2[2] - c1[2]) * k];
}
function strokePath(pts, col, wdt, alpha) {
ctx.strokeStyle = rgba(col, alpha);
ctx.lineWidth = wdt;
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (var i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]);
ctx.stroke();
}
function draw(t) {
var f = flash * FLASH;
// 하늘 — 잉크에서 지평선 쪽 슬레이트로. 플래시만큼 전체가 띄어오른다
// 잔광 확산: 하늘을 매 프레임 완전히 덮어쓰지 않고 FADE 만큼만 섞는다 —
// 직전 프레임의 밝은 흔적(헤일로·블룸)이 몇 프레임에 걸쳐 식으며 남는다(누적면적 ↑)
var FADE = 0.30;
var top = mix(C_INK, C_DUSK, 0.10 + 0.55 * f);
var bot = mix(C_INK, C_DUSK, 0.50 + 0.50 * f);
var sky = ctx.createLinearGradient(0, 0, 0, H);
sky.addColorStop(0, rgba(top, FADE));
sky.addColorStop(1, rgba(bot, FADE));
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, H);
for (var i = 0; i < specks.length; i++) {
var sp = specks[i];
var a = 0.05 + 0.30 * (0.5 + 0.5 * Math.sin(t * sp.spd + sp.ph));
ctx.fillStyle = rgba(C_MIST, a);
ctx.fillRect(sp.rx * W, sp.ry * H, 1.6, 1.6);
}
var gy = H - GROUND_H;
ctx.fillStyle = rgba(mix(C_INK, C_DUSK, 0.16 + 0.2 * f), 1);
ctx.fillRect(0, gy, W, GROUND_H);
ctx.fillStyle = rgba(C_MIST, 0.20 + 0.25 * f);
ctx.fillRect(0, gy, W, 1);
// 잔광 — 방금 지나간 낙뢰가 줄기 자리에 남기는 옅은 빛
var j, s, age, k;
for (j = 0; j < strikes.length; j++) {
s = strikes[j];
age = t - s.born;
if (age > s.life && age < s.life + 0.5) {
k = 1 - (age - s.life) / 0.5;
strokePath(s.pts, C_LILA, 10, 0.11 * k);
strokePath(s.pts, C_PINK, 2.4, 0.06 * k);
}
}
// 활성 낙뢰 — 헤일로·중간광·코어 세 겹
for (j = 0; j < strikes.length; j++) {
s = strikes[j];
var b = env(s, t);
if (b <= 0) continue;
strokePath(s.pts, C_LILA, 12, 0.22 * b);
strokePath(s.pts, C_LILA, 4.2, 0.55 * b);
strokePath(s.pts, C_CORE, 1.8, b);
// 착지 블룸 — 지면에 번지는 민트 빛과 핑크 고리
var bx = s.ex, by = gy;
var r = 12 + 42 * b;
var g2 = ctx.createRadialGradient(bx, by, 0, bx, by, r);
g2.addColorStop(0, rgba(C_MINT, 0.5 * b));
g2.addColorStop(0.55, rgba(C_LILA, 0.2 * b));
g2.addColorStop(1, rgba(C_LILA, 0));
ctx.fillStyle = g2;
ctx.fillRect(bx - r, by - r, r * 2, r * 2);
ctx.beginPath();
ctx.arc(bx, by, 5 + 20 * b, 0, TAU);
ctx.strokeStyle = rgba(C_PINK, 0.3 * b);
ctx.lineWidth = 1.4;
ctx.stroke();
}
if (f > 0.02) {
ctx.fillStyle = rgba(C_MINT, 0.08 * f);
ctx.fillRect(0, gy, W, GROUND_H);
}
}
function update(t, dt) {
if (t >= nextAt) {
spawn(t);
nextAt = t + 0.42 + rand() * 0.14;
}
flash *= Math.exp(-dt * 3.4);
for (var i = 0; i < strikes.length; i++) {
var s = strikes[i];
if (t - s.born < s.life && t - s.flickAt > 0.06) {
s.pts = makePath(s.sx, s.ex); // 재점동 — 경로를 다시 뽑는 깜빡임
s.flickAt = t;
}
}
while (strikes.length && t - strikes[0].born > 1.3) strikes.shift();
}
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
}
function stillFrame() {
strikes = [];
flash = 0.85;
var sx = 0.52 * W;
var ex = sx + W * 0.06;
strikes.push({ sx: sx, ex: ex, pts: makePath(sx, ex), born: 0.05, life: 0.62, flickAt: 0 });
draw(0.06);
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
var dt = Math.min(0.05, (now - (frame.prev || now)) / 1000);
frame.prev = now;
update(t, dt);
draw(t);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
buildSpecks();
nOut.textContent = String(DEPTH);
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// MidpointBasic — vanilla 판(run/316/demo/01-lightning-bolt-midpoint-basic/index.html) 의 React 포트.
// 중점변위 기본 낙뢰 — 선분을 반으로 쪼개 중점을 수직으로 밀기를 DEPTH 단계 반복해 직선을 번개로 만든다.
// 낙뢰는 정해진 순서로 화면을 골고루 오가고, 살아 있는 동안 경로를 다시 뽑아 깜빡이며(재점등),
// 낙뢰 순간 하늘 플래시와 지면 착지 블룸이 함께 온다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MidpointBasic.module.scss";
export type MidpointBasicProps = {
duration?: string;
easing?: string;
color?: string;
depth?: number; // 재귀 분할 깊이 — 변형 "분할 깊이" 축(5 굵직 · 7 잔잔)
rough?: number; // 중점변위 거칠기 — 중점을 선분 길이의 몇 배까지 미는지
flash?: number; // 하늘 플래시 비율 — 낙뢰 순간 하늘이 밝아지는 정도
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Pt = [number, number];
type Strike = { sx: number; ex: number; pts: Pt[]; born: number; life: number; flickAt: number };
type Speck = { rx: number; ry: number; ph: number; spd: number };
type Colors = { ink: RGB; dusk: RGB; core: RGB; lilac: RGB; mint: RGB; pink: RGB; mist: RGB };
const TAU = Math.PI * 2;
const FIELD_LABEL = "중점변위 기본 낙뢰 실험판";
const GROUND_H = 14;
const ANCHORS = [0.18, 0.55, 0.86, 0.34, 0.7, 0.1, 0.92, 0.46];
// 시드 고정 PRNG — mulberry32. 미리보기가 항상 같은 낙뢰 순서로 흐른다
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 MidpointBasic({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b9a0ff",
depth = 6,
rough = 0.42,
flash = 0.85,
}: MidpointBasicProps) {
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 depthRef = useRef(depth) as Box<number>;
const roughRef = useRef(rough) as Box<number>;
const flashRef = useRef(flash) as Box<number>;
const strikesRef = useRef<Strike[]>([]) as Box<Strike[]>;
const specksRef = useRef<Speck[]>([]) as Box<Speck[]>;
const schedRef = useRef({ nextAt: 0.08, cycle: 0, flash: 0.3 }) as Box<{ nextAt: number; cycle: number; flash: number }>;
const rafRef = useRef(0) as Box<number>;
const [n, setN] = useState(String(depth));
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;
}
// 색은 CSS 커스텀 프로퍼티(--bolt-*)에서 getComputedStyle 로 읽는다 — 파스텔 토큰이 scss 한 곳에
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 displace(ax: number, ay: number, bx: number, by: number, d: number, out: Pt[]): void {
if (d === 0) { out.push([bx, by]); return; }
let mx = (ax + bx) / 2, my = (ay + by) / 2;
const dx = bx - ax, dy = by - ay;
const len = Math.hypot(dx, dy) || 1;
const nx = -dy / len, ny = dx / len; // 선분에 수직인 법선
const off = (randRef.current() * 2 - 1) * len * roughRef.current * 0.5;
mx += nx * off; my += ny * off;
displace(ax, ay, mx, my, d - 1, out);
displace(mx, my, bx, by, d - 1, out);
}
const randRef = useRef(makePRNG(0x316a01)) as Box<() => number>;
function makePath(sx: number, ex: number): Pt[] {
const { h: H } = sizeRef.current;
const pts: Pt[] = [[sx, -8]];
displace(sx, -8, ex, H + 6, depthRef.current, pts);
return pts;
}
function buildSpecks(): void {
const rand = randRef.current;
const arr: Speck[] = [];
for (let i = 0; i < 22; i++) {
arr.push({ rx: rand(), ry: rand() * 0.82, ph: rand() * TAU, spd: 1.1 + rand() * 1.7 });
}
specksRef.current = arr;
}
function spawn(t: number): void {
const rand = randRef.current;
const { w: W } = sizeRef.current;
const sched = schedRef.current;
const ax = ANCHORS[sched.cycle % ANCHORS.length] + (rand() - 0.5) * 0.1;
sched.cycle++;
const sx = Math.max(0.05, Math.min(0.95, ax)) * W;
const ex = sx + (rand() - 0.5) * W * 0.26;
strikesRef.current.push({ sx, ex, pts: makePath(sx, ex), born: t, life: 0.62, flickAt: t });
sched.flash = Math.min(1, sched.flash + 0.95);
}
// 낙뢰 밝기 포락선 — 감쇠하다 두 번 다시 밝아지는 실제 번개의 재점등
function env(s: Strike, t: number): number {
const u = (t - s.born) / s.life;
if (u >= 1 || u < 0) return 0;
let b = Math.pow(1 - u, 1.5);
if (u > 0.3 && u < 0.37) b = Math.max(b, 0.82);
if (u > 0.55 && u < 0.61) b = Math.max(b, 0.5);
return b;
}
function rgba(c: RGB, a: number): string {
return `rgba(${c[0]},${c[1]},${c[2]},${a})`;
}
function mix(c1: RGB, c2: RGB, k: number): RGB {
return [c1[0] + (c2[0] - c1[0]) * k, c1[1] + (c2[1] - c1[1]) * k, c1[2] + (c2[2] - c1[2]) * k];
}
function strokePath(pts: Pt[], col: RGB, wdt: number, alpha: number): void {
const ctx = ctxRef.current;
if (!ctx) return;
ctx.strokeStyle = rgba(col, alpha);
ctx.lineWidth = wdt;
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]);
ctx.stroke();
}
function draw(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
if (!ctx || !col) return;
const { w: W, h: H } = sizeRef.current;
const f = schedRef.current.flash * flashRef.current;
// 하늘 — 잉크에서 지평선 쪽 슬레이트로. 플래시만큼 전체가 띄어오른다
// 잔광 확산: 하늘을 매 프레임 완전히 덮어쓰지 않고 FADE 만큼만 섞는다 —
// 직전 프레임의 밝은 흔적(헤일로·블룸)이 몇 프레임에 걸쳐 식으며 남는다(누적면적 ↑)
const FADE = 0.3;
const top = mix(col.ink, col.dusk, 0.10 + 0.55 * f);
const bot = mix(col.ink, col.dusk, 0.50 + 0.50 * f);
const sky = ctx.createLinearGradient(0, 0, 0, H);
sky.addColorStop(0, rgba(top, FADE));
sky.addColorStop(1, rgba(bot, FADE));
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, H);
const specks = specksRef.current;
for (let i = 0; i < specks.length; i++) {
const sp = specks[i];
const a = 0.05 + 0.3 * (0.5 + 0.5 * Math.sin(t * sp.spd + sp.ph));
ctx.fillStyle = rgba(col.mist, a);
ctx.fillRect(sp.rx * W, sp.ry * H, 1.6, 1.6);
}
const gy = H - GROUND_H;
ctx.fillStyle = rgba(mix(col.ink, col.dusk, 0.16 + 0.2 * f), 1);
ctx.fillRect(0, gy, W, GROUND_H);
ctx.fillStyle = rgba(col.mist, 0.2 + 0.25 * f);
ctx.fillRect(0, gy, W, 1);
const strikes = strikesRef.current;
// 잔광 — 방금 지나간 낙뢰가 줄기 자리에 남기는 옅은 빛
for (let j = 0; j < strikes.length; j++) {
const s = strikes[j];
const age = t - s.born;
if (age > s.life && age < s.life + 0.5) {
const k = 1 - (age - s.life) / 0.5;
strokePath(s.pts, col.lilac, 10, 0.11 * k);
strokePath(s.pts, col.pink, 2.4, 0.06 * k);
}
}
// 활성 낙뢰 — 헤일로·중간광·코어 세 겹
for (let j = 0; j < strikes.length; j++) {
const s = strikes[j];
const b = env(s, t);
if (b <= 0) continue;
strokePath(s.pts, col.lilac, 12, 0.22 * b);
strokePath(s.pts, col.lilac, 4.2, 0.55 * b);
strokePath(s.pts, col.core, 1.8, b);
// 착지 블룸 — 지면에 번지는 민트 빛과 핑크 고리
const bx = s.ex, by = gy;
const r = 12 + 42 * b;
const g2 = ctx.createRadialGradient(bx, by, 0, bx, by, r);
g2.addColorStop(0, rgba(col.mint, 0.5 * b));
g2.addColorStop(0.55, rgba(col.lilac, 0.2 * b));
g2.addColorStop(1, rgba(col.lilac, 0));
ctx.fillStyle = g2;
ctx.fillRect(bx - r, by - r, r * 2, r * 2);
ctx.beginPath();
ctx.arc(bx, by, 5 + 20 * b, 0, TAU);
ctx.strokeStyle = rgba(col.pink, 0.3 * b);
ctx.lineWidth = 1.4;
ctx.stroke();
}
if (f > 0.02) {
ctx.fillStyle = rgba(col.mint, 0.08 * f);
ctx.fillRect(0, gy, W, GROUND_H);
}
}
function update(t: number, dt: number): void {
const sched = schedRef.current;
if (t >= sched.nextAt) {
spawn(t);
sched.nextAt = t + 0.42 + randRef.current() * 0.14;
}
sched.flash *= Math.exp(-dt * 3.4);
const strikes = strikesRef.current;
for (let i = 0; i < strikes.length; i++) {
const s = strikes[i];
if (t - s.born < s.life && t - s.flickAt > 0.06) {
s.pts = makePath(s.sx, s.ex); // 재점동 — 경로를 다시 뽑는 깜빡임
s.flickAt = t;
}
}
while (strikes.length && t - strikes[0].born > 1.3) strikes.shift();
}
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();
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width);
cv.height = Math.round(r.height);
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctxRef.current = ctx;
}
function stillFrame(): void {
const { w: W } = sizeRef.current;
strikesRef.current = [];
schedRef.current.flash = 0.85;
const sx = 0.52 * W;
const ex = sx + W * 0.06;
strikesRef.current.push({ sx, ex, pts: makePath(sx, ex), born: 0.05, life: 0.62, flickAt: 0 });
draw(0.06);
}
let t0 = -1;
let prev = 0;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
const dt = Math.min(0.05, (now - (prev || now)) / 1000);
prev = now;
update(t, dt);
draw(t);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
depthRef.current = depth;
roughRef.current = rough;
flashRef.current = flash;
colRef.current = {
ink: tokRGB(root, "--bolt-ink"),
dusk: tokRGB(root, "--bolt-dusk"),
core: tokRGB(root, "--bolt-core"),
lilac: tokRGB(root, "--bolt-lilac"),
mint: tokRGB(root, "--bolt-mint"),
pink: tokRGB(root, "--bolt-pink"),
mist: tokRGB(root, "--bolt-mist"),
};
randRef.current = makePRNG(0x316a01);
schedRef.current = { nextAt: 0.08, cycle: 0, flash: 0.3 };
strikesRef.current = [];
fit();
buildSpecks();
setN(String(depth));
function onResize(): void {
fit();
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [depth, rough, flash]);
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}>
<span>시작 점과 끝 점을 잇는 선분을 반으로 쪼개 중점을 수직으로 밀기를 6 단계 반복하면 직선이 번개가 됩니다. 낙뢰는 화면을 골고루 오가며 칠 때마다 경로를 다시 뽑아 깜빡입니다</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}>{n}</b>단계 중점변위 · 기본 낙뢰</span>
<span className={styles.metaDim}>canvas · moveTo lineTo · requestAnimationFrame</span>
</div>
</div>
</main>
);
}01 기본 낙뢰 — 선분을 반으로 쪼개는 중점변위 한 겹 — 측정 누적면적 37.683% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 캔버스 번개 효과 그리기 9종 — 프랙탈 중점변위로 치는 낙뢰
이 데모가 속한 글
캔버스 번개 효과 그리기 9종 — 프랙탈 중점변위로 치는 낙뢰번개 효과 그리기는 번개 사진을 붙이는 일이 아니라 곧은 선 하나를 반씩 쪼개는 일입니다. 라이브러리·WebGL 없이 선분을 재귀로 쪼개는 중점변위 하나로 번개 가지가 스스로 갈라집니다. 시작점과 끝점을 이은 선분의 이 글의 데모 전부 보기


