04과거 위치 이력을 잇고 겹칠수록 밝아지는 꼬리와 빛무리
입자마다 최근 위치 N개를 저장해 선으로 잇고 오래된 점일수록 투명하게 하며, 가산 합성으로 겹친 곳이 흰빛으로 번진다.
쓰임야간 히어로 배경·게임 승리 화면
TRAIL_LENGLOW_BLURFADE_ALPHAHEAD_R

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="과거 위치 이력을 잇고 겹칠수록 밝아지는 꼬리와 빛무리"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">꼬리 이력을 가산 합성으로 겹친다 — 겹친 곳이 흰빛으로 번진다</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">과거 위치 이력을 잇고 겹칠수록 밝아지는 꼬리와 빛무리</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: manipulation;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { cursor: default; }
}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);
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: manipulation;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
// 파스텔 알갱이 팔레트와 밤하늘 두 끝 색
const PINK = [255, 170, 196];
const MINT = [150, 226, 196];
const LILAC = [196, 176, 255];
const SKY_BLUE = [150, 214, 255];
const BUTTER = [255, 230, 150];
const CREAM = [255, 247, 230];
const PALETTE = [PINK, MINT, LILAC, SKY_BLUE, BUTTER];
const SKY_TOP = [18, 16, 32];
const SKY_BOT = [46, 40, 76];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i, seed) {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function hash2(x, y, seed) {
return hash1((Math.imul(x | 0, 73856093) ^ Math.imul(y | 0, 19349663)) | 0, seed);
}
// 1차원 값 노이즈 — 정수점 해시를 smooth 로 이어 붙인다
function noise1(x, seed) {
const xi = Math.floor(x);
return lerp(hash1(xi, seed), hash1(xi + 1, seed), smooth(x - xi));
}
// 2차원 값 노이즈
function vnoise(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 밤하늘 바탕 — 위가 어둡고 아래가 보랏빛
function drawSky(ctx, height) {
const g = ctx.createLinearGradient(0, 0, 0, height);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, height);
}
function start(root, canvas, statusEl, btnEl, make) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { x: W / 2, y: H / 2, idle: 99999, vx: 0, vy: 0, dpr: 1 };
let lastPx = -1;
let lastPy = -1;
// 화면 배율이 바뀌어도 캔버스 픽셀 수를 다시 맞춘다
function fit() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
inp.dpr = dpr;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fit();
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
ctx.setTransform(inp.dpr, 0, 0, inp.dpr, 0, 0);
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function tickOnce() {
inp.idle += 1;
inp.vx *= 0.9;
inp.vy *= 0.9;
scene.step();
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
tickOnce();
}
}
function refresh() {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
const nx = clamp((e.clientX - r.left) * W / r.width, 0, W);
const ny = clamp((e.clientY - r.top) * H / r.height, 0, H);
if (lastPx >= 0) {
inp.vx = lerp(inp.vx, nx - lastPx, 0.5);
inp.vy = lerp(inp.vy, ny - lastPy, 0.5);
}
lastPx = nx;
lastPy = ny;
inp.x = nx;
inp.y = ny;
inp.idle = 0;
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
tickOnce();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
}
show();
listen(btnEl, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e) {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e) {
lastPx = -1;
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.vx = dx * 0.5;
inp.vy = dy * 0.5;
inp.idle = 0;
refresh();
});
listen(window, 'resize', function () {
fit();
show();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const TRAIL_LEN = 16;
const GLOW_BLUR = 9;
const FADE_ALPHA = 0.86;
const HEAD_R = 2.1;
const BURST_GAP = 44;
const SPARK_N = 56;
const SPARK_DRAG = 0.972;
const SPARK_GRAVITY = 0.045;
const PREWARM_STEPS = 38;
const TXT_AUTO = '꼬리 이력을 가산 합성으로 겹친다 — 겹친 곳이 흰빛으로 번진다';
const TXT_TAP = '누른 자리에서 빛무리가 터진다';
// 위치 이력 한 줄에서 i 번째 점의 알파 — 머리에서 멀수록 FADE_ALPHA 를 거듭 곱한다
function trailAlpha(back) {
return Math.pow(FADE_ALPHA, back);
}
function makeScene(inp, reduce) {
const rng = lcg(4004);
let sparks = [];
let gap = 2;
let colorIdx = 0;
const glows = [];
function glowFor(idx) {
if (glows[idx]) {
return glows[idx];
}
const size = GLOW_BLUR * 4;
const cv = document.createElement('canvas');
cv.width = size;
cv.height = size;
const c = cv.getContext('2d');
if (c) {
const g = c.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
g.addColorStop(0, rgba(CREAM, 0.9));
g.addColorStop(0.25, rgba(PALETTE[idx], 0.45));
g.addColorStop(1, rgba(PALETTE[idx], 0));
c.fillStyle = g;
c.fillRect(0, 0, size, size);
}
glows[idx] = cv;
return cv;
}
function burst(x, y) {
const hue = colorIdx % PALETTE.length;
colorIdx += 1;
for (let i = 0; i < SPARK_N; i += 1) {
const a = rng() * PI2;
const sp = 2.2 + rng() * 2.4;
sparks.push({ x: x, y: y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, age: 0, life: 70 + rng() * 26, hist: [], hue: i % 4 === 0 ? (hue + 1) % PALETTE.length : hue });
}
if (sparks.length > 330) {
sparks.splice(0, sparks.length - 330);
}
}
const scene = {
step() {
gap -= 1;
if (gap <= 0) {
burst(90 + rng() * 300, 80 + rng() * 70);
gap = BURST_GAP;
}
const alive = [];
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
s.age += 1;
s.hist.push(s.x, s.y);
if (s.hist.length > TRAIL_LEN * 2) {
s.hist.splice(0, 2);
}
s.vx *= SPARK_DRAG;
s.vy = s.vy * SPARK_DRAG + SPARK_GRAVITY;
s.x += s.vx;
s.y += s.vy;
if (s.age < s.life) {
alive.push(s);
}
}
sparks = alive;
},
draw(ctx) {
drawSky(ctx, H);
// 이 항목의 핵심 — 합성 방식을 가산으로 바꾸면 겹친 곳의 색이 더해져 하얗게 번진다
ctx.globalCompositeOperation = 'lighter';
ctx.lineCap = 'round';
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
const life = Math.pow(1 - s.age / s.life, 0.7);
const n = s.hist.length / 2;
for (let k = 1; k < n; k += 1) {
const a = trailAlpha(n - k) * life;
ctx.strokeStyle = rgba(PALETTE[s.hue], a * 0.85);
ctx.lineWidth = lerp(0.6, HEAD_R * 1.3, k / n);
ctx.beginPath();
ctx.moveTo(s.hist[(k - 1) * 2], s.hist[(k - 1) * 2 + 1]);
ctx.lineTo(s.hist[k * 2], s.hist[k * 2 + 1]);
ctx.stroke();
}
const r = GLOW_BLUR * (0.6 + 0.4 * life);
ctx.globalAlpha = life * (reduce ? 0.6 : 0.85);
ctx.drawImage(glowFor(s.hue), s.x - r, s.y - r, r * 2, r * 2);
ctx.globalAlpha = 1;
ctx.fillStyle = rgba(CREAM, life);
ctx.beginPath();
ctx.arc(s.x, s.y, HEAD_R * (0.5 + 0.5 * life), 0, PI2);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
},
label() {
return inp.idle < IDLE_TICKS ? TXT_TAP : TXT_AUTO;
},
tap() {
burst(inp.x, inp.y);
}
};
for (let i = 0; i < PREWARM_STEPS; i += 1) {
scene.step();
}
return scene;
}
start(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene);
})();import { useEffect, useRef } from "react";
import styles from "./FwAdditiveTrailHistoryGlow.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
type RGB = [number, number, number];
interface Input { x: number; y: number; idle: number; vx: number; vy: number; dpr: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
tap(): void;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
// 파스텔 알갱이 팔레트와 밤하늘 두 끝 색
const PINK: RGB = [255, 170, 196];
const MINT: RGB = [150, 226, 196];
const LILAC: RGB = [196, 176, 255];
const SKY_BLUE: RGB = [150, 214, 255];
const BUTTER: RGB = [255, 230, 150];
const CREAM: RGB = [255, 247, 230];
const PALETTE: RGB[] = [PINK, MINT, LILAC, SKY_BLUE, BUTTER];
const SKY_TOP: RGB = [18, 16, 32];
const SKY_BOT: RGB = [46, 40, 76];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i: number, seed: number): number {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function hash2(x: number, y: number, seed: number): number {
return hash1((Math.imul(x | 0, 73856093) ^ Math.imul(y | 0, 19349663)) | 0, seed);
}
// 1차원 값 노이즈 — 정수점 해시를 smooth 로 이어 붙인다
function noise1(x: number, seed: number): number {
const xi = Math.floor(x);
return lerp(hash1(xi, seed), hash1(xi + 1, seed), smooth(x - xi));
}
// 2차원 값 노이즈
function vnoise(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 밤하늘 바탕 — 위가 어둡고 아래가 보랏빛
function drawSky(ctx: CanvasRenderingContext2D, height: number): void {
const g = ctx.createLinearGradient(0, 0, 0, height);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, height);
}
function start(root: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLElement, make: Maker): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function (): void { return; };
}
const ctx: CanvasRenderingContext2D = ctx0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { x: W / 2, y: H / 2, idle: 99999, vx: 0, vy: 0, dpr: 1 };
let lastPx = -1;
let lastPy = -1;
// 화면 배율이 바뀌어도 캔버스 픽셀 수를 다시 맞춘다
function fit(): void {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
inp.dpr = dpr;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fit();
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: any) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
ctx.setTransform(inp.dpr, 0, 0, inp.dpr, 0, 0);
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function tickOnce(): void {
inp.idle += 1;
inp.vx *= 0.9;
inp.vy *= 0.9;
scene.step();
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
tickOnce();
}
}
function refresh(): void {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e: PointerEvent): void {
const r = canvas.getBoundingClientRect();
const nx = clamp((e.clientX - r.left) * W / r.width, 0, W);
const ny = clamp((e.clientY - r.top) * H / r.height, 0, H);
if (lastPx >= 0) {
inp.vx = lerp(inp.vx, nx - lastPx, 0.5);
inp.vy = lerp(inp.vy, ny - lastPy, 0.5);
}
lastPx = nx;
lastPy = ny;
inp.x = nx;
inp.y = ny;
inp.idle = 0;
}
function tick(ts: number): void {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
tickOnce();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
}
show();
listen(btnEl, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e: PointerEvent): void {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e: PointerEvent): void {
lastPx = -1;
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e: KeyboardEvent): void {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.vx = dx * 0.5;
inp.vy = dy * 0.5;
inp.idle = 0;
refresh();
});
listen(window, 'resize', function (): void {
fit();
show();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const TRAIL_LEN = 16;
const GLOW_BLUR = 9;
const FADE_ALPHA = 0.86;
const HEAD_R = 2.1;
const BURST_GAP = 44;
const SPARK_N = 56;
const SPARK_DRAG = 0.972;
const SPARK_GRAVITY = 0.045;
const PREWARM_STEPS = 38;
const TXT_AUTO = '꼬리 이력을 가산 합성으로 겹친다 — 겹친 곳이 흰빛으로 번진다';
const TXT_TAP = '누른 자리에서 빛무리가 터진다';
interface Spark { x: number; y: number; vx: number; vy: number; age: number; life: number; hist: number[]; hue: number; }
// 위치 이력 한 줄에서 i 번째 점의 알파 — 머리에서 멀수록 FADE_ALPHA 를 거듭 곱한다
function trailAlpha(back: number): number {
return Math.pow(FADE_ALPHA, back);
}
function makeScene(inp: Input, reduce: boolean): Scene {
const rng = lcg(4004);
let sparks: Spark[] = [];
let gap = 2;
let colorIdx = 0;
const glows: HTMLCanvasElement[] = [];
function glowFor(idx: number): HTMLCanvasElement {
if (glows[idx]) {
return glows[idx];
}
const size = GLOW_BLUR * 4;
const cv = document.createElement('canvas');
cv.width = size;
cv.height = size;
const c = cv.getContext('2d');
if (c) {
const g = c.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
g.addColorStop(0, rgba(CREAM, 0.9));
g.addColorStop(0.25, rgba(PALETTE[idx], 0.45));
g.addColorStop(1, rgba(PALETTE[idx], 0));
c.fillStyle = g;
c.fillRect(0, 0, size, size);
}
glows[idx] = cv;
return cv;
}
function burst(x: number, y: number): void {
const hue = colorIdx % PALETTE.length;
colorIdx += 1;
for (let i = 0; i < SPARK_N; i += 1) {
const a = rng() * PI2;
const sp = 2.2 + rng() * 2.4;
sparks.push({ x: x, y: y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, age: 0, life: 70 + rng() * 26, hist: [], hue: i % 4 === 0 ? (hue + 1) % PALETTE.length : hue });
}
if (sparks.length > 330) {
sparks.splice(0, sparks.length - 330);
}
}
const scene: Scene = {
step(): void {
gap -= 1;
if (gap <= 0) {
burst(90 + rng() * 300, 80 + rng() * 70);
gap = BURST_GAP;
}
const alive: Spark[] = [];
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
s.age += 1;
s.hist.push(s.x, s.y);
if (s.hist.length > TRAIL_LEN * 2) {
s.hist.splice(0, 2);
}
s.vx *= SPARK_DRAG;
s.vy = s.vy * SPARK_DRAG + SPARK_GRAVITY;
s.x += s.vx;
s.y += s.vy;
if (s.age < s.life) {
alive.push(s);
}
}
sparks = alive;
},
draw(ctx: CanvasRenderingContext2D): void {
drawSky(ctx, H);
// 이 항목의 핵심 — 합성 방식을 가산으로 바꾸면 겹친 곳의 색이 더해져 하얗게 번진다
ctx.globalCompositeOperation = 'lighter';
ctx.lineCap = 'round';
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
const life = Math.pow(1 - s.age / s.life, 0.7);
const n = s.hist.length / 2;
for (let k = 1; k < n; k += 1) {
const a = trailAlpha(n - k) * life;
ctx.strokeStyle = rgba(PALETTE[s.hue], a * 0.85);
ctx.lineWidth = lerp(0.6, HEAD_R * 1.3, k / n);
ctx.beginPath();
ctx.moveTo(s.hist[(k - 1) * 2], s.hist[(k - 1) * 2 + 1]);
ctx.lineTo(s.hist[k * 2], s.hist[k * 2 + 1]);
ctx.stroke();
}
const r = GLOW_BLUR * (0.6 + 0.4 * life);
ctx.globalAlpha = life * (reduce ? 0.6 : 0.85);
ctx.drawImage(glowFor(s.hue), s.x - r, s.y - r, r * 2, r * 2);
ctx.globalAlpha = 1;
ctx.fillStyle = rgba(CREAM, life);
ctx.beginPath();
ctx.arc(s.x, s.y, HEAD_R * (0.5 + 0.5 * life), 0, PI2);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
},
label(): string {
return inp.idle < IDLE_TICKS ? TXT_TAP : TXT_AUTO;
},
tap(): void {
burst(inp.x, inp.y);
}
};
for (let i = 0; i < PREWARM_STEPS; i += 1) {
scene.step();
}
return scene;
}
export default function FwAdditiveTrailHistoryGlow() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return start(root, canvas, statusEl, btnEl, makeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="과거 위치 이력을 잇고 겹칠수록 밝아지는 꼬리와 빛무리" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">꼬리 이력을 가산 합성으로 겹친다 — 겹친 곳이 흰빛으로 번진다</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>과거 위치 이력을 잇고 겹칠수록 밝아지는 꼬리와 빛무리</p>
</div>
</main>
);
}04 과거 위치 이력을 잇고 겹칠수록 밝아지는 꼬리와 빛무리 — 측정 누적면적 41.561% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · css fireworks 9가지 — 쏘아 올려 터지고 물 위에 비치는 불꽃놀이 장면
이 데모가 속한 글
css fireworks 9가지 — 쏘아 올려 터지고 물 위에 비치는 불꽃놀이 장면css fireworks 를 터뜨리려면 불꽃 하나가 아니라 알갱이 수백 개의 궤적부터 정해야 한다 — 01번 구면 분포와 07번 이차 분열이 그 궤적을 계산으로 짠다. 이 글의 데모 전부 보기


