アスキーアート 9種 — 明るさを文字に置き換える段階表から輪郭の記号まで
アスキーアートは絵をどうやって文字に変えるのでしょうか。01番はセルの平均の明るさから濃さの段階表で文字を選び、05番は明るさが変わる向きに合わせてスラッシュや縦線を選び、06番は動く模様をフレームごとに文字として読み直します。
アスキーアートは文字を描くことではなく、セルごとの明るさを読んで文字ひとつで答える計算です — アスキーアートを作るときは絵を格子に分けるところから始まります
01 sc-ascii-ramp-brightness-glyph-density
02 sc-ascii-glyph-cell-aspect-correct
03 sc-ascii-gamma-contrast-curve-tune
04 sc-ascii-ordered-matrix-dither-two-glyph
05 sc-ascii-gradient-edge-direction-slash-bar
06 sc-ascii-plasma-wave-glyph-field-live
07 sc-ascii-sphere-diffuse-shade-rotate
08 sc-ascii-per-glyph-color-palette-tint
09 sc-ascii-hero-wave-banner-scroll-scene
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 セルの明るさから文字の濃さの段階表で選んで打つ段階表
- 02 文字セルが縦に長い比を合わせて円を保つ補正
- 03 ガンマとコントラストの曲線で文字の分布をならす調整
- 04 二文字だけで明暗を表す順序ディザ
- 05 明るさが変わる向きを読んでスラッシュや縦線を選ぶ輪郭
- 06 動くプラズマをフレームごとに文字として読み直す文字盤
- 07 球の表面の光と影を文字の濃さに移す陰影
- 08 セルの平均の色を文字ごとにのせるパレットの着色
- 09 波打つ文字バナーでつくるヒーロー
九つの場面は同じ骨組みで動きます。場面は画面に貼らない二枚目のキャンバスに描き、画面に打たれる文字は、その画素をセルごとに平均した値からだけ出ます。ライブラリ・WebGL・CDNは使わず乱数もなく、場面の時計から出た位相だけで動くので、LAP_S の10秒がたつと継ぎ目なくくり返されます。
場面は隠れたキャンバスに描き、文字は読んだ値でだけ打ちます
setSrc が元のキャンバスの大きさと倍率を決め、readGrid が getImageData を一度呼んで、セルごとに元の画素の平均のr・g・bと明るさを求めます。明るさは (0.2126 r + 0.7152 g + 0.0722 b) / 255 で、文字盤はセルごとに fillText を呼ばず、行の中で同じ色が続く塊を一度に打ちます。書体は等幅のシステム書体の組で、文字の幅は100pxの書体で M の幅を測って比にしています。
一盤は時間表で流れます
デモは場面の時計0.6秒から始まり、フレームの間隔は0.05秒で切るので、タブが隠れて戻っても一気に飛びません。文字盤はキャンバスに描かれるため、アスキーアート コピペ用の平文はコピーボタンかCキーで行末の空白を除いてクリップボードに入れる方式で、貼り付けるときは等幅の書体にするとセルがそろいます。一盤が終わる0.6秒前に、role="status" の段落へ状態の文が一度だけ書かれます。
場面ごとにのせた計算ひと重ね
属性をひとつ動かす代わりに、計算をひと重ねずつのせました。01は明るさを段階表の番号に、02はセルの縦横比を、03はガンマとコントラストの曲線を、04は規則格子ディザのしきい値を、05は明るさの勾配の向きを、06は正弦波四つの和を、07は拡散の陰影を、08は平均の色とパレットの距離を、09は行ごとにずれた位相を計算します。CSSディザの編はCSSで画素を打ち、ジェネラティブアートの編は線とタイルを描き、落ちる文字の編は文字の物理を扱いますが、この編の主題は絵の画素を読んで文字を選ぶ計算です。
アスキーアート 9種 — 明るさを文字の濃さに移す段階表・文字セルの縦横比の補正・ガンマとコントラストの曲線・二文字だけで描く順序ディザ・明るさの向きを読む輪郭の記号・動くプラズマの文字盤・球の陰影の文字・文字ごとに色をのせるパレット・波打つ文字バナーのヒーロー
九つの場面を描画して得た面積・フレーム・強さ
| 場面 | 累積面積 | 動いたフレーム | 強さの平均 |
|---|---|---|---|
| 01 段階表 | 18.8% | 23/23 | 78.5 |
| 02 セルの比 | 10.9% | 23/23 | 67.1 |
| 03 ガンマ曲線 | 32.5% | 23/23 | 65.5 |
| 04 順序ディザ | 13.0% | 23/23 | 103.3 |
| 05 向きの記号 | 15.0% | 23/23 | 75.2 |
| 06 プラズマ | 39.8% | 23/23 | 60.9 |
| 07 球の陰影 | 15.6% | 23/23 | 73.9 |
| 08 色の着色 | 45.1% | 23/23 | 61.2 |
| 09 波のバナー | 28.8% | 23/23 | 72.4 |
08が45.1%でもっとも広く、02が10.9%でもっとも狭く、二文字だけで描く04は面積が13.0%なのに強さの平均が103.3で最大です。九つとも23/23で動いているので、止まった絵として読める場面はありません。
01セルの明るさから文字の濃さの段階表で選んで打つ段階表
画像を文字に変換する画面の基本の仕組みで、readGrid が38×20のセルごとに平均の明るさを求め、glyphIndex がその明るさを段階表の番号に変えて文字をひとつ選びます。左は10段階の段階表、右は rampOf(2) で空白と @ だけを残して中間の明るさが消える対比版です。段階表を5段階にすると一つの場面で平均5種、10段階で10種、16段階で15種の文字が使われます。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="アスキーアートの舞台 — Enter か Space で一時停止、N で一歩、R でやり直し、C で文字盤をコピー">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="弧を描いて渡る太陽、息づく輪、下を流れる波の帯の場面を格子のセルに分け、左の盤はセルの平均の明るさを十段階の文字の段階表で打ち、右の盤は同じ平均を二文字だけで読んで中間の明るさが消えています"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">アスキーアートのデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
<button class="vj__copy" data-role="copy" type="button">コピー</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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-cream;
}
// 무대 상자 — 여기서 시작한 터치만 칸 누르기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step,
.vj__copy {
padding: $sp-1 $sp-2;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.vj__step,
.vj__copy {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover,
.vj__copy:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active,
.vj__copy:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible,
.vj__copy:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 214px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__copy,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step,
.vj__copy { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active,
.vj__copy:hover,
.vj__copy:active { transform: none !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);
}
.vj {
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: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step,
.vj__copy {
padding: 4px 8px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__step,
.vj__copy {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover,
.vj__copy:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active,
.vj__copy:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible,
.vj__copy:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 214px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__copy,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step,
.vj__copy {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active,
.vj__copy:hover,
.vj__copy:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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(a, b, v) { const k = clamp((v - a) / (b - a), 0, 1); return k * k * (3 - 2 * k); }
// 파스텔 팔레트 — 글자 판은 짙은 남보라 바탕 위에 옅은 라벤더 글자(대비 12 이상), 바깥 무대는 크림 라벤더
const PAGE = [246, 243, 252];
const BOARD = [34, 30, 56];
const BOARD_EDGE = [74, 68, 112];
const GLYPH = [226, 219, 250];
const MINT = [168, 226, 204];
const ROSE = [248, 176, 198];
const BUTTER = [250, 226, 150];
const SKY = [170, 208, 248];
const LAV = [196, 184, 244];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function grayStr(l, a) {
const v = Math.round(clamp(l, 0, 1) * 255);
return 'rgba(' + v + ',' + v + ',' + v + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초) — 한 바퀴 LAP_S 초이고 장면의 모든 움직임은 위상 ph = sceneT / LAP_S 의 정수배라 바퀴 끝에서 이음매가 없다
let sceneT = 0;
let lap = 0;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 고정폭 글꼴 목록만
const MONO = 'ui-monospace, "Cascadia Mono", Consolas, Menlo, "DejaVu Sans Mono", "Courier New", monospace';
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
// 글자 한 줄 높이 = 글자 크기의 1.2 배. 폭은 글꼴에서 잰 값(advRatio)을 쓴다
const LINE_K = 1.2;
let advRatio = 0.6;
// ── 원본 장면 캔버스 — 화면에 붙이지 않는 두 번째 캔버스. 장면은 여기 그리고 화소를 읽는다 ──
const srcCv = document.createElement('canvas');
const srcRaw = srcCv.getContext('2d', { willReadFrequently: true });
if (!srcRaw) {
return;
}
const sctx = srcRaw;
// ss = 원본 화소 / 판 화소. 1 이면 판 크기 그대로, 0.5 면 가로세로 절반 해상도로 계산해 읽는다
let srcSs = 1;
function setSrc(w, h, ss) {
const sw = Math.round(w * ss);
const sh = Math.round(h * ss);
if (srcCv.width !== sw || srcCv.height !== sh) {
srcCv.width = sw;
srcCv.height = sh;
}
srcSs = ss;
sctx.setTransform(ss, 0, 0, ss, 0, 0);
}
// cols×rows 칸을 왼쪽 위부터 읽는다. 칸 하나가 원본에서 덮는 영역은 가로 cw, 세로 sh (판 화소 단위)
function readGrid(cols, rows, cw, sh) {
const sw = srcCv.width;
const shp = srcCv.height;
const data = sctx.getImageData(0, 0, sw, shp).data;
const grid = { cols: cols, rows: rows, cw: cw, sh: sh, lum: [], r: [], g: [], b: [] };
for (let cy = 0; cy < rows; cy += 1) {
const y0 = Math.min(shp - 1, Math.floor(cy * sh * srcSs));
const y1 = Math.max(y0 + 1, Math.min(shp, Math.floor((cy + 1) * sh * srcSs)));
for (let cx = 0; cx < cols; cx += 1) {
const x0 = Math.min(sw - 1, Math.floor(cx * cw * srcSs));
const x1 = Math.max(x0 + 1, Math.min(sw, Math.floor((cx + 1) * cw * srcSs)));
let sr = 0;
let sg = 0;
let sb = 0;
let n = 0;
for (let y = y0; y < y1; y += 1) {
let p = (y * sw + x0) * 4;
for (let x = x0; x < x1; x += 1) {
sr += data[p];
sg += data[p + 1];
sb += data[p + 2];
n += 1;
p += 4;
}
}
const mr = sr / n;
const mg = sg / n;
const mb = sb / n;
grid.r.push(mr);
grid.g.push(mg);
grid.b.push(mb);
grid.lum.push((0.2126 * mr + 0.7152 * mg + 0.0722 * mb) / 255);
}
}
return grid;
}
// ── 글자 램프 — 성긴 글자에서 짙은 글자 순서 열여섯 개. n 단계 램프는 이 줄에서 고르게 n 개를 뽑는다 ──
const RAMP_FULL = ' .,:;-~=+*ox#%&@';
function rampOf(n) {
let s = '';
for (let i = 0; i < n; i += 1) {
s += RAMP_FULL.charAt(Math.round(i * (RAMP_FULL.length - 1) / (n - 1)));
}
return s;
}
// 밝기 v(0 에서 1)를 램프 번호로 — 단계 폭 1/n 으로 나눠 내림
function glyphIndex(v, n) { return Math.min(n - 1, Math.max(0, Math.floor(v * n))); }
// 칸 크기 — 글자 크기 fp 를 주면 폭은 글꼴에서 잰 글자 폭, 높이는 줄 높이
function cellW(fp) { return advRatio * fp; }
function cellH(fp) { return LINE_K * fp; }
function measureAdv() {
ctx.font = '100px ' + MONO;
const m = ctx.measureText('M').width / 100;
advRatio = m > 0.3 && m < 0.9 ? m : 0.6;
}
// ── 그리기 도구 ──
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
function paintPage() {
ctx.fillStyle = rgba(PAGE, 1);
ctx.fillRect(0, 0, W, H);
}
// 글자 판 바탕 — 짙은 남보라 둥근 사각형
function panel(x, y, w, h) {
box(x - 1, y - 1, w + 2, h + 2, 11, BOARD_EDGE, 1);
box(x, y, w, h, 10, BOARD, 1);
}
// 글자 판 그리기 — chars 는 칸마다 글자 하나(왼쪽 위부터 줄 순서). colorIdx 가 있으면 칸마다 팔레트 번호, 없으면 모두 pal[0].
// 같은 색이 이어지는 칸은 한 번에 찍어 한 판에 fillText 가 줄 수 × 색 조각 수만큼만 불린다
function drawGlyphs(x, y, fp, g, chars, colorIdx, pal) {
const cw = cellW(fp);
const ch = cellH(fp);
ctx.font = '600 ' + fp + 'px ' + MONO;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
for (let r = 0; r < g.rows; r += 1) {
let c = 0;
while (c < g.cols) {
const ci = colorIdx ? colorIdx[r * g.cols + c] : 0;
let run = '';
let e = c;
while (e < g.cols && (colorIdx ? colorIdx[r * g.cols + e] : 0) === ci) {
run += chars[r * g.cols + e];
e += 1;
}
if (run.trim().length > 0) {
ctx.fillStyle = rgba(pal[ci], 1);
ctx.fillText(run, x + c * cw, y + (r + 0.5) * ch + 0.5);
}
c = e;
}
}
}
// 판의 글자를 줄바꿈으로 이은 텍스트 — コピー 단추가 이 글을 클립보드에 넣는다
function plainOf(g, chars) {
const lines = [];
for (let r = 0; r < g.rows; r += 1) {
lines.push(chars.slice(r * g.cols, (r + 1) * g.cols).join('').replace(/\s+$/, ''));
}
return lines.join('\n');
}
// 쓰인 서로 다른 글자 수(공백 포함)와 글자별 횟수
function usage(chars, ramp) {
const out = [];
for (let i = 0; i < ramp.length; i += 1) {
out.push(0);
}
for (const c of chars) {
const k = ramp.indexOf(c);
if (k >= 0) {
out[k] += 1;
}
}
return out;
}
function kinds(u) {
let n = 0;
for (const v of u) {
if (v > 0) {
n += 1;
}
}
return n;
}
// 글자별 사용 횟수 막대 — 판 아래 띠에 램프 순서대로
function usageBars(x, y, w, h, u, c) {
let mx = 1;
for (const v of u) {
if (v > mx) {
mx = v;
}
}
const bw = w / u.length;
for (let i = 0; i < u.length; i += 1) {
const bh = Math.max(u[i] > 0 ? 2 : 0, h * u[i] / mx);
box(x + i * bw + 1, y + h - bh, bw - 2, bh, 2, c, 0.95);
}
}
// 두 번째 캔버스에 쓰는 회색 장면 도구 — 중심에서 번지는 둥근 빛
function glow(x, y, r, l, a) {
const g = sctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, grayStr(l, a));
g.addColorStop(1, grayStr(l, 0));
sctx.fillStyle = g;
sctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function vgrad(w, h, l0, l1) {
const g = sctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, grayStr(l0, 1));
g.addColorStop(1, grayStr(l1, 1));
sctx.fillStyle = g;
sctx.fillRect(0, 0, w, h);
}
// 판 배치 — 위 46 은 상태 줄·단추 자리. 판 둘이면 가로 228, 하나면 464
const PY = 46;
const PH = 246;
const PW2 = 228;
const PW1 = 464;
const PX_L = 8;
const PX_R = 244;
// コピー 단추를 누른 뒤 상태 줄에 잠깐 뜨는 말
const COPY_OK = '文字盤をテキストでコピーしました';
const COPY_FAIL = 'コピーできませんでした — 文字盤を選んで手でコピーしてください';
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const copyEl = root.querySelector('[data-role="copy"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !copyEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const copyBtn = copyEl;
const doneP = doneEl;
const pad = padEl;
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
measureAdv();
}
// 지금 화면에 찍힌 글자 판 — コピー 단추가 이 텍스트를 넣는다
let plainText = '';
let copyFlash = 0;
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
if (copyFlash > 0) {
copyFlash = Math.max(0, copyFlash - dt);
}
if (sceneT >= LAP_S) {
sceneT -= LAP_S;
lap += 1;
}
}
const DT_MAX = 0.05;
const STEP_S = 0.4;
function showNow() {
draw();
pushStatus(copyFlash > 0 ? copyNote : statusText());
checkDone();
}
let copyNote = '';
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 장면이 한창인 순간의 정지 장면 한 장
function showRest() {
stopLoop();
sceneT = REST_S;
showNow();
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
function restart() {
wake();
sceneT = START_S;
lap = 0;
doneSaid = false;
showNow();
}
// 一歩進める — 멈춘 채 장면 시계를 0.4 초 앞으로
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
advance(STEP_S);
showNow();
}
// コピー — 화면에 있는 글자 판을 텍스트로 클립보드에. 임시 입력칸을 만들어 선택하고 コピー 명령을 부른다
function copyBoard() {
wake();
const ta = document.createElement('textarea');
ta.value = plainText;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
let ok = false;
try {
ok = document.execCommand('copy');
}
catch (err) {
ok = false;
}
document.body.removeChild(ta);
copyNote = ok ? COPY_OK : COPY_FAIL;
copyFlash = 1.6;
pushStatus(copyNote);
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
copyBtn.addEventListener('click', copyBoard, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space·P 는 一時停止, N 은 一歩進める, R 은 처음부터, C 는 글자 판 コピー
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
}
else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
}
else if (k === 'r' || k === 'R') {
e.preventDefault();
restart();
}
else if (k === 'c' || k === 'C') {
e.preventDefault();
copyBoard();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 一時停止 토글
pad.addEventListener('click', function () { togglePause(); }, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
sceneT = START_S;
showNow();
if (!paused) {
startLoop();
}
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 01 칸의 밝기를 글자 농도 줄에서 골라 찍는 램프 ───────────────────────
const LAP_S = 10;
const START_S = 0.6;
const REST_S = 3.1;
// 램프 길이 — 왼쪽 판이 쓰는 글자 수. 오른쪽 판은 같은 칸 평균을 두 글자 램프로 읽는 대비판이다
const RAMP_LEN = 10;
const FONT_PX = 10;
const RAMP = rampOf(RAMP_LEN);
const RAMP_2 = rampOf(2);
let lastKinds = 0;
let lastKinds2 = 0;
let lastCols = 0;
let lastRows = 0;
// 원본 장면 — 해가 호를 그리며 지나가고 고리가 숨 쉬며 아래에 물결 띠가 흐른다. 밝기 범위 전체를 쓴다
function paintScene(w, h, a) {
vgrad(w, h, 0.08, 0.34);
glow(w * (0.5 + 0.3 * Math.cos(a)), h * (0.36 + 0.16 * Math.sin(a)), 84, 1, 0.9);
sctx.beginPath();
sctx.arc(w * (0.5 + 0.3 * Math.cos(a)), h * (0.36 + 0.16 * Math.sin(a)), 21, 0, TAU);
sctx.fillStyle = grayStr(0.97, 1);
sctx.fill();
sctx.beginPath();
sctx.arc(w * 0.5, h * 0.64, 40 + 10 * Math.sin(2 * a), 0, TAU);
sctx.lineWidth = 11;
sctx.strokeStyle = grayStr(0.78, 0.9);
sctx.stroke();
for (let k = 0; k < 3; k += 1) {
sctx.beginPath();
for (let x = 0; x <= w; x += 4) {
const y = h * 0.84 + k * 13 + 6 * Math.sin(x * 0.05 + a * (k + 1) + k);
if (x === 0) {
sctx.moveTo(x, y);
}
else {
sctx.lineTo(x, y);
}
}
sctx.lineTo(w, h);
sctx.lineTo(0, h);
sctx.closePath();
sctx.fillStyle = grayStr(0.46 - 0.12 * k, 0.9);
sctx.fill();
}
}
function draw() {
paintPage();
const a = sceneT / LAP_S * TAU;
setSrc(PW2, PH, 1);
paintScene(PW2, PH, a);
const cw = cellW(FONT_PX);
const ch = cellH(FONT_PX);
const cols = Math.floor(PW2 / cw);
const rows = Math.floor(PH / ch);
const g = readGrid(cols, rows, cw, ch);
const c1 = [];
const c2 = [];
for (let i = 0; i < g.lum.length; i += 1) {
c1.push(RAMP.charAt(glyphIndex(g.lum[i], RAMP_LEN)));
c2.push(RAMP_2.charAt(glyphIndex(g.lum[i], 2)));
}
lastKinds = kinds(usage(c1, RAMP));
lastKinds2 = kinds(usage(c2, RAMP_2));
lastCols = cols;
lastRows = rows;
plainText = plainOf(g, c1);
const ox = (PW2 - cols * cw) / 2;
const oy = (PH - rows * ch) / 2;
panel(PX_L, PY, PW2, PH);
drawGlyphs(PX_L + ox, PY + oy, FONT_PX, g, c1, null, [GLYPH]);
panel(PX_R, PY, PW2, PH);
drawGlyphs(PX_R + ox, PY + oy, FONT_PX, g, c2, null, [GLYPH]);
tag('段階表 {n}段'.replace('{n}', String(RAMP_LEN)), PX_L + 8, PY + 14, PAPER, INK, 10, 'left');
tag('段階表 {n}段 (対比)'.replace('{n}', '2'), PX_R + 8, PY + 14, PAPER, INK, 10, 'left');
}
function statusText() {
return '段階表 {n}段・文字 {k}種・{c}×{r}セル'.replace('{n}', String(RAMP_LEN)).replace('{k}', String(lastKinds)).replace('{c}', String(lastCols)).replace('{r}', String(lastRows));
}
fitCanvas();
if (still) {
showRest();
}
else {
sceneT = START_S;
showNow();
startLoop();
}
return function () {
stopLoop();
off.abort();
};
})();// RampBrightnessGlyph - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./RampBrightnessGlyph.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function RampBrightnessGlyph() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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(a: number, b: number, v: number): number { const k = clamp((v - a) / (b - a), 0, 1); return k * k * (3 - 2 * k); }
// 파스텔 팔레트 — 글자 판은 짙은 남보라 바탕 위에 옅은 라벤더 글자(대비 12 이상), 바깥 무대는 크림 라벤더
const PAGE: RGB = [246, 243, 252];
const BOARD: RGB = [34, 30, 56];
const BOARD_EDGE: RGB = [74, 68, 112];
const GLYPH: RGB = [226, 219, 250];
const MINT: RGB = [168, 226, 204];
const ROSE: RGB = [248, 176, 198];
const BUTTER: RGB = [250, 226, 150];
const SKY: RGB = [170, 208, 248];
const LAV: RGB = [196, 184, 244];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function grayStr(l: number, a: number): string {
const v = Math.round(clamp(l, 0, 1) * 255);
return 'rgba(' + v + ',' + v + ',' + v + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초) — 한 바퀴 LAP_S 초이고 장면의 모든 움직임은 위상 ph = sceneT / LAP_S 의 정수배라 바퀴 끝에서 이음매가 없다
let sceneT = 0;
let lap = 0;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 고정폭 글꼴 목록만
const MONO = 'ui-monospace, "Cascadia Mono", Consolas, Menlo, "DejaVu Sans Mono", "Courier New", monospace';
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
// 글자 한 줄 높이 = 글자 크기의 1.2 배. 폭은 글꼴에서 잰 값(advRatio)을 쓴다
const LINE_K = 1.2;
let advRatio = 0.6;
// ── 원본 장면 캔버스 — 화면에 붙이지 않는 두 번째 캔버스. 장면은 여기 그리고 화소를 읽는다 ──
const srcCv: HTMLCanvasElement = document.createElement('canvas');
const srcRaw = srcCv.getContext('2d', { willReadFrequently: true });
if (!srcRaw) { return; }
const sctx: CanvasRenderingContext2D = srcRaw;
// ss = 원본 화소 / 판 화소. 1 이면 판 크기 그대로, 0.5 면 가로세로 절반 해상도로 계산해 읽는다
let srcSs = 1;
function setSrc(w: number, h: number, ss: number): void {
const sw = Math.round(w * ss);
const sh = Math.round(h * ss);
if (srcCv.width !== sw || srcCv.height !== sh) {
srcCv.width = sw;
srcCv.height = sh;
}
srcSs = ss;
sctx.setTransform(ss, 0, 0, ss, 0, 0);
}
// 칸 격자 — 칸마다 원본 화소의 평균 밝기(lum, 0 에서 1)와 평균 색을 담는다
type Grid = { cols: number; rows: number; cw: number; sh: number; lum: number[]; r: number[]; g: number[]; b: number[] };
// cols×rows 칸을 왼쪽 위부터 읽는다. 칸 하나가 원본에서 덮는 영역은 가로 cw, 세로 sh (판 화소 단위)
function readGrid(cols: number, rows: number, cw: number, sh: number): Grid {
const sw = srcCv.width;
const shp = srcCv.height;
const data = sctx.getImageData(0, 0, sw, shp).data;
const grid: Grid = { cols: cols, rows: rows, cw: cw, sh: sh, lum: [], r: [], g: [], b: [] };
for (let cy = 0; cy < rows; cy += 1) {
const y0 = Math.min(shp - 1, Math.floor(cy * sh * srcSs));
const y1 = Math.max(y0 + 1, Math.min(shp, Math.floor((cy + 1) * sh * srcSs)));
for (let cx = 0; cx < cols; cx += 1) {
const x0 = Math.min(sw - 1, Math.floor(cx * cw * srcSs));
const x1 = Math.max(x0 + 1, Math.min(sw, Math.floor((cx + 1) * cw * srcSs)));
let sr = 0;
let sg = 0;
let sb = 0;
let n = 0;
for (let y = y0; y < y1; y += 1) {
let p = (y * sw + x0) * 4;
for (let x = x0; x < x1; x += 1) {
sr += data[p];
sg += data[p + 1];
sb += data[p + 2];
n += 1;
p += 4;
}
}
const mr = sr / n;
const mg = sg / n;
const mb = sb / n;
grid.r.push(mr);
grid.g.push(mg);
grid.b.push(mb);
grid.lum.push((0.2126 * mr + 0.7152 * mg + 0.0722 * mb) / 255);
}
}
return grid;
}
// ── 글자 램프 — 성긴 글자에서 짙은 글자 순서 열여섯 개. n 단계 램프는 이 줄에서 고르게 n 개를 뽑는다 ──
const RAMP_FULL = ' .,:;-~=+*ox#%&@';
function rampOf(n: number): string {
let s = '';
for (let i = 0; i < n; i += 1) { s += RAMP_FULL.charAt(Math.round(i * (RAMP_FULL.length - 1) / (n - 1))); }
return s;
}
// 밝기 v(0 에서 1)를 램프 번호로 — 단계 폭 1/n 으로 나눠 내림
function glyphIndex(v: number, n: number): number { return Math.min(n - 1, Math.max(0, Math.floor(v * n))); }
// 칸 크기 — 글자 크기 fp 를 주면 폭은 글꼴에서 잰 글자 폭, 높이는 줄 높이
function cellW(fp: number): number { return advRatio * fp; }
function cellH(fp: number): number { return LINE_K * fp; }
function measureAdv(): void {
ctx.font = '100px ' + MONO;
const m = ctx.measureText('M').width / 100;
advRatio = m > 0.3 && m < 0.9 ? m : 0.6;
}
// ── 그리기 도구 ──
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
function paintPage(): void {
ctx.fillStyle = rgba(PAGE, 1);
ctx.fillRect(0, 0, W, H);
}
// 글자 판 바탕 — 짙은 남보라 둥근 사각형
function panel(x: number, y: number, w: number, h: number): void {
box(x - 1, y - 1, w + 2, h + 2, 11, BOARD_EDGE, 1);
box(x, y, w, h, 10, BOARD, 1);
}
// 글자 판 그리기 — chars 는 칸마다 글자 하나(왼쪽 위부터 줄 순서). colorIdx 가 있으면 칸마다 팔레트 번호, 없으면 모두 pal[0].
// 같은 색이 이어지는 칸은 한 번에 찍어 한 판에 fillText 가 줄 수 × 색 조각 수만큼만 불린다
function drawGlyphs(x: number, y: number, fp: number, g: Grid, chars: string[], colorIdx: number[] | null, pal: RGB[]): void {
const cw = cellW(fp);
const ch = cellH(fp);
ctx.font = '600 ' + fp + 'px ' + MONO;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
for (let r = 0; r < g.rows; r += 1) {
let c = 0;
while (c < g.cols) {
const ci = colorIdx ? colorIdx[r * g.cols + c] : 0;
let run = '';
let e = c;
while (e < g.cols && (colorIdx ? colorIdx[r * g.cols + e] : 0) === ci) { run += chars[r * g.cols + e]; e += 1; }
if (run.trim().length > 0) {
ctx.fillStyle = rgba(pal[ci], 1);
ctx.fillText(run, x + c * cw, y + (r + 0.5) * ch + 0.5);
}
c = e;
}
}
}
// 판의 글자를 줄바꿈으로 이은 텍스트 — 복사 단추가 이 글을 클립보드에 넣는다
function plainOf(g: Grid, chars: string[]): string {
const lines: string[] = [];
for (let r = 0; r < g.rows; r += 1) {
lines.push(chars.slice(r * g.cols, (r + 1) * g.cols).join('').replace(/\s+$/, ''));
}
return lines.join('\n');
}
// 쓰인 서로 다른 글자 수(공백 포함)와 글자별 횟수
function usage(chars: string[], ramp: string): number[] {
const out: number[] = [];
for (let i = 0; i < ramp.length; i += 1) { out.push(0); }
for (const c of chars) {
const k = ramp.indexOf(c);
if (k >= 0) { out[k] += 1; }
}
return out;
}
function kinds(u: number[]): number {
let n = 0;
for (const v of u) { if (v > 0) { n += 1; } }
return n;
}
// 글자별 사용 횟수 막대 — 판 아래 띠에 램프 순서대로
function usageBars(x: number, y: number, w: number, h: number, u: number[], c: RGB): void {
let mx = 1;
for (const v of u) { if (v > mx) { mx = v; } }
const bw = w / u.length;
for (let i = 0; i < u.length; i += 1) {
const bh = Math.max(u[i] > 0 ? 2 : 0, h * u[i] / mx);
box(x + i * bw + 1, y + h - bh, bw - 2, bh, 2, c, 0.95);
}
}
// 두 번째 캔버스에 쓰는 회색 장면 도구 — 중심에서 번지는 둥근 빛
function glow(x: number, y: number, r: number, l: number, a: number): void {
const g = sctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, grayStr(l, a));
g.addColorStop(1, grayStr(l, 0));
sctx.fillStyle = g;
sctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function vgrad(w: number, h: number, l0: number, l1: number): void {
const g = sctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, grayStr(l0, 1));
g.addColorStop(1, grayStr(l1, 1));
sctx.fillStyle = g;
sctx.fillRect(0, 0, w, h);
}
// 판 배치 — 위 46 은 상태 줄·단추 자리. 판 둘이면 가로 228, 하나면 464
const PY = 46;
const PH = 246;
const PW2 = 228;
const PW1 = 464;
const PX_L = 8;
const PX_R = 244;
// 복사 단추를 누른 뒤 상태 줄에 잠깐 뜨는 말
const COPY_OK = '글자 판을 텍스트로 복사했다';
const COPY_FAIL = '복사하지 못했다 — 글자 판을 직접 선택해 복사';
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const copyEl = root.querySelector('[data-role="copy"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !copyEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const copyBtn: HTMLButtonElement = copyEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
measureAdv();
}
// 지금 화면에 찍힌 글자 판 — 복사 단추가 이 텍스트를 넣는다
let plainText = '';
let copyFlash = 0;
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
if (copyFlash > 0) { copyFlash = Math.max(0, copyFlash - dt); }
if (sceneT >= LAP_S) { sceneT -= LAP_S; lap += 1; }
}
const DT_MAX = 0.05;
const STEP_S = 0.4;
function showNow(): void {
draw();
pushStatus(copyFlash > 0 ? copyNote : statusText());
checkDone();
}
let copyNote = '';
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 장면이 한창인 순간의 정지 장면 한 장
function showRest(): void {
stopLoop();
sceneT = REST_S;
showNow();
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
function restart(): void {
wake();
sceneT = START_S;
lap = 0;
doneSaid = false;
showNow();
}
// 한 걸음 — 멈춘 채 장면 시계를 0.4 초 앞으로
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
advance(STEP_S);
showNow();
}
// 복사 — 화면에 있는 글자 판을 텍스트로 클립보드에. 임시 입력칸을 만들어 선택하고 복사 명령을 부른다
function copyBoard(): void {
wake();
const ta = document.createElement('textarea');
ta.value = plainText;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
let ok = false;
try { ok = document.execCommand('copy'); } catch (err) { ok = false; }
document.body.removeChild(ta);
copyNote = ok ? COPY_OK : COPY_FAIL;
copyFlash = 1.6;
pushStatus(copyNote);
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
copyBtn.addEventListener('click', copyBoard, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space·P 는 일시정지, N 은 한 걸음, R 은 처음부터, C 는 글자 판 복사
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
} else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
} else if (k === 'r' || k === 'R') {
e.preventDefault();
restart();
} else if (k === 'c' || k === 'C') {
e.preventDefault();
copyBoard();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 일시정지 토글
pad.addEventListener('click', function (): void { togglePause(); }, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { sceneT = START_S; showNow(); if (!paused) { startLoop(); } }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 01 칸의 밝기를 글자 농도 줄에서 골라 찍는 램프 ───────────────────────
const LAP_S = 10;
const START_S = 0.6;
const REST_S = 3.1;
// 램프 길이 — 왼쪽 판이 쓰는 글자 수. 오른쪽 판은 같은 칸 평균을 두 글자 램프로 읽는 대비판이다
const RAMP_LEN = 10;
const FONT_PX = 10;
const RAMP = rampOf(RAMP_LEN);
const RAMP_2 = rampOf(2);
let lastKinds = 0;
let lastKinds2 = 0;
let lastCols = 0;
let lastRows = 0;
// 원본 장면 — 해가 호를 그리며 지나가고 고리가 숨 쉬며 아래에 물결 띠가 흐른다. 밝기 범위 전체를 쓴다
function paintScene(w: number, h: number, a: number): void {
vgrad(w, h, 0.08, 0.34);
glow(w * (0.5 + 0.3 * Math.cos(a)), h * (0.36 + 0.16 * Math.sin(a)), 84, 1, 0.9);
sctx.beginPath();
sctx.arc(w * (0.5 + 0.3 * Math.cos(a)), h * (0.36 + 0.16 * Math.sin(a)), 21, 0, TAU);
sctx.fillStyle = grayStr(0.97, 1);
sctx.fill();
sctx.beginPath();
sctx.arc(w * 0.5, h * 0.64, 40 + 10 * Math.sin(2 * a), 0, TAU);
sctx.lineWidth = 11;
sctx.strokeStyle = grayStr(0.78, 0.9);
sctx.stroke();
for (let k = 0; k < 3; k += 1) {
sctx.beginPath();
for (let x = 0; x <= w; x += 4) {
const y = h * 0.84 + k * 13 + 6 * Math.sin(x * 0.05 + a * (k + 1) + k);
if (x === 0) { sctx.moveTo(x, y); } else { sctx.lineTo(x, y); }
}
sctx.lineTo(w, h);
sctx.lineTo(0, h);
sctx.closePath();
sctx.fillStyle = grayStr(0.46 - 0.12 * k, 0.9);
sctx.fill();
}
}
function draw(): void {
paintPage();
const a = sceneT / LAP_S * TAU;
setSrc(PW2, PH, 1);
paintScene(PW2, PH, a);
const cw = cellW(FONT_PX);
const ch = cellH(FONT_PX);
const cols = Math.floor(PW2 / cw);
const rows = Math.floor(PH / ch);
const g = readGrid(cols, rows, cw, ch);
const c1: string[] = [];
const c2: string[] = [];
for (let i = 0; i < g.lum.length; i += 1) {
c1.push(RAMP.charAt(glyphIndex(g.lum[i], RAMP_LEN)));
c2.push(RAMP_2.charAt(glyphIndex(g.lum[i], 2)));
}
lastKinds = kinds(usage(c1, RAMP));
lastKinds2 = kinds(usage(c2, RAMP_2));
lastCols = cols;
lastRows = rows;
plainText = plainOf(g, c1);
const ox = (PW2 - cols * cw) / 2;
const oy = (PH - rows * ch) / 2;
panel(PX_L, PY, PW2, PH);
drawGlyphs(PX_L + ox, PY + oy, FONT_PX, g, c1, null, [GLYPH]);
panel(PX_R, PY, PW2, PH);
drawGlyphs(PX_R + ox, PY + oy, FONT_PX, g, c2, null, [GLYPH]);
tag('램프 {n}단계'.replace('{n}', String(RAMP_LEN)), PX_L + 8, PY + 14, PAPER, INK, 10, 'left');
tag('램프 {n}단계 (대비)'.replace('{n}', '2'), PX_R + 8, PY + 14, PAPER, INK, 10, 'left');
}
function statusText(): string {
return '램프 {n}단계 · 글자 {k}종 · {c}×{r}칸'.replace('{n}', String(RAMP_LEN)).replace('{k}', String(lastKinds)).replace('{c}', String(lastCols)).replace('{r}', String(lastRows));
}
fitCanvas();
if (still) { showRest(); } else { sceneT = START_S; showNow(); startLoop(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'아스키 아트 시연 무대 — Enter 나 Space 로 일시정지, N 으로 한 걸음, R 로 처음부터, C 로 글자 판 복사'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'해가 호를 그리며 지나가고 고리가 숨 쉬며 아래에 물결 띠가 흐르는 장면을 격자 칸으로 나눠 칸의 평균 밝기를 열 단계 글자 램프로 찍은 왼쪽 판과, 같은 칸 평균을 두 글자로만 읽어 중간 밝기가 사라진 오른쪽 판'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'아스키 아트 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
<button className={cx("vj__copy")} data-role="copy" type="button">{'복사'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}01 · セルの明るさから文字の濃さの段階表で選んで打つ段階表 — 計測 累積面積 18.842% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
const cw = cellW(FONT_PX);
const ch = cellH(FONT_PX);
const cols = Math.floor(PW2 / cw);
const rows = Math.floor(PH / ch);
const g = readGrid(cols, rows, cw, ch);
const c1 = [];
const c2 = [];
for (let i = 0; i < g.lum.length; i += 1) {
c1.push(RAMP.charAt(glyphIndex(g.lum[i], RAMP_LEN)));
c2.push(RAMP_2.charAt(glyphIndex(g.lum[i], 2)));
}
lastKinds = kinds(usage(c1, RAMP));
lastKinds2 = kinds(usage(c2, RAMP_2));
lastCols = cols;
lastRows = rows;
plainText = plainOf(g, c1);
02文字セルが縦に長い比を合わせて円を保つ補正
比率を保ちたいロゴ・顔・円形の図案を文字にするとき、まず引っかかるのが文字セルの形です。セルは横5.9px縦12.0pxで縦が約2.05倍長く、正方形で読むと絵が縦に伸びます。補正した盤は読むセルの縦を GLYPH_RATIO 倍にとって半径40pxの円を80px × 80pxで打ち、右の失敗版は同じ20行で元の上半分だけを読み、同じ円が80px × 約164pxの楕円になります。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="アスキーアートの舞台 — Enter か Space で一時停止、N で一歩、R でやり直し、C で文字盤をコピー">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="場面の上半分で円と正方形が左右に揺れます。左の盤は読むセルの縦を文字の幅と高さの比だけ伸ばして円が丸いまま、右の盤は正方形のセルで読んで同じ円が縦長の楕円になります"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">アスキーアートのデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
<button class="vj__copy" data-role="copy" type="button">コピー</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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-cream;
}
// 무대 상자 — 여기서 시작한 터치만 칸 누르기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step,
.vj__copy {
padding: $sp-1 $sp-2;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.vj__step,
.vj__copy {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover,
.vj__copy:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active,
.vj__copy:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible,
.vj__copy:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 214px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__copy,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step,
.vj__copy { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active,
.vj__copy:hover,
.vj__copy:active { transform: none !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);
}
.vj {
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: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step,
.vj__copy {
padding: 4px 8px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__step,
.vj__copy {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover,
.vj__copy:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active,
.vj__copy:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible,
.vj__copy:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 214px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__copy,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step,
.vj__copy {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active,
.vj__copy:hover,
.vj__copy:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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(a, b, v) { const k = clamp((v - a) / (b - a), 0, 1); return k * k * (3 - 2 * k); }
// 파스텔 팔레트 — 글자 판은 짙은 남보라 바탕 위에 옅은 라벤더 글자(대비 12 이상), 바깥 무대는 크림 라벤더
const PAGE = [246, 243, 252];
const BOARD = [34, 30, 56];
const BOARD_EDGE = [74, 68, 112];
const GLYPH = [226, 219, 250];
const MINT = [168, 226, 204];
const ROSE = [248, 176, 198];
const BUTTER = [250, 226, 150];
const SKY = [170, 208, 248];
const LAV = [196, 184, 244];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function grayStr(l, a) {
const v = Math.round(clamp(l, 0, 1) * 255);
return 'rgba(' + v + ',' + v + ',' + v + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초) — 한 바퀴 LAP_S 초이고 장면의 모든 움직임은 위상 ph = sceneT / LAP_S 의 정수배라 바퀴 끝에서 이음매가 없다
let sceneT = 0;
let lap = 0;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 고정폭 글꼴 목록만
const MONO = 'ui-monospace, "Cascadia Mono", Consolas, Menlo, "DejaVu Sans Mono", "Courier New", monospace';
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
// 글자 한 줄 높이 = 글자 크기의 1.2 배. 폭은 글꼴에서 잰 값(advRatio)을 쓴다
const LINE_K = 1.2;
let advRatio = 0.6;
// ── 원본 장면 캔버스 — 화면에 붙이지 않는 두 번째 캔버스. 장면은 여기 그리고 화소를 읽는다 ──
const srcCv = document.createElement('canvas');
const srcRaw = srcCv.getContext('2d', { willReadFrequently: true });
if (!srcRaw) {
return;
}
const sctx = srcRaw;
// ss = 원본 화소 / 판 화소. 1 이면 판 크기 그대로, 0.5 면 가로세로 절반 해상도로 계산해 읽는다
let srcSs = 1;
function setSrc(w, h, ss) {
const sw = Math.round(w * ss);
const sh = Math.round(h * ss);
if (srcCv.width !== sw || srcCv.height !== sh) {
srcCv.width = sw;
srcCv.height = sh;
}
srcSs = ss;
sctx.setTransform(ss, 0, 0, ss, 0, 0);
}
// cols×rows 칸을 왼쪽 위부터 읽는다. 칸 하나가 원본에서 덮는 영역은 가로 cw, 세로 sh (판 화소 단위)
function readGrid(cols, rows, cw, sh) {
const sw = srcCv.width;
const shp = srcCv.height;
const data = sctx.getImageData(0, 0, sw, shp).data;
const grid = { cols: cols, rows: rows, cw: cw, sh: sh, lum: [], r: [], g: [], b: [] };
for (let cy = 0; cy < rows; cy += 1) {
const y0 = Math.min(shp - 1, Math.floor(cy * sh * srcSs));
const y1 = Math.max(y0 + 1, Math.min(shp, Math.floor((cy + 1) * sh * srcSs)));
for (let cx = 0; cx < cols; cx += 1) {
const x0 = Math.min(sw - 1, Math.floor(cx * cw * srcSs));
const x1 = Math.max(x0 + 1, Math.min(sw, Math.floor((cx + 1) * cw * srcSs)));
let sr = 0;
let sg = 0;
let sb = 0;
let n = 0;
for (let y = y0; y < y1; y += 1) {
let p = (y * sw + x0) * 4;
for (let x = x0; x < x1; x += 1) {
sr += data[p];
sg += data[p + 1];
sb += data[p + 2];
n += 1;
p += 4;
}
}
const mr = sr / n;
const mg = sg / n;
const mb = sb / n;
grid.r.push(mr);
grid.g.push(mg);
grid.b.push(mb);
grid.lum.push((0.2126 * mr + 0.7152 * mg + 0.0722 * mb) / 255);
}
}
return grid;
}
// ── 글자 램프 — 성긴 글자에서 짙은 글자 순서 열여섯 개. n 단계 램프는 이 줄에서 고르게 n 개를 뽑는다 ──
const RAMP_FULL = ' .,:;-~=+*ox#%&@';
function rampOf(n) {
let s = '';
for (let i = 0; i < n; i += 1) {
s += RAMP_FULL.charAt(Math.round(i * (RAMP_FULL.length - 1) / (n - 1)));
}
return s;
}
// 밝기 v(0 에서 1)를 램프 번호로 — 단계 폭 1/n 으로 나눠 내림
function glyphIndex(v, n) { return Math.min(n - 1, Math.max(0, Math.floor(v * n))); }
// 칸 크기 — 글자 크기 fp 를 주면 폭은 글꼴에서 잰 글자 폭, 높이는 줄 높이
function cellW(fp) { return advRatio * fp; }
function cellH(fp) { return LINE_K * fp; }
function measureAdv() {
ctx.font = '100px ' + MONO;
const m = ctx.measureText('M').width / 100;
advRatio = m > 0.3 && m < 0.9 ? m : 0.6;
}
// ── 그리기 도구 ──
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
function paintPage() {
ctx.fillStyle = rgba(PAGE, 1);
ctx.fillRect(0, 0, W, H);
}
// 글자 판 바탕 — 짙은 남보라 둥근 사각형
function panel(x, y, w, h) {
box(x - 1, y - 1, w + 2, h + 2, 11, BOARD_EDGE, 1);
box(x, y, w, h, 10, BOARD, 1);
}
// 글자 판 그리기 — chars 는 칸마다 글자 하나(왼쪽 위부터 줄 순서). colorIdx 가 있으면 칸마다 팔레트 번호, 없으면 모두 pal[0].
// 같은 색이 이어지는 칸은 한 번에 찍어 한 판에 fillText 가 줄 수 × 색 조각 수만큼만 불린다
function drawGlyphs(x, y, fp, g, chars, colorIdx, pal) {
const cw = cellW(fp);
const ch = cellH(fp);
ctx.font = '600 ' + fp + 'px ' + MONO;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
for (let r = 0; r < g.rows; r += 1) {
let c = 0;
while (c < g.cols) {
const ci = colorIdx ? colorIdx[r * g.cols + c] : 0;
let run = '';
let e = c;
while (e < g.cols && (colorIdx ? colorIdx[r * g.cols + e] : 0) === ci) {
run += chars[r * g.cols + e];
e += 1;
}
if (run.trim().length > 0) {
ctx.fillStyle = rgba(pal[ci], 1);
ctx.fillText(run, x + c * cw, y + (r + 0.5) * ch + 0.5);
}
c = e;
}
}
}
// 판의 글자를 줄바꿈으로 이은 텍스트 — コピー 단추가 이 글을 클립보드에 넣는다
function plainOf(g, chars) {
const lines = [];
for (let r = 0; r < g.rows; r += 1) {
lines.push(chars.slice(r * g.cols, (r + 1) * g.cols).join('').replace(/\s+$/, ''));
}
return lines.join('\n');
}
// 쓰인 서로 다른 글자 수(공백 포함)와 글자별 횟수
function usage(chars, ramp) {
const out = [];
for (let i = 0; i < ramp.length; i += 1) {
out.push(0);
}
for (const c of chars) {
const k = ramp.indexOf(c);
if (k >= 0) {
out[k] += 1;
}
}
return out;
}
function kinds(u) {
let n = 0;
for (const v of u) {
if (v > 0) {
n += 1;
}
}
return n;
}
// 글자별 사용 횟수 막대 — 판 아래 띠에 램프 순서대로
function usageBars(x, y, w, h, u, c) {
let mx = 1;
for (const v of u) {
if (v > mx) {
mx = v;
}
}
const bw = w / u.length;
for (let i = 0; i < u.length; i += 1) {
const bh = Math.max(u[i] > 0 ? 2 : 0, h * u[i] / mx);
box(x + i * bw + 1, y + h - bh, bw - 2, bh, 2, c, 0.95);
}
}
// 두 번째 캔버스에 쓰는 회색 장면 도구 — 중심에서 번지는 둥근 빛
function glow(x, y, r, l, a) {
const g = sctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, grayStr(l, a));
g.addColorStop(1, grayStr(l, 0));
sctx.fillStyle = g;
sctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function vgrad(w, h, l0, l1) {
const g = sctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, grayStr(l0, 1));
g.addColorStop(1, grayStr(l1, 1));
sctx.fillStyle = g;
sctx.fillRect(0, 0, w, h);
}
// 판 배치 — 위 46 은 상태 줄·단추 자리. 판 둘이면 가로 228, 하나면 464
const PY = 46;
const PH = 246;
const PW2 = 228;
const PW1 = 464;
const PX_L = 8;
const PX_R = 244;
// コピー 단추를 누른 뒤 상태 줄에 잠깐 뜨는 말
const COPY_OK = '文字盤をテキストでコピーしました';
const COPY_FAIL = 'コピーできませんでした — 文字盤を選んで手でコピーしてください';
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const copyEl = root.querySelector('[data-role="copy"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !copyEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const copyBtn = copyEl;
const doneP = doneEl;
const pad = padEl;
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
measureAdv();
}
// 지금 화면에 찍힌 글자 판 — コピー 단추가 이 텍스트를 넣는다
let plainText = '';
let copyFlash = 0;
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
if (copyFlash > 0) {
copyFlash = Math.max(0, copyFlash - dt);
}
if (sceneT >= LAP_S) {
sceneT -= LAP_S;
lap += 1;
}
}
const DT_MAX = 0.05;
const STEP_S = 0.4;
function showNow() {
draw();
pushStatus(copyFlash > 0 ? copyNote : statusText());
checkDone();
}
let copyNote = '';
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 장면이 한창인 순간의 정지 장면 한 장
function showRest() {
stopLoop();
sceneT = REST_S;
showNow();
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
function restart() {
wake();
sceneT = START_S;
lap = 0;
doneSaid = false;
showNow();
}
// 一歩進める — 멈춘 채 장면 시계를 0.4 초 앞으로
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
advance(STEP_S);
showNow();
}
// コピー — 화면에 있는 글자 판을 텍스트로 클립보드에. 임시 입력칸을 만들어 선택하고 コピー 명령을 부른다
function copyBoard() {
wake();
const ta = document.createElement('textarea');
ta.value = plainText;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
let ok = false;
try {
ok = document.execCommand('copy');
}
catch (err) {
ok = false;
}
document.body.removeChild(ta);
copyNote = ok ? COPY_OK : COPY_FAIL;
copyFlash = 1.6;
pushStatus(copyNote);
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
copyBtn.addEventListener('click', copyBoard, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space·P 는 一時停止, N 은 一歩進める, R 은 처음부터, C 는 글자 판 コピー
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
}
else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
}
else if (k === 'r' || k === 'R') {
e.preventDefault();
restart();
}
else if (k === 'c' || k === 'C') {
e.preventDefault();
copyBoard();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 一時停止 토글
pad.addEventListener('click', function () { togglePause(); }, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
sceneT = START_S;
showNow();
if (!paused) {
startLoop();
}
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 02 글자 칸이 세로로 긴 비율을 맞춰 원형을 지키는 보정 ─────────────────
const LAP_S = 10;
const START_S = 0.6;
const REST_S = 2.4;
const FONT_PX = 10;
const RAMP_LEN = 10;
const RAMP = rampOf(RAMP_LEN);
// 글자 한 칸의 잰 값 — MEASURE_W 는 글꼴에서 잰 글자 폭, SAMPLE_H 는 읽는 칸의 세로(줄 높이), GLYPH_RATIO 는 둘의 비
let MEASURE_W = 6;
let SAMPLE_H = 12;
let GLYPH_RATIO = 2;
let lastCols = 0;
let lastRows = 0;
// 원본 장면 — 위쪽 절반에 원과 정사각형이 좌우로 흔들리고, 아래쪽 절반에는 바닥 줄무늬와 작은 원이 있다.
// 보정 없는 판은 위쪽 절반만 읽어 세로로 두 배 늘려 찍는다
function paintScene(w, h, a) {
vgrad(w, h, 0.14, 0.3);
sctx.beginPath();
sctx.arc(w * 0.3 + 16 * Math.sin(a), 64 + 8 * Math.cos(a), 40, 0, TAU);
sctx.fillStyle = grayStr(0.96, 1);
sctx.fill();
sctx.fillStyle = grayStr(0.74, 1);
sctx.fillRect(w * 0.72 - 25 - 16 * Math.sin(a), 64 + 8 * Math.cos(a) - 25, 50, 50);
for (let k = 0; k < 7; k += 1) {
sctx.fillStyle = grayStr(0.5, 0.5);
sctx.fillRect(0, h * 0.54 + k * 20 + 3 * Math.sin(a + k), w, 6);
}
sctx.beginPath();
sctx.arc(w * 0.5 + 60 * Math.cos(a), 196, 26, 0, TAU);
sctx.fillStyle = grayStr(0.9, 1);
sctx.fill();
}
function draw() {
paintPage();
const a = sceneT / LAP_S * TAU;
MEASURE_W = cellW(FONT_PX);
SAMPLE_H = cellH(FONT_PX);
GLYPH_RATIO = SAMPLE_H / MEASURE_W;
setSrc(PW2, PH, 1);
paintScene(PW2, PH, a);
const cols = Math.floor(PW2 / MEASURE_W);
const rows = Math.floor(PH / SAMPLE_H);
// 보정: 읽는 칸의 세로를 줄 높이만큼(가로의 GLYPH_RATIO 배) 잡는다. 보정 없음: 읽는 칸을 정사각형(가로 = 세로)으로 잡는다
const gA = readGrid(cols, rows, MEASURE_W, SAMPLE_H);
const gB = readGrid(cols, rows, MEASURE_W, MEASURE_W);
const cA = [];
const cB = [];
for (let i = 0; i < gA.lum.length; i += 1) {
cA.push(RAMP.charAt(glyphIndex(gA.lum[i], RAMP_LEN)));
cB.push(RAMP.charAt(glyphIndex(gB.lum[i], RAMP_LEN)));
}
lastCols = cols;
lastRows = rows;
plainText = plainOf(gA, cA);
const ox = (PW2 - cols * MEASURE_W) / 2;
const oy = (PH - rows * SAMPLE_H) / 2;
panel(PX_L, PY, PW2, PH);
drawGlyphs(PX_L + ox, PY + oy, FONT_PX, gA, cA, null, [GLYPH]);
panel(PX_R, PY, PW2, PH);
drawGlyphs(PX_R + ox, PY + oy, FONT_PX, gB, cB, null, [GLYPH]);
tag('補正 ×{q}'.replace('{q}', GLYPH_RATIO.toFixed(1)), PX_L + 8, PY + 14, PAPER, INK, 10, 'left');
tag('補正なし (正方形のセル)', PX_R + 8, PY + 14, ROSE, INK, 10, 'left');
}
function statusText() {
return 'セル {w}×{h}px・比 {q}'.replace('{w}', MEASURE_W.toFixed(1)).replace('{h}', SAMPLE_H.toFixed(1)).replace('{q}', GLYPH_RATIO.toFixed(2));
}
fitCanvas();
if (still) {
showRest();
}
else {
sceneT = START_S;
showNow();
startLoop();
}
return function () {
stopLoop();
off.abort();
};
})();// GlyphCellAspect - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./GlyphCellAspect.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function GlyphCellAspect() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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(a: number, b: number, v: number): number { const k = clamp((v - a) / (b - a), 0, 1); return k * k * (3 - 2 * k); }
// 파스텔 팔레트 — 글자 판은 짙은 남보라 바탕 위에 옅은 라벤더 글자(대비 12 이상), 바깥 무대는 크림 라벤더
const PAGE: RGB = [246, 243, 252];
const BOARD: RGB = [34, 30, 56];
const BOARD_EDGE: RGB = [74, 68, 112];
const GLYPH: RGB = [226, 219, 250];
const MINT: RGB = [168, 226, 204];
const ROSE: RGB = [248, 176, 198];
const BUTTER: RGB = [250, 226, 150];
const SKY: RGB = [170, 208, 248];
const LAV: RGB = [196, 184, 244];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function grayStr(l: number, a: number): string {
const v = Math.round(clamp(l, 0, 1) * 255);
return 'rgba(' + v + ',' + v + ',' + v + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초) — 한 바퀴 LAP_S 초이고 장면의 모든 움직임은 위상 ph = sceneT / LAP_S 의 정수배라 바퀴 끝에서 이음매가 없다
let sceneT = 0;
let lap = 0;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 고정폭 글꼴 목록만
const MONO = 'ui-monospace, "Cascadia Mono", Consolas, Menlo, "DejaVu Sans Mono", "Courier New", monospace';
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
// 글자 한 줄 높이 = 글자 크기의 1.2 배. 폭은 글꼴에서 잰 값(advRatio)을 쓴다
const LINE_K = 1.2;
let advRatio = 0.6;
// ── 원본 장면 캔버스 — 화면에 붙이지 않는 두 번째 캔버스. 장면은 여기 그리고 화소를 읽는다 ──
const srcCv: HTMLCanvasElement = document.createElement('canvas');
const srcRaw = srcCv.getContext('2d', { willReadFrequently: true });
if (!srcRaw) { return; }
const sctx: CanvasRenderingContext2D = srcRaw;
// ss = 원본 화소 / 판 화소. 1 이면 판 크기 그대로, 0.5 면 가로세로 절반 해상도로 계산해 읽는다
let srcSs = 1;
function setSrc(w: number, h: number, ss: number): void {
const sw = Math.round(w * ss);
const sh = Math.round(h * ss);
if (srcCv.width !== sw || srcCv.height !== sh) {
srcCv.width = sw;
srcCv.height = sh;
}
srcSs = ss;
sctx.setTransform(ss, 0, 0, ss, 0, 0);
}
// 칸 격자 — 칸마다 원본 화소의 평균 밝기(lum, 0 에서 1)와 평균 색을 담는다
type Grid = { cols: number; rows: number; cw: number; sh: number; lum: number[]; r: number[]; g: number[]; b: number[] };
// cols×rows 칸을 왼쪽 위부터 읽는다. 칸 하나가 원본에서 덮는 영역은 가로 cw, 세로 sh (판 화소 단위)
function readGrid(cols: number, rows: number, cw: number, sh: number): Grid {
const sw = srcCv.width;
const shp = srcCv.height;
const data = sctx.getImageData(0, 0, sw, shp).data;
const grid: Grid = { cols: cols, rows: rows, cw: cw, sh: sh, lum: [], r: [], g: [], b: [] };
for (let cy = 0; cy < rows; cy += 1) {
const y0 = Math.min(shp - 1, Math.floor(cy * sh * srcSs));
const y1 = Math.max(y0 + 1, Math.min(shp, Math.floor((cy + 1) * sh * srcSs)));
for (let cx = 0; cx < cols; cx += 1) {
const x0 = Math.min(sw - 1, Math.floor(cx * cw * srcSs));
const x1 = Math.max(x0 + 1, Math.min(sw, Math.floor((cx + 1) * cw * srcSs)));
let sr = 0;
let sg = 0;
let sb = 0;
let n = 0;
for (let y = y0; y < y1; y += 1) {
let p = (y * sw + x0) * 4;
for (let x = x0; x < x1; x += 1) {
sr += data[p];
sg += data[p + 1];
sb += data[p + 2];
n += 1;
p += 4;
}
}
const mr = sr / n;
const mg = sg / n;
const mb = sb / n;
grid.r.push(mr);
grid.g.push(mg);
grid.b.push(mb);
grid.lum.push((0.2126 * mr + 0.7152 * mg + 0.0722 * mb) / 255);
}
}
return grid;
}
// ── 글자 램프 — 성긴 글자에서 짙은 글자 순서 열여섯 개. n 단계 램프는 이 줄에서 고르게 n 개를 뽑는다 ──
const RAMP_FULL = ' .,:;-~=+*ox#%&@';
function rampOf(n: number): string {
let s = '';
for (let i = 0; i < n; i += 1) { s += RAMP_FULL.charAt(Math.round(i * (RAMP_FULL.length - 1) / (n - 1))); }
return s;
}
// 밝기 v(0 에서 1)를 램프 번호로 — 단계 폭 1/n 으로 나눠 내림
function glyphIndex(v: number, n: number): number { return Math.min(n - 1, Math.max(0, Math.floor(v * n))); }
// 칸 크기 — 글자 크기 fp 를 주면 폭은 글꼴에서 잰 글자 폭, 높이는 줄 높이
function cellW(fp: number): number { return advRatio * fp; }
function cellH(fp: number): number { return LINE_K * fp; }
function measureAdv(): void {
ctx.font = '100px ' + MONO;
const m = ctx.measureText('M').width / 100;
advRatio = m > 0.3 && m < 0.9 ? m : 0.6;
}
// ── 그리기 도구 ──
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
function paintPage(): void {
ctx.fillStyle = rgba(PAGE, 1);
ctx.fillRect(0, 0, W, H);
}
// 글자 판 바탕 — 짙은 남보라 둥근 사각형
function panel(x: number, y: number, w: number, h: number): void {
box(x - 1, y - 1, w + 2, h + 2, 11, BOARD_EDGE, 1);
box(x, y, w, h, 10, BOARD, 1);
}
// 글자 판 그리기 — chars 는 칸마다 글자 하나(왼쪽 위부터 줄 순서). colorIdx 가 있으면 칸마다 팔레트 번호, 없으면 모두 pal[0].
// 같은 색이 이어지는 칸은 한 번에 찍어 한 판에 fillText 가 줄 수 × 색 조각 수만큼만 불린다
function drawGlyphs(x: number, y: number, fp: number, g: Grid, chars: string[], colorIdx: number[] | null, pal: RGB[]): void {
const cw = cellW(fp);
const ch = cellH(fp);
ctx.font = '600 ' + fp + 'px ' + MONO;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
for (let r = 0; r < g.rows; r += 1) {
let c = 0;
while (c < g.cols) {
const ci = colorIdx ? colorIdx[r * g.cols + c] : 0;
let run = '';
let e = c;
while (e < g.cols && (colorIdx ? colorIdx[r * g.cols + e] : 0) === ci) { run += chars[r * g.cols + e]; e += 1; }
if (run.trim().length > 0) {
ctx.fillStyle = rgba(pal[ci], 1);
ctx.fillText(run, x + c * cw, y + (r + 0.5) * ch + 0.5);
}
c = e;
}
}
}
// 판의 글자를 줄바꿈으로 이은 텍스트 — 복사 단추가 이 글을 클립보드에 넣는다
function plainOf(g: Grid, chars: string[]): string {
const lines: string[] = [];
for (let r = 0; r < g.rows; r += 1) {
lines.push(chars.slice(r * g.cols, (r + 1) * g.cols).join('').replace(/\s+$/, ''));
}
return lines.join('\n');
}
// 쓰인 서로 다른 글자 수(공백 포함)와 글자별 횟수
function usage(chars: string[], ramp: string): number[] {
const out: number[] = [];
for (let i = 0; i < ramp.length; i += 1) { out.push(0); }
for (const c of chars) {
const k = ramp.indexOf(c);
if (k >= 0) { out[k] += 1; }
}
return out;
}
function kinds(u: number[]): number {
let n = 0;
for (const v of u) { if (v > 0) { n += 1; } }
return n;
}
// 글자별 사용 횟수 막대 — 판 아래 띠에 램프 순서대로
function usageBars(x: number, y: number, w: number, h: number, u: number[], c: RGB): void {
let mx = 1;
for (const v of u) { if (v > mx) { mx = v; } }
const bw = w / u.length;
for (let i = 0; i < u.length; i += 1) {
const bh = Math.max(u[i] > 0 ? 2 : 0, h * u[i] / mx);
box(x + i * bw + 1, y + h - bh, bw - 2, bh, 2, c, 0.95);
}
}
// 두 번째 캔버스에 쓰는 회색 장면 도구 — 중심에서 번지는 둥근 빛
function glow(x: number, y: number, r: number, l: number, a: number): void {
const g = sctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, grayStr(l, a));
g.addColorStop(1, grayStr(l, 0));
sctx.fillStyle = g;
sctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function vgrad(w: number, h: number, l0: number, l1: number): void {
const g = sctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, grayStr(l0, 1));
g.addColorStop(1, grayStr(l1, 1));
sctx.fillStyle = g;
sctx.fillRect(0, 0, w, h);
}
// 판 배치 — 위 46 은 상태 줄·단추 자리. 판 둘이면 가로 228, 하나면 464
const PY = 46;
const PH = 246;
const PW2 = 228;
const PW1 = 464;
const PX_L = 8;
const PX_R = 244;
// 복사 단추를 누른 뒤 상태 줄에 잠깐 뜨는 말
const COPY_OK = '글자 판을 텍스트로 복사했다';
const COPY_FAIL = '복사하지 못했다 — 글자 판을 직접 선택해 복사';
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const copyEl = root.querySelector('[data-role="copy"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !copyEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const copyBtn: HTMLButtonElement = copyEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
measureAdv();
}
// 지금 화면에 찍힌 글자 판 — 복사 단추가 이 텍스트를 넣는다
let plainText = '';
let copyFlash = 0;
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
if (copyFlash > 0) { copyFlash = Math.max(0, copyFlash - dt); }
if (sceneT >= LAP_S) { sceneT -= LAP_S; lap += 1; }
}
const DT_MAX = 0.05;
const STEP_S = 0.4;
function showNow(): void {
draw();
pushStatus(copyFlash > 0 ? copyNote : statusText());
checkDone();
}
let copyNote = '';
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 장면이 한창인 순간의 정지 장면 한 장
function showRest(): void {
stopLoop();
sceneT = REST_S;
showNow();
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
function restart(): void {
wake();
sceneT = START_S;
lap = 0;
doneSaid = false;
showNow();
}
// 한 걸음 — 멈춘 채 장면 시계를 0.4 초 앞으로
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
advance(STEP_S);
showNow();
}
// 복사 — 화면에 있는 글자 판을 텍스트로 클립보드에. 임시 입력칸을 만들어 선택하고 복사 명령을 부른다
function copyBoard(): void {
wake();
const ta = document.createElement('textarea');
ta.value = plainText;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
let ok = false;
try { ok = document.execCommand('copy'); } catch (err) { ok = false; }
document.body.removeChild(ta);
copyNote = ok ? COPY_OK : COPY_FAIL;
copyFlash = 1.6;
pushStatus(copyNote);
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
copyBtn.addEventListener('click', copyBoard, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space·P 는 일시정지, N 은 한 걸음, R 은 처음부터, C 는 글자 판 복사
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
} else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
} else if (k === 'r' || k === 'R') {
e.preventDefault();
restart();
} else if (k === 'c' || k === 'C') {
e.preventDefault();
copyBoard();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 일시정지 토글
pad.addEventListener('click', function (): void { togglePause(); }, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { sceneT = START_S; showNow(); if (!paused) { startLoop(); } }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 02 글자 칸이 세로로 긴 비율을 맞춰 원형을 지키는 보정 ─────────────────
const LAP_S = 10;
const START_S = 0.6;
const REST_S = 2.4;
const FONT_PX = 10;
const RAMP_LEN = 10;
const RAMP = rampOf(RAMP_LEN);
// 글자 한 칸의 잰 값 — MEASURE_W 는 글꼴에서 잰 글자 폭, SAMPLE_H 는 읽는 칸의 세로(줄 높이), GLYPH_RATIO 는 둘의 비
let MEASURE_W = 6;
let SAMPLE_H = 12;
let GLYPH_RATIO = 2;
let lastCols = 0;
let lastRows = 0;
// 원본 장면 — 위쪽 절반에 원과 정사각형이 좌우로 흔들리고, 아래쪽 절반에는 바닥 줄무늬와 작은 원이 있다.
// 보정 없는 판은 위쪽 절반만 읽어 세로로 두 배 늘려 찍는다
function paintScene(w: number, h: number, a: number): void {
vgrad(w, h, 0.14, 0.3);
sctx.beginPath();
sctx.arc(w * 0.3 + 16 * Math.sin(a), 64 + 8 * Math.cos(a), 40, 0, TAU);
sctx.fillStyle = grayStr(0.96, 1);
sctx.fill();
sctx.fillStyle = grayStr(0.74, 1);
sctx.fillRect(w * 0.72 - 25 - 16 * Math.sin(a), 64 + 8 * Math.cos(a) - 25, 50, 50);
for (let k = 0; k < 7; k += 1) {
sctx.fillStyle = grayStr(0.5, 0.5);
sctx.fillRect(0, h * 0.54 + k * 20 + 3 * Math.sin(a + k), w, 6);
}
sctx.beginPath();
sctx.arc(w * 0.5 + 60 * Math.cos(a), 196, 26, 0, TAU);
sctx.fillStyle = grayStr(0.9, 1);
sctx.fill();
}
function draw(): void {
paintPage();
const a = sceneT / LAP_S * TAU;
MEASURE_W = cellW(FONT_PX);
SAMPLE_H = cellH(FONT_PX);
GLYPH_RATIO = SAMPLE_H / MEASURE_W;
setSrc(PW2, PH, 1);
paintScene(PW2, PH, a);
const cols = Math.floor(PW2 / MEASURE_W);
const rows = Math.floor(PH / SAMPLE_H);
// 보정: 읽는 칸의 세로를 줄 높이만큼(가로의 GLYPH_RATIO 배) 잡는다. 보정 없음: 읽는 칸을 정사각형(가로 = 세로)으로 잡는다
const gA = readGrid(cols, rows, MEASURE_W, SAMPLE_H);
const gB = readGrid(cols, rows, MEASURE_W, MEASURE_W);
const cA: string[] = [];
const cB: string[] = [];
for (let i = 0; i < gA.lum.length; i += 1) {
cA.push(RAMP.charAt(glyphIndex(gA.lum[i], RAMP_LEN)));
cB.push(RAMP.charAt(glyphIndex(gB.lum[i], RAMP_LEN)));
}
lastCols = cols;
lastRows = rows;
plainText = plainOf(gA, cA);
const ox = (PW2 - cols * MEASURE_W) / 2;
const oy = (PH - rows * SAMPLE_H) / 2;
panel(PX_L, PY, PW2, PH);
drawGlyphs(PX_L + ox, PY + oy, FONT_PX, gA, cA, null, [GLYPH]);
panel(PX_R, PY, PW2, PH);
drawGlyphs(PX_R + ox, PY + oy, FONT_PX, gB, cB, null, [GLYPH]);
tag('보정 ×{q}'.replace('{q}', GLYPH_RATIO.toFixed(1)), PX_L + 8, PY + 14, PAPER, INK, 10, 'left');
tag('보정 없음 (정사각 칸)', PX_R + 8, PY + 14, ROSE, INK, 10, 'left');
}
function statusText(): string {
return '칸 {w}×{h}px · 비율 {q}'.replace('{w}', MEASURE_W.toFixed(1)).replace('{h}', SAMPLE_H.toFixed(1)).replace('{q}', GLYPH_RATIO.toFixed(2));
}
fitCanvas();
if (still) { showRest(); } else { sceneT = START_S; showNow(); startLoop(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'아스키 아트 시연 무대 — Enter 나 Space 로 일시정지, N 으로 한 걸음, R 로 처음부터, C 로 글자 판 복사'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'위쪽에 원과 정사각형이 좌우로 흔들리는 장면을 글자 칸의 가로세로 비만큼 세로를 늘려 읽은 왼쪽 판에서는 원이 둥글고, 정사각형 칸으로 읽은 오른쪽 판에서는 같은 원이 세로로 길어진 타원이 된다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'아스키 아트 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
<button className={cx("vj__copy")} data-role="copy" type="button">{'복사'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}02 · 文字セルが縦に長い比を合わせて円を保つ補正 — 計測 累積面積 10.906% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
MEASURE_W = cellW(FONT_PX);
SAMPLE_H = cellH(FONT_PX);
GLYPH_RATIO = SAMPLE_H / MEASURE_W;
setSrc(PW2, PH, 1);
paintScene(PW2, PH, a);
const cols = Math.floor(PW2 / MEASURE_W);
const rows = Math.floor(PH / SAMPLE_H);
// 補正あり: 読むセルの縦を行の高さぶん(横のGLYPH_RATIO倍)にとる。補正なし: 読むセルを正方形(横 = 縦)にとる
const gA = readGrid(cols, rows, MEASURE_W, SAMPLE_H);
const gB = readGrid(cols, rows, MEASURE_W, MEASURE_W);
const cA = [];
const cB = [];
for (let i = 0; i < gA.lum.length; i += 1) {
cA.push(RAMP.charAt(glyphIndex(gA.lum[i], RAMP_LEN)));
cB.push(RAMP.charAt(glyphIndex(gB.lum[i], RAMP_LEN)));
}
03ガンマとコントラストの曲線で文字の分布をならす調整
写真の露出が偏っても文字の絵がかたまらないようにする前処理です。curve は明るさを PIVOT 0.72のまわりで CONTRAST 2.6倍に引き伸ばしてから GAMMA 1.2乗で曲げ、0.5から0.95の狭い帯に詰まった霧の場面を段階表全体へ広げます。曲線ありでは平均10種、なしでは4.9種が使われ、もっとも多く使われた文字の割合は21%と42%に分かれます。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="アスキーアートの舞台 — Enter か Space で一時停止、N で一歩、R でやり直し、C で文字盤をコピー">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="霧がかかったように明るさが狭い帯に偏った場面を、左の盤はコントラストとガンマの曲線で十文字全体に広げ、右の盤は曲線なしで読んで数文字しか使いません。盤ごとに文字別の使用回数の棒があります"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">アスキーアートのデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
<button class="vj__copy" data-role="copy" type="button">コピー</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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-cream;
}
// 무대 상자 — 여기서 시작한 터치만 칸 누르기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step,
.vj__copy {
padding: $sp-1 $sp-2;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.vj__step,
.vj__copy {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover,
.vj__copy:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active,
.vj__copy:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible,
.vj__copy:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 214px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__copy,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step,
.vj__copy { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active,
.vj__copy:hover,
.vj__copy:active { transform: none !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);
}
.vj {
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: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step,
.vj__copy {
padding: 4px 8px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__step,
.vj__copy {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover,
.vj__copy:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active,
.vj__copy:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible,
.vj__copy:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 214px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__copy,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step,
.vj__copy {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active,
.vj__copy:hover,
.vj__copy:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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(a, b, v) { const k = clamp((v - a) / (b - a), 0, 1); return k * k * (3 - 2 * k); }
// 파스텔 팔레트 — 글자 판은 짙은 남보라 바탕 위에 옅은 라벤더 글자(대비 12 이상), 바깥 무대는 크림 라벤더
const PAGE = [246, 243, 252];
const BOARD = [34, 30, 56];
const BOARD_EDGE = [74, 68, 112];
const GLYPH = [226, 219, 250];
const MINT = [168, 226, 204];
const ROSE = [248, 176, 198];
const BUTTER = [250, 226, 150];
const SKY = [170, 208, 248];
const LAV = [196, 184, 244];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function grayStr(l, a) {
const v = Math.round(clamp(l, 0, 1) * 255);
return 'rgba(' + v + ',' + v + ',' + v + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초) — 한 바퀴 LAP_S 초이고 장면의 모든 움직임은 위상 ph = sceneT / LAP_S 의 정수배라 바퀴 끝에서 이음매가 없다
let sceneT = 0;
let lap = 0;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 고정폭 글꼴 목록만
const MONO = 'ui-monospace, "Cascadia Mono", Consolas, Menlo, "DejaVu Sans Mono", "Courier New", monospace';
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
// 글자 한 줄 높이 = 글자 크기의 1.2 배. 폭은 글꼴에서 잰 값(advRatio)을 쓴다
const LINE_K = 1.2;
let advRatio = 0.6;
// ── 원본 장면 캔버스 — 화면에 붙이지 않는 두 번째 캔버스. 장면은 여기 그리고 화소를 읽는다 ──
const srcCv = document.createElement('canvas');
const srcRaw = srcCv.getContext('2d', { willReadFrequently: true });
if (!srcRaw) {
return;
}
const sctx = srcRaw;
// ss = 원본 화소 / 판 화소. 1 이면 판 크기 그대로, 0.5 면 가로세로 절반 해상도로 계산해 읽는다
let srcSs = 1;
function setSrc(w, h, ss) {
const sw = Math.round(w * ss);
const sh = Math.round(h * ss);
if (srcCv.width !== sw || srcCv.height !== sh) {
srcCv.width = sw;
srcCv.height = sh;
}
srcSs = ss;
sctx.setTransform(ss, 0, 0, ss, 0, 0);
}
// cols×rows 칸을 왼쪽 위부터 읽는다. 칸 하나가 원본에서 덮는 영역은 가로 cw, 세로 sh (판 화소 단위)
function readGrid(cols, rows, cw, sh) {
const sw = srcCv.width;
const shp = srcCv.height;
const data = sctx.getImageData(0, 0, sw, shp).data;
const grid = { cols: cols, rows: rows, cw: cw, sh: sh, lum: [], r: [], g: [], b: [] };
for (let cy = 0; cy < rows; cy += 1) {
const y0 = Math.min(shp - 1, Math.floor(cy * sh * srcSs));
const y1 = Math.max(y0 + 1, Math.min(shp, Math.floor((cy + 1) * sh * srcSs)));
for (let cx = 0; cx < cols; cx += 1) {
const x0 = Math.min(sw - 1, Math.floor(cx * cw * srcSs));
const x1 = Math.max(x0 + 1, Math.min(sw, Math.floor((cx + 1) * cw * srcSs)));
let sr = 0;
let sg = 0;
let sb = 0;
let n = 0;
for (let y = y0; y < y1; y += 1) {
let p = (y * sw + x0) * 4;
for (let x = x0; x < x1; x += 1) {
sr += data[p];
sg += data[p + 1];
sb += data[p + 2];
n += 1;
p += 4;
}
}
const mr = sr / n;
const mg = sg / n;
const mb = sb / n;
grid.r.push(mr);
grid.g.push(mg);
grid.b.push(mb);
grid.lum.push((0.2126 * mr + 0.7152 * mg + 0.0722 * mb) / 255);
}
}
return grid;
}
// ── 글자 램프 — 성긴 글자에서 짙은 글자 순서 열여섯 개. n 단계 램프는 이 줄에서 고르게 n 개를 뽑는다 ──
const RAMP_FULL = ' .,:;-~=+*ox#%&@';
function rampOf(n) {
let s = '';
for (let i = 0; i < n; i += 1) {
s += RAMP_FULL.charAt(Math.round(i * (RAMP_FULL.length - 1) / (n - 1)));
}
return s;
}
// 밝기 v(0 에서 1)를 램프 번호로 — 단계 폭 1/n 으로 나눠 내림
function glyphIndex(v, n) { return Math.min(n - 1, Math.max(0, Math.floor(v * n))); }
// 칸 크기 — 글자 크기 fp 를 주면 폭은 글꼴에서 잰 글자 폭, 높이는 줄 높이
function cellW(fp) { return advRatio * fp; }
function cellH(fp) { return LINE_K * fp; }
function measureAdv() {
ctx.font = '100px ' + MONO;
const m = ctx.measureText('M').width / 100;
advRatio = m > 0.3 && m < 0.9 ? m : 0.6;
}
// ── 그리기 도구 ──
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
function paintPage() {
ctx.fillStyle = rgba(PAGE, 1);
ctx.fillRect(0, 0, W, H);
}
// 글자 판 바탕 — 짙은 남보라 둥근 사각형
function panel(x, y, w, h) {
box(x - 1, y - 1, w + 2, h + 2, 11, BOARD_EDGE, 1);
box(x, y, w, h, 10, BOARD, 1);
}
// 글자 판 그리기 — chars 는 칸마다 글자 하나(왼쪽 위부터 줄 순서). colorIdx 가 있으면 칸마다 팔레트 번호, 없으면 모두 pal[0].
// 같은 색이 이어지는 칸은 한 번에 찍어 한 판에 fillText 가 줄 수 × 색 조각 수만큼만 불린다
function drawGlyphs(x, y, fp, g, chars, colorIdx, pal) {
const cw = cellW(fp);
const ch = cellH(fp);
ctx.font = '600 ' + fp + 'px ' + MONO;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
for (let r = 0; r < g.rows; r += 1) {
let c = 0;
while (c < g.cols) {
const ci = colorIdx ? colorIdx[r * g.cols + c] : 0;
let run = '';
let e = c;
while (e < g.cols && (colorIdx ? colorIdx[r * g.cols + e] : 0) === ci) {
run += chars[r * g.cols + e];
e += 1;
}
if (run.trim().length > 0) {
ctx.fillStyle = rgba(pal[ci], 1);
ctx.fillText(run, x + c * cw, y + (r + 0.5) * ch + 0.5);
}
c = e;
}
}
}
// 판의 글자를 줄바꿈으로 이은 텍스트 — コピー 단추가 이 글을 클립보드에 넣는다
function plainOf(g, chars) {
const lines = [];
for (let r = 0; r < g.rows; r += 1) {
lines.push(chars.slice(r * g.cols, (r + 1) * g.cols).join('').replace(/\s+$/, ''));
}
return lines.join('\n');
}
// 쓰인 서로 다른 글자 수(공백 포함)와 글자별 횟수
function usage(chars, ramp) {
const out = [];
for (let i = 0; i < ramp.length; i += 1) {
out.push(0);
}
for (const c of chars) {
const k = ramp.indexOf(c);
if (k >= 0) {
out[k] += 1;
}
}
return out;
}
function kinds(u) {
let n = 0;
for (const v of u) {
if (v > 0) {
n += 1;
}
}
return n;
}
// 글자별 사용 횟수 막대 — 판 아래 띠에 램프 순서대로
function usageBars(x, y, w, h, u, c) {
let mx = 1;
for (const v of u) {
if (v > mx) {
mx = v;
}
}
const bw = w / u.length;
for (let i = 0; i < u.length; i += 1) {
const bh = Math.max(u[i] > 0 ? 2 : 0, h * u[i] / mx);
box(x + i * bw + 1, y + h - bh, bw - 2, bh, 2, c, 0.95);
}
}
// 두 번째 캔버스에 쓰는 회색 장면 도구 — 중심에서 번지는 둥근 빛
function glow(x, y, r, l, a) {
const g = sctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, grayStr(l, a));
g.addColorStop(1, grayStr(l, 0));
sctx.fillStyle = g;
sctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function vgrad(w, h, l0, l1) {
const g = sctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, grayStr(l0, 1));
g.addColorStop(1, grayStr(l1, 1));
sctx.fillStyle = g;
sctx.fillRect(0, 0, w, h);
}
// 판 배치 — 위 46 은 상태 줄·단추 자리. 판 둘이면 가로 228, 하나면 464
const PY = 46;
const PH = 246;
const PW2 = 228;
const PW1 = 464;
const PX_L = 8;
const PX_R = 244;
// コピー 단추를 누른 뒤 상태 줄에 잠깐 뜨는 말
const COPY_OK = '文字盤をテキストでコピーしました';
const COPY_FAIL = 'コピーできませんでした — 文字盤を選んで手でコピーしてください';
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const copyEl = root.querySelector('[data-role="copy"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !copyEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const copyBtn = copyEl;
const doneP = doneEl;
const pad = padEl;
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
measureAdv();
}
// 지금 화면에 찍힌 글자 판 — コピー 단추가 이 텍스트를 넣는다
let plainText = '';
let copyFlash = 0;
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
if (copyFlash > 0) {
copyFlash = Math.max(0, copyFlash - dt);
}
if (sceneT >= LAP_S) {
sceneT -= LAP_S;
lap += 1;
}
}
const DT_MAX = 0.05;
const STEP_S = 0.4;
function showNow() {
draw();
pushStatus(copyFlash > 0 ? copyNote : statusText());
checkDone();
}
let copyNote = '';
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 장면이 한창인 순간의 정지 장면 한 장
function showRest() {
stopLoop();
sceneT = REST_S;
showNow();
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
function restart() {
wake();
sceneT = START_S;
lap = 0;
doneSaid = false;
showNow();
}
// 一歩進める — 멈춘 채 장면 시계를 0.4 초 앞으로
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
advance(STEP_S);
showNow();
}
// コピー — 화면에 있는 글자 판을 텍스트로 클립보드에. 임시 입력칸을 만들어 선택하고 コピー 명령을 부른다
function copyBoard() {
wake();
const ta = document.createElement('textarea');
ta.value = plainText;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
let ok = false;
try {
ok = document.execCommand('copy');
}
catch (err) {
ok = false;
}
document.body.removeChild(ta);
copyNote = ok ? COPY_OK : COPY_FAIL;
copyFlash = 1.6;
pushStatus(copyNote);
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
copyBtn.addEventListener('click', copyBoard, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space·P 는 一時停止, N 은 一歩進める, R 은 처음부터, C 는 글자 판 コピー
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
}
else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
}
else if (k === 'r' || k === 'R') {
e.preventDefault();
restart();
}
else if (k === 'c' || k === 'C') {
e.preventDefault();
copyBoard();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 一時停止 토글
pad.addEventListener('click', function () { togglePause(); }, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
sceneT = START_S;
showNow();
if (!paused) {
startLoop();
}
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 03 감마와 대비 곡선으로 글자 분포를 고르게 펴는 조정 ───────────────────
const LAP_S = 10;
const START_S = 0.6;
const REST_S = 2.7;
const FONT_PX = 10;
const RAMP_LEN = 10;
const RAMP = rampOf(RAMP_LEN);
// 곡선 — 밝기 v 를 PIVOT 둘레로 CONTRAST 배 늘린 뒤 GAMMA 제곱으로 구부린다
const PIVOT = 0.72;
const CONTRAST = 2.6;
const GAMMA = 1.2;
function curve(v) {
return Math.pow(clamp((v - PIVOT) * CONTRAST + 0.5, 0, 1), GAMMA);
}
let useA = [];
let useB = [];
// 원본 장면 — 안개 낀 사진. 밝기가 0.5 에서 0.95 사이 좁은 띠에 몰려 있다
function paintScene(w, h, a) {
sctx.fillStyle = grayStr(0.72, 1);
sctx.fillRect(0, 0, w, h);
for (let k = 0; k < 4; k += 1) {
glow(w * (0.5 + 0.38 * Math.cos(a + k * 1.7)), h * (0.5 + 0.34 * Math.sin(2 * a + k * 2.3)), 78, 0.95, 0.85);
}
for (let k = 0; k < 3; k += 1) {
glow(w * (0.5 + 0.4 * Math.sin(a + k * 2.1)), h * (0.5 + 0.36 * Math.cos(a + k * 1.3)), 70, 0.5, 0.8);
}
sctx.beginPath();
sctx.arc(w * 0.5 + 40 * Math.sin(a), h * 0.5 + 30 * Math.cos(a), 34, 0, TAU);
sctx.lineWidth = 7;
sctx.strokeStyle = grayStr(0.55, 0.9);
sctx.stroke();
}
function draw() {
paintPage();
const a = sceneT / LAP_S * TAU;
setSrc(PW2, PH, 1);
paintScene(PW2, PH, a);
const cw = cellW(FONT_PX);
const ch = cellH(FONT_PX);
const boardH = PH - 30;
const cols = Math.floor(PW2 / cw);
const rows = Math.floor(boardH / ch);
const g = readGrid(cols, rows, cw, ch);
const cA = [];
const cB = [];
for (let i = 0; i < g.lum.length; i += 1) {
cA.push(RAMP.charAt(glyphIndex(curve(g.lum[i]), RAMP_LEN)));
cB.push(RAMP.charAt(glyphIndex(g.lum[i], RAMP_LEN)));
}
useA = usage(cA, RAMP);
useB = usage(cB, RAMP);
plainText = plainOf(g, cA);
const ox = (PW2 - cols * cw) / 2;
const oy = (boardH - rows * ch) / 2;
panel(PX_L, PY, PW2, PH);
drawGlyphs(PX_L + ox, PY + oy, FONT_PX, g, cA, null, [GLYPH]);
usageBars(PX_L + 10, PY + PH - 24, PW2 - 20, 18, useA, MINT);
panel(PX_R, PY, PW2, PH);
drawGlyphs(PX_R + ox, PY + oy, FONT_PX, g, cB, null, [GLYPH]);
usageBars(PX_R + 10, PY + PH - 24, PW2 - 20, 18, useB, ROSE);
tag('曲線あり', PX_L + 8, PY + 14, PAPER, INK, 10, 'left');
tag('曲線なし', PX_R + 8, PY + 14, ROSE, INK, 10, 'left');
}
function statusText() {
return 'ガンマ {g}・コントラスト ×{c}・文字 {k}種 / {j}種'.replace('{g}', GAMMA.toFixed(1)).replace('{c}', CONTRAST.toFixed(1)).replace('{k}', String(kinds(useA))).replace('{j}', String(kinds(useB)));
}
fitCanvas();
if (still) {
showRest();
}
else {
sceneT = START_S;
showNow();
startLoop();
}
return function () {
stopLoop();
off.abort();
};
})();// GammaContrastCurve - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./GammaContrastCurve.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function GammaContrastCurve() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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(a: number, b: number, v: number): number { const k = clamp((v - a) / (b - a), 0, 1); return k * k * (3 - 2 * k); }
// 파스텔 팔레트 — 글자 판은 짙은 남보라 바탕 위에 옅은 라벤더 글자(대비 12 이상), 바깥 무대는 크림 라벤더
const PAGE: RGB = [246, 243, 252];
const BOARD: RGB = [34, 30, 56];
const BOARD_EDGE: RGB = [74, 68, 112];
const GLYPH: RGB = [226, 219, 250];
const MINT: RGB = [168, 226, 204];
const ROSE: RGB = [248, 176, 198];
const BUTTER: RGB = [250, 226, 150];
const SKY: RGB = [170, 208, 248];
const LAV: RGB = [196, 184, 244];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function grayStr(l: number, a: number): string {
const v = Math.round(clamp(l, 0, 1) * 255);
return 'rgba(' + v + ',' + v + ',' + v + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초) — 한 바퀴 LAP_S 초이고 장면의 모든 움직임은 위상 ph = sceneT / LAP_S 의 정수배라 바퀴 끝에서 이음매가 없다
let sceneT = 0;
let lap = 0;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 고정폭 글꼴 목록만
const MONO = 'ui-monospace, "Cascadia Mono", Consolas, Menlo, "DejaVu Sans Mono", "Courier New", monospace';
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
// 글자 한 줄 높이 = 글자 크기의 1.2 배. 폭은 글꼴에서 잰 값(advRatio)을 쓴다
const LINE_K = 1.2;
let advRatio = 0.6;
// ── 원본 장면 캔버스 — 화면에 붙이지 않는 두 번째 캔버스. 장면은 여기 그리고 화소를 읽는다 ──
const srcCv: HTMLCanvasElement = document.createElement('canvas');
const srcRaw = srcCv.getContext('2d', { willReadFrequently: true });
if (!srcRaw) { return; }
const sctx: CanvasRenderingContext2D = srcRaw;
// ss = 원본 화소 / 판 화소. 1 이면 판 크기 그대로, 0.5 면 가로세로 절반 해상도로 계산해 읽는다
let srcSs = 1;
function setSrc(w: number, h: number, ss: number): void {
const sw = Math.round(w * ss);
const sh = Math.round(h * ss);
if (srcCv.width !== sw || srcCv.height !== sh) {
srcCv.width = sw;
srcCv.height = sh;
}
srcSs = ss;
sctx.setTransform(ss, 0, 0, ss, 0, 0);
}
// 칸 격자 — 칸마다 원본 화소의 평균 밝기(lum, 0 에서 1)와 평균 색을 담는다
type Grid = { cols: number; rows: number; cw: number; sh: number; lum: number[]; r: number[]; g: number[]; b: number[] };
// cols×rows 칸을 왼쪽 위부터 읽는다. 칸 하나가 원본에서 덮는 영역은 가로 cw, 세로 sh (판 화소 단위)
function readGrid(cols: number, rows: number, cw: number, sh: number): Grid {
const sw = srcCv.width;
const shp = srcCv.height;
const data = sctx.getImageData(0, 0, sw, shp).data;
const grid: Grid = { cols: cols, rows: rows, cw: cw, sh: sh, lum: [], r: [], g: [], b: [] };
for (let cy = 0; cy < rows; cy += 1) {
const y0 = Math.min(shp - 1, Math.floor(cy * sh * srcSs));
const y1 = Math.max(y0 + 1, Math.min(shp, Math.floor((cy + 1) * sh * srcSs)));
for (let cx = 0; cx < cols; cx += 1) {
const x0 = Math.min(sw - 1, Math.floor(cx * cw * srcSs));
const x1 = Math.max(x0 + 1, Math.min(sw, Math.floor((cx + 1) * cw * srcSs)));
let sr = 0;
let sg = 0;
let sb = 0;
let n = 0;
for (let y = y0; y < y1; y += 1) {
let p = (y * sw + x0) * 4;
for (let x = x0; x < x1; x += 1) {
sr += data[p];
sg += data[p + 1];
sb += data[p + 2];
n += 1;
p += 4;
}
}
const mr = sr / n;
const mg = sg / n;
const mb = sb / n;
grid.r.push(mr);
grid.g.push(mg);
grid.b.push(mb);
grid.lum.push((0.2126 * mr + 0.7152 * mg + 0.0722 * mb) / 255);
}
}
return grid;
}
// ── 글자 램프 — 성긴 글자에서 짙은 글자 순서 열여섯 개. n 단계 램프는 이 줄에서 고르게 n 개를 뽑는다 ──
const RAMP_FULL = ' .,:;-~=+*ox#%&@';
function rampOf(n: number): string {
let s = '';
for (let i = 0; i < n; i += 1) { s += RAMP_FULL.charAt(Math.round(i * (RAMP_FULL.length - 1) / (n - 1))); }
return s;
}
// 밝기 v(0 에서 1)를 램프 번호로 — 단계 폭 1/n 으로 나눠 내림
function glyphIndex(v: number, n: number): number { return Math.min(n - 1, Math.max(0, Math.floor(v * n))); }
// 칸 크기 — 글자 크기 fp 를 주면 폭은 글꼴에서 잰 글자 폭, 높이는 줄 높이
function cellW(fp: number): number { return advRatio * fp; }
function cellH(fp: number): number { return LINE_K * fp; }
function measureAdv(): void {
ctx.font = '100px ' + MONO;
const m = ctx.measureText('M').width / 100;
advRatio = m > 0.3 && m < 0.9 ? m : 0.6;
}
// ── 그리기 도구 ──
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
function paintPage(): void {
ctx.fillStyle = rgba(PAGE, 1);
ctx.fillRect(0, 0, W, H);
}
// 글자 판 바탕 — 짙은 남보라 둥근 사각형
function panel(x: number, y: number, w: number, h: number): void {
box(x - 1, y - 1, w + 2, h + 2, 11, BOARD_EDGE, 1);
box(x, y, w, h, 10, BOARD, 1);
}
// 글자 판 그리기 — chars 는 칸마다 글자 하나(왼쪽 위부터 줄 순서). colorIdx 가 있으면 칸마다 팔레트 번호, 없으면 모두 pal[0].
// 같은 색이 이어지는 칸은 한 번에 찍어 한 판에 fillText 가 줄 수 × 색 조각 수만큼만 불린다
function drawGlyphs(x: number, y: number, fp: number, g: Grid, chars: string[], colorIdx: number[] | null, pal: RGB[]): void {
const cw = cellW(fp);
const ch = cellH(fp);
ctx.font = '600 ' + fp + 'px ' + MONO;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
for (let r = 0; r < g.rows; r += 1) {
let c = 0;
while (c < g.cols) {
const ci = colorIdx ? colorIdx[r * g.cols + c] : 0;
let run = '';
let e = c;
while (e < g.cols && (colorIdx ? colorIdx[r * g.cols + e] : 0) === ci) { run += chars[r * g.cols + e]; e += 1; }
if (run.trim().length > 0) {
ctx.fillStyle = rgba(pal[ci], 1);
ctx.fillText(run, x + c * cw, y + (r + 0.5) * ch + 0.5);
}
c = e;
}
}
}
// 판의 글자를 줄바꿈으로 이은 텍스트 — 복사 단추가 이 글을 클립보드에 넣는다
function plainOf(g: Grid, chars: string[]): string {
const lines: string[] = [];
for (let r = 0; r < g.rows; r += 1) {
lines.push(chars.slice(r * g.cols, (r + 1) * g.cols).join('').replace(/\s+$/, ''));
}
return lines.join('\n');
}
// 쓰인 서로 다른 글자 수(공백 포함)와 글자별 횟수
function usage(chars: string[], ramp: string): number[] {
const out: number[] = [];
for (let i = 0; i < ramp.length; i += 1) { out.push(0); }
for (const c of chars) {
const k = ramp.indexOf(c);
if (k >= 0) { out[k] += 1; }
}
return out;
}
function kinds(u: number[]): number {
let n = 0;
for (const v of u) { if (v > 0) { n += 1; } }
return n;
}
// 글자별 사용 횟수 막대 — 판 아래 띠에 램프 순서대로
function usageBars(x: number, y: number, w: number, h: number, u: number[], c: RGB): void {
let mx = 1;
for (const v of u) { if (v > mx) { mx = v; } }
const bw = w / u.length;
for (let i = 0; i < u.length; i += 1) {
const bh = Math.max(u[i] > 0 ? 2 : 0, h * u[i] / mx);
box(x + i * bw + 1, y + h - bh, bw - 2, bh, 2, c, 0.95);
}
}
// 두 번째 캔버스에 쓰는 회색 장면 도구 — 중심에서 번지는 둥근 빛
function glow(x: number, y: number, r: number, l: number, a: number): void {
const g = sctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, grayStr(l, a));
g.addColorStop(1, grayStr(l, 0));
sctx.fillStyle = g;
sctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function vgrad(w: number, h: number, l0: number, l1: number): void {
const g = sctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, grayStr(l0, 1));
g.addColorStop(1, grayStr(l1, 1));
sctx.fillStyle = g;
sctx.fillRect(0, 0, w, h);
}
// 판 배치 — 위 46 은 상태 줄·단추 자리. 판 둘이면 가로 228, 하나면 464
const PY = 46;
const PH = 246;
const PW2 = 228;
const PW1 = 464;
const PX_L = 8;
const PX_R = 244;
// 복사 단추를 누른 뒤 상태 줄에 잠깐 뜨는 말
const COPY_OK = '글자 판을 텍스트로 복사했다';
const COPY_FAIL = '복사하지 못했다 — 글자 판을 직접 선택해 복사';
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const copyEl = root.querySelector('[data-role="copy"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !copyEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const copyBtn: HTMLButtonElement = copyEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
measureAdv();
}
// 지금 화면에 찍힌 글자 판 — 복사 단추가 이 텍스트를 넣는다
let plainText = '';
let copyFlash = 0;
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
if (copyFlash > 0) { copyFlash = Math.max(0, copyFlash - dt); }
if (sceneT >= LAP_S) { sceneT -= LAP_S; lap += 1; }
}
const DT_MAX = 0.05;
const STEP_S = 0.4;
function showNow(): void {
draw();
pushStatus(copyFlash > 0 ? copyNote : statusText());
checkDone();
}
let copyNote = '';
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 장면이 한창인 순간의 정지 장면 한 장
function showRest(): void {
stopLoop();
sceneT = REST_S;
showNow();
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
function restart(): void {
wake();
sceneT = START_S;
lap = 0;
doneSaid = false;
showNow();
}
// 한 걸음 — 멈춘 채 장면 시계를 0.4 초 앞으로
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
advance(STEP_S);
showNow();
}
// 복사 — 화면에 있는 글자 판을 텍스트로 클립보드에. 임시 입력칸을 만들어 선택하고 복사 명령을 부른다
function copyBoard(): void {
wake();
const ta = document.createElement('textarea');
ta.value = plainText;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
let ok = false;
try { ok = document.execCommand('copy'); } catch (err) { ok = false; }
document.body.removeChild(ta);
copyNote = ok ? COPY_OK : COPY_FAIL;
copyFlash = 1.6;
pushStatus(copyNote);
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
copyBtn.addEventListener('click', copyBoard, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space·P 는 일시정지, N 은 한 걸음, R 은 처음부터, C 는 글자 판 복사
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
} else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
} else if (k === 'r' || k === 'R') {
e.preventDefault();
restart();
} else if (k === 'c' || k === 'C') {
e.preventDefault();
copyBoard();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 일시정지 토글
pad.addEventListener('click', function (): void { togglePause(); }, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { sceneT = START_S; showNow(); if (!paused) { startLoop(); } }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 03 감마와 대비 곡선으로 글자 분포를 고르게 펴는 조정 ───────────────────
const LAP_S = 10;
const START_S = 0.6;
const REST_S = 2.7;
const FONT_PX = 10;
const RAMP_LEN = 10;
const RAMP = rampOf(RAMP_LEN);
// 곡선 — 밝기 v 를 PIVOT 둘레로 CONTRAST 배 늘린 뒤 GAMMA 제곱으로 구부린다
const PIVOT = 0.72;
const CONTRAST = 2.6;
const GAMMA = 1.2;
function curve(v: number): number {
return Math.pow(clamp((v - PIVOT) * CONTRAST + 0.5, 0, 1), GAMMA);
}
let useA: number[] = [];
let useB: number[] = [];
// 원본 장면 — 안개 낀 사진. 밝기가 0.5 에서 0.95 사이 좁은 띠에 몰려 있다
function paintScene(w: number, h: number, a: number): void {
sctx.fillStyle = grayStr(0.72, 1);
sctx.fillRect(0, 0, w, h);
for (let k = 0; k < 4; k += 1) {
glow(w * (0.5 + 0.38 * Math.cos(a + k * 1.7)), h * (0.5 + 0.34 * Math.sin(2 * a + k * 2.3)), 78, 0.95, 0.85);
}
for (let k = 0; k < 3; k += 1) {
glow(w * (0.5 + 0.4 * Math.sin(a + k * 2.1)), h * (0.5 + 0.36 * Math.cos(a + k * 1.3)), 70, 0.5, 0.8);
}
sctx.beginPath();
sctx.arc(w * 0.5 + 40 * Math.sin(a), h * 0.5 + 30 * Math.cos(a), 34, 0, TAU);
sctx.lineWidth = 7;
sctx.strokeStyle = grayStr(0.55, 0.9);
sctx.stroke();
}
function draw(): void {
paintPage();
const a = sceneT / LAP_S * TAU;
setSrc(PW2, PH, 1);
paintScene(PW2, PH, a);
const cw = cellW(FONT_PX);
const ch = cellH(FONT_PX);
const boardH = PH - 30;
const cols = Math.floor(PW2 / cw);
const rows = Math.floor(boardH / ch);
const g = readGrid(cols, rows, cw, ch);
const cA: string[] = [];
const cB: string[] = [];
for (let i = 0; i < g.lum.length; i += 1) {
cA.push(RAMP.charAt(glyphIndex(curve(g.lum[i]), RAMP_LEN)));
cB.push(RAMP.charAt(glyphIndex(g.lum[i], RAMP_LEN)));
}
useA = usage(cA, RAMP);
useB = usage(cB, RAMP);
plainText = plainOf(g, cA);
const ox = (PW2 - cols * cw) / 2;
const oy = (boardH - rows * ch) / 2;
panel(PX_L, PY, PW2, PH);
drawGlyphs(PX_L + ox, PY + oy, FONT_PX, g, cA, null, [GLYPH]);
usageBars(PX_L + 10, PY + PH - 24, PW2 - 20, 18, useA, MINT);
panel(PX_R, PY, PW2, PH);
drawGlyphs(PX_R + ox, PY + oy, FONT_PX, g, cB, null, [GLYPH]);
usageBars(PX_R + 10, PY + PH - 24, PW2 - 20, 18, useB, ROSE);
tag('곡선 있음', PX_L + 8, PY + 14, PAPER, INK, 10, 'left');
tag('곡선 없음', PX_R + 8, PY + 14, ROSE, INK, 10, 'left');
}
function statusText(): string {
return '감마 {g} · 대비 ×{c} · 글자 {k}종 / {j}종'.replace('{g}', GAMMA.toFixed(1)).replace('{c}', CONTRAST.toFixed(1)).replace('{k}', String(kinds(useA))).replace('{j}', String(kinds(useB)));
}
fitCanvas();
if (still) { showRest(); } else { sceneT = START_S; showNow(); startLoop(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'아스키 아트 시연 무대 — Enter 나 Space 로 일시정지, N 으로 한 걸음, R 로 처음부터, C 로 글자 판 복사'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'안개 낀 사진처럼 밝기가 좁은 띠에 몰린 장면을 대비와 감마 곡선을 거쳐 열 단계 글자 전체에 고르게 편 왼쪽 판과, 곡선 없이 읽어 몇 글자만 쓰는 오른쪽 판, 판마다 글자별 사용 횟수 막대'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'아스키 아트 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
<button className={cx("vj__copy")} data-role="copy" type="button">{'복사'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}03 · ガンマとコントラストの曲線で文字の分布をならす調整 — 計測 累積面積 32.474% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
const g = readGrid(cols, rows, cw, ch);
const cA = [];
const cB = [];
for (let i = 0; i < g.lum.length; i += 1) {
cA.push(RAMP.charAt(glyphIndex(curve(g.lum[i]), RAMP_LEN)));
cB.push(RAMP.charAt(glyphIndex(g.lum[i], RAMP_LEN)));
}
useA = usage(cA, RAMP);
useB = usage(cB, RAMP);
04二文字だけで明暗を表す順序ディザ
文字の種類を増やせない環境で白黒の文字絵を作るときに、規則格子ディザを使います。セルの明るさを、セルの位置 (r % 4, c % 4) で選んだ ORDER_4 のしきい値 (値 + 0.5) / 16 と比べて @ と . のどちらかだけを打ち、右の盤はしきい値 THRESHOLD 0.5ひとつで切ります。濃い文字 @ の割合は、規則格子が平均45.8%、しきい値ひとつが40.4%です。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="アスキーアートの舞台 — Enter か Space で一時停止、N で一歩、R でやり直し、C で文字盤をコピー">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="なめらかな明暗の場面を点と塗りつぶした文字の二つだけで打ちます。左の盤はセルの位置でくり返す4×4のしきい値の表と比べて明るさが点の密度として読め、右の盤はしきい値ひとつで切って境目だけが残ります"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">アスキーアートのデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
<button class="vj__copy" data-role="copy" type="button">コピー</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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-cream;
}
// 무대 상자 — 여기서 시작한 터치만 칸 누르기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step,
.vj__copy {
padding: $sp-1 $sp-2;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.vj__step,
.vj__copy {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover,
.vj__copy:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active,
.vj__copy:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible,
.vj__copy:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 214px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__copy,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step,
.vj__copy { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active,
.vj__copy:hover,
.vj__copy:active { transform: none !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);
}
.vj {
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: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step,
.vj__copy {
padding: 4px 8px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__step,
.vj__copy {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover,
.vj__copy:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active,
.vj__copy:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible,
.vj__copy:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 214px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__copy,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step,
.vj__copy {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active,
.vj__copy:hover,
.vj__copy:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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(a, b, v) { const k = clamp((v - a) / (b - a), 0, 1); return k * k * (3 - 2 * k); }
// 파스텔 팔레트 — 글자 판은 짙은 남보라 바탕 위에 옅은 라벤더 글자(대비 12 이상), 바깥 무대는 크림 라벤더
const PAGE = [246, 243, 252];
const BOARD = [34, 30, 56];
const BOARD_EDGE = [74, 68, 112];
const GLYPH = [226, 219, 250];
const MINT = [168, 226, 204];
const ROSE = [248, 176, 198];
const BUTTER = [250, 226, 150];
const SKY = [170, 208, 248];
const LAV = [196, 184, 244];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function grayStr(l, a) {
const v = Math.round(clamp(l, 0, 1) * 255);
return 'rgba(' + v + ',' + v + ',' + v + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초) — 한 바퀴 LAP_S 초이고 장면의 모든 움직임은 위상 ph = sceneT / LAP_S 의 정수배라 바퀴 끝에서 이음매가 없다
let sceneT = 0;
let lap = 0;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 고정폭 글꼴 목록만
const MONO = 'ui-monospace, "Cascadia Mono", Consolas, Menlo, "DejaVu Sans Mono", "Courier New", monospace';
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
// 글자 한 줄 높이 = 글자 크기의 1.2 배. 폭은 글꼴에서 잰 값(advRatio)을 쓴다
const LINE_K = 1.2;
let advRatio = 0.6;
// ── 원본 장면 캔버스 — 화면에 붙이지 않는 두 번째 캔버스. 장면은 여기 그리고 화소를 읽는다 ──
const srcCv = document.createElement('canvas');
const srcRaw = srcCv.getContext('2d', { willReadFrequently: true });
if (!srcRaw) {
return;
}
const sctx = srcRaw;
// ss = 원본 화소 / 판 화소. 1 이면 판 크기 그대로, 0.5 면 가로세로 절반 해상도로 계산해 읽는다
let srcSs = 1;
function setSrc(w, h, ss) {
const sw = Math.round(w * ss);
const sh = Math.round(h * ss);
if (srcCv.width !== sw || srcCv.height !== sh) {
srcCv.width = sw;
srcCv.height = sh;
}
srcSs = ss;
sctx.setTransform(ss, 0, 0, ss, 0, 0);
}
// cols×rows 칸을 왼쪽 위부터 읽는다. 칸 하나가 원본에서 덮는 영역은 가로 cw, 세로 sh (판 화소 단위)
function readGrid(cols, rows, cw, sh) {
const sw = srcCv.width;
const shp = srcCv.height;
const data = sctx.getImageData(0, 0, sw, shp).data;
const grid = { cols: cols, rows: rows, cw: cw, sh: sh, lum: [], r: [], g: [], b: [] };
for (let cy = 0; cy < rows; cy += 1) {
const y0 = Math.min(shp - 1, Math.floor(cy * sh * srcSs));
const y1 = Math.max(y0 + 1, Math.min(shp, Math.floor((cy + 1) * sh * srcSs)));
for (let cx = 0; cx < cols; cx += 1) {
const x0 = Math.min(sw - 1, Math.floor(cx * cw * srcSs));
const x1 = Math.max(x0 + 1, Math.min(sw, Math.floor((cx + 1) * cw * srcSs)));
let sr = 0;
let sg = 0;
let sb = 0;
let n = 0;
for (let y = y0; y < y1; y += 1) {
let p = (y * sw + x0) * 4;
for (let x = x0; x < x1; x += 1) {
sr += data[p];
sg += data[p + 1];
sb += data[p + 2];
n += 1;
p += 4;
}
}
const mr = sr / n;
const mg = sg / n;
const mb = sb / n;
grid.r.push(mr);
grid.g.push(mg);
grid.b.push(mb);
grid.lum.push((0.2126 * mr + 0.7152 * mg + 0.0722 * mb) / 255);
}
}
return grid;
}
// ── 글자 램프 — 성긴 글자에서 짙은 글자 순서 열여섯 개. n 단계 램프는 이 줄에서 고르게 n 개를 뽑는다 ──
const RAMP_FULL = ' .,:;-~=+*ox#%&@';
function rampOf(n) {
let s = '';
for (let i = 0; i < n; i += 1) {
s += RAMP_FULL.charAt(Math.round(i * (RAMP_FULL.length - 1) / (n - 1)));
}
return s;
}
// 밝기 v(0 에서 1)를 램프 번호로 — 단계 폭 1/n 으로 나눠 내림
function glyphIndex(v, n) { return Math.min(n - 1, Math.max(0, Math.floor(v * n))); }
// 칸 크기 — 글자 크기 fp 를 주면 폭은 글꼴에서 잰 글자 폭, 높이는 줄 높이
function cellW(fp) { return advRatio * fp; }
function cellH(fp) { return LINE_K * fp; }
function measureAdv() {
ctx.font = '100px ' + MONO;
const m = ctx.measureText('M').width / 100;
advRatio = m > 0.3 && m < 0.9 ? m : 0.6;
}
// ── 그리기 도구 ──
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
function paintPage() {
ctx.fillStyle = rgba(PAGE, 1);
ctx.fillRect(0, 0, W, H);
}
// 글자 판 바탕 — 짙은 남보라 둥근 사각형
function panel(x, y, w, h) {
box(x - 1, y - 1, w + 2, h + 2, 11, BOARD_EDGE, 1);
box(x, y, w, h, 10, BOARD, 1);
}
// 글자 판 그리기 — chars 는 칸마다 글자 하나(왼쪽 위부터 줄 순서). colorIdx 가 있으면 칸마다 팔레트 번호, 없으면 모두 pal[0].
// 같은 색이 이어지는 칸은 한 번에 찍어 한 판에 fillText 가 줄 수 × 색 조각 수만큼만 불린다
function drawGlyphs(x, y, fp, g, chars, colorIdx, pal) {
const cw = cellW(fp);
const ch = cellH(fp);
ctx.font = '600 ' + fp + 'px ' + MONO;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
for (let r = 0; r < g.rows; r += 1) {
let c = 0;
while (c < g.cols) {
const ci = colorIdx ? colorIdx[r * g.cols + c] : 0;
let run = '';
let e = c;
while (e < g.cols && (colorIdx ? colorIdx[r * g.cols + e] : 0) === ci) {
run += chars[r * g.cols + e];
e += 1;
}
if (run.trim().length > 0) {
ctx.fillStyle = rgba(pal[ci], 1);
ctx.fillText(run, x + c * cw, y + (r + 0.5) * ch + 0.5);
}
c = e;
}
}
}
// 판의 글자를 줄바꿈으로 이은 텍스트 — コピー 단추가 이 글을 클립보드에 넣는다
function plainOf(g, chars) {
const lines = [];
for (let r = 0; r < g.rows; r += 1) {
lines.push(chars.slice(r * g.cols, (r + 1) * g.cols).join('').replace(/\s+$/, ''));
}
return lines.join('\n');
}
// 쓰인 서로 다른 글자 수(공백 포함)와 글자별 횟수
function usage(chars, ramp) {
const out = [];
for (let i = 0; i < ramp.length; i += 1) {
out.push(0);
}
for (const c of chars) {
const k = ramp.indexOf(c);
if (k >= 0) {
out[k] += 1;
}
}
return out;
}
function kinds(u) {
let n = 0;
for (const v of u) {
if (v > 0) {
n += 1;
}
}
return n;
}
// 글자별 사용 횟수 막대 — 판 아래 띠에 램프 순서대로
function usageBars(x, y, w, h, u, c) {
let mx = 1;
for (const v of u) {
if (v > mx) {
mx = v;
}
}
const bw = w / u.length;
for (let i = 0; i < u.length; i += 1) {
const bh = Math.max(u[i] > 0 ? 2 : 0, h * u[i] / mx);
box(x + i * bw + 1, y + h - bh, bw - 2, bh, 2, c, 0.95);
}
}
// 두 번째 캔버스에 쓰는 회색 장면 도구 — 중심에서 번지는 둥근 빛
function glow(x, y, r, l, a) {
const g = sctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, grayStr(l, a));
g.addColorStop(1, grayStr(l, 0));
sctx.fillStyle = g;
sctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function vgrad(w, h, l0, l1) {
const g = sctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, grayStr(l0, 1));
g.addColorStop(1, grayStr(l1, 1));
sctx.fillStyle = g;
sctx.fillRect(0, 0, w, h);
}
// 판 배치 — 위 46 은 상태 줄·단추 자리. 판 둘이면 가로 228, 하나면 464
const PY = 46;
const PH = 246;
const PW2 = 228;
const PW1 = 464;
const PX_L = 8;
const PX_R = 244;
// コピー 단추를 누른 뒤 상태 줄에 잠깐 뜨는 말
const COPY_OK = '文字盤をテキストでコピーしました';
const COPY_FAIL = 'コピーできませんでした — 文字盤を選んで手でコピーしてください';
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const copyEl = root.querySelector('[data-role="copy"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !copyEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const copyBtn = copyEl;
const doneP = doneEl;
const pad = padEl;
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
measureAdv();
}
// 지금 화면에 찍힌 글자 판 — コピー 단추가 이 텍스트를 넣는다
let plainText = '';
let copyFlash = 0;
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
if (copyFlash > 0) {
copyFlash = Math.max(0, copyFlash - dt);
}
if (sceneT >= LAP_S) {
sceneT -= LAP_S;
lap += 1;
}
}
const DT_MAX = 0.05;
const STEP_S = 0.4;
function showNow() {
draw();
pushStatus(copyFlash > 0 ? copyNote : statusText());
checkDone();
}
let copyNote = '';
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 장면이 한창인 순간의 정지 장면 한 장
function showRest() {
stopLoop();
sceneT = REST_S;
showNow();
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
function restart() {
wake();
sceneT = START_S;
lap = 0;
doneSaid = false;
showNow();
}
// 一歩進める — 멈춘 채 장면 시계를 0.4 초 앞으로
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
advance(STEP_S);
showNow();
}
// コピー — 화면에 있는 글자 판을 텍스트로 클립보드에. 임시 입력칸을 만들어 선택하고 コピー 명령을 부른다
function copyBoard() {
wake();
const ta = document.createElement('textarea');
ta.value = plainText;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
let ok = false;
try {
ok = document.execCommand('copy');
}
catch (err) {
ok = false;
}
document.body.removeChild(ta);
copyNote = ok ? COPY_OK : COPY_FAIL;
copyFlash = 1.6;
pushStatus(copyNote);
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
copyBtn.addEventListener('click', copyBoard, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space·P 는 一時停止, N 은 一歩進める, R 은 처음부터, C 는 글자 판 コピー
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
}
else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
}
else if (k === 'r' || k === 'R') {
e.preventDefault();
restart();
}
else if (k === 'c' || k === 'C') {
e.preventDefault();
copyBoard();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 一時停止 토글
pad.addEventListener('click', function () { togglePause(); }, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
sceneT = START_S;
showNow();
if (!paused) {
startLoop();
}
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 04 두 글자만으로 명암을 표현하는 순서 디더 ────────────────────────────
const LAP_S = 10;
const START_S = 0.6;
const REST_S = 3.4;
const FONT_PX = 10;
// 규칙 격자 — ORDER_N×ORDER_N 문턱 표를 칸 위치로 되풀이해 깐다. 값 0 에서 15 가 한 번씩, 문턱은 (값 + 0.5) / 16
const ORDER_N = 4;
const ORDER_4 = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];
// 대비판은 문턱 하나로만 자른다
const THRESHOLD = 0.5;
// 쓰는 두 글자 — 옅은 점과 채운 글자
const TWO_GLYPH = '.@';
let inkA = 0;
let inkB = 0;
// 원본 장면 — 부드럽게 이어지는 명암: 대각 그라데이션 위로 빛 셋이 떠다닌다
function paintScene(w, h, a) {
const g = sctx.createLinearGradient(0, 0, w, h);
g.addColorStop(0, grayStr(0.02, 1));
g.addColorStop(1, grayStr(0.75, 1));
sctx.fillStyle = g;
sctx.fillRect(0, 0, w, h);
glow(w * (0.5 + 0.34 * Math.cos(a)), h * (0.4 + 0.3 * Math.sin(a)), 100, 1, 0.8);
glow(w * (0.5 - 0.3 * Math.sin(a)), h * (0.6 - 0.28 * Math.cos(2 * a)), 80, 0.2, 0.9);
glow(w * (0.5 + 0.36 * Math.sin(2 * a)), h * (0.78 + 0.1 * Math.cos(a)), 70, 0.95, 0.7);
}
function draw() {
paintPage();
const a = sceneT / LAP_S * TAU;
setSrc(PW2, PH, 1);
paintScene(PW2, PH, a);
const cw = cellW(FONT_PX);
const ch = cellH(FONT_PX);
const cols = Math.floor(PW2 / cw);
const rows = Math.floor(PH / ch);
const g = readGrid(cols, rows, cw, ch);
const cA = [];
const cB = [];
let nA = 0;
let nB = 0;
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const v = g.lum[r * cols + c];
const t = (ORDER_4[(r % ORDER_N) * ORDER_N + (c % ORDER_N)] + 0.5) / (ORDER_N * ORDER_N);
if (v > t) {
cA.push(TWO_GLYPH.charAt(1));
nA += 1;
}
else {
cA.push(TWO_GLYPH.charAt(0));
}
if (v > THRESHOLD) {
cB.push(TWO_GLYPH.charAt(1));
nB += 1;
}
else {
cB.push(TWO_GLYPH.charAt(0));
}
}
}
inkA = Math.round(100 * nA / (rows * cols));
inkB = Math.round(100 * nB / (rows * cols));
plainText = plainOf(g, cA);
const ox = (PW2 - cols * cw) / 2;
const oy = (PH - rows * ch) / 2;
panel(PX_L, PY, PW2, PH);
drawGlyphs(PX_L + ox, PY + oy, FONT_PX, g, cA, null, [GLYPH]);
panel(PX_R, PY, PW2, PH);
drawGlyphs(PX_R + ox, PY + oy, FONT_PX, g, cB, null, [GLYPH]);
tag('規則格子 {n}×{n}'.replace('{n}', String(ORDER_N)).replace('{n}', String(ORDER_N)), PX_L + 8, PY + 14, PAPER, INK, 10, 'left');
tag('しきい値ひとつ {t}'.replace('{t}', THRESHOLD.toFixed(1)), PX_R + 8, PY + 14, ROSE, INK, 10, 'left');
}
function statusText() {
return '濃い文字 {a}%・対比版 {b}%'.replace('{a}', String(inkA)).replace('{b}', String(inkB));
}
fitCanvas();
if (still) {
showRest();
}
else {
sceneT = START_S;
showNow();
startLoop();
}
return function () {
stopLoop();
off.abort();
};
})();// OrderedMatrixDither - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./OrderedMatrixDither.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function OrderedMatrixDither() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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(a: number, b: number, v: number): number { const k = clamp((v - a) / (b - a), 0, 1); return k * k * (3 - 2 * k); }
// 파스텔 팔레트 — 글자 판은 짙은 남보라 바탕 위에 옅은 라벤더 글자(대비 12 이상), 바깥 무대는 크림 라벤더
const PAGE: RGB = [246, 243, 252];
const BOARD: RGB = [34, 30, 56];
const BOARD_EDGE: RGB = [74, 68, 112];
const GLYPH: RGB = [226, 219, 250];
const MINT: RGB = [168, 226, 204];
const ROSE: RGB = [248, 176, 198];
const BUTTER: RGB = [250, 226, 150];
const SKY: RGB = [170, 208, 248];
const LAV: RGB = [196, 184, 244];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function grayStr(l: number, a: number): string {
const v = Math.round(clamp(l, 0, 1) * 255);
return 'rgba(' + v + ',' + v + ',' + v + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초) — 한 바퀴 LAP_S 초이고 장면의 모든 움직임은 위상 ph = sceneT / LAP_S 의 정수배라 바퀴 끝에서 이음매가 없다
let sceneT = 0;
let lap = 0;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 고정폭 글꼴 목록만
const MONO = 'ui-monospace, "Cascadia Mono", Consolas, Menlo, "DejaVu Sans Mono", "Courier New", monospace';
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
// 글자 한 줄 높이 = 글자 크기의 1.2 배. 폭은 글꼴에서 잰 값(advRatio)을 쓴다
const LINE_K = 1.2;
let advRatio = 0.6;
// ── 원본 장면 캔버스 — 화면에 붙이지 않는 두 번째 캔버스. 장면은 여기 그리고 화소를 읽는다 ──
const srcCv: HTMLCanvasElement = document.createElement('canvas');
const srcRaw = srcCv.getContext('2d', { willReadFrequently: true });
if (!srcRaw) { return; }
const sctx: CanvasRenderingContext2D = srcRaw;
// ss = 원본 화소 / 판 화소. 1 이면 판 크기 그대로, 0.5 면 가로세로 절반 해상도로 계산해 읽는다
let srcSs = 1;
function setSrc(w: number, h: number, ss: number): void {
const sw = Math.round(w * ss);
const sh = Math.round(h * ss);
if (srcCv.width !== sw || srcCv.height !== sh) {
srcCv.width = sw;
srcCv.height = sh;
}
srcSs = ss;
sctx.setTransform(ss, 0, 0, ss, 0, 0);
}
// 칸 격자 — 칸마다 원본 화소의 평균 밝기(lum, 0 에서 1)와 평균 색을 담는다
type Grid = { cols: number; rows: number; cw: number; sh: number; lum: number[]; r: number[]; g: number[]; b: number[] };
// cols×rows 칸을 왼쪽 위부터 읽는다. 칸 하나가 원본에서 덮는 영역은 가로 cw, 세로 sh (판 화소 단위)
function readGrid(cols: number, rows: number, cw: number, sh: number): Grid {
const sw = srcCv.width;
const shp = srcCv.height;
const data = sctx.getImageData(0, 0, sw, shp).data;
const grid: Grid = { cols: cols, rows: rows, cw: cw, sh: sh, lum: [], r: [], g: [], b: [] };
for (let cy = 0; cy < rows; cy += 1) {
const y0 = Math.min(shp - 1, Math.floor(cy * sh * srcSs));
const y1 = Math.max(y0 + 1, Math.min(shp, Math.floor((cy + 1) * sh * srcSs)));
for (let cx = 0; cx < cols; cx += 1) {
const x0 = Math.min(sw - 1, Math.floor(cx * cw * srcSs));
const x1 = Math.max(x0 + 1, Math.min(sw, Math.floor((cx + 1) * cw * srcSs)));
let sr = 0;
let sg = 0;
let sb = 0;
let n = 0;
for (let y = y0; y < y1; y += 1) {
let p = (y * sw + x0) * 4;
for (let x = x0; x < x1; x += 1) {
sr += data[p];
sg += data[p + 1];
sb += data[p + 2];
n += 1;
p += 4;
}
}
const mr = sr / n;
const mg = sg / n;
const mb = sb / n;
grid.r.push(mr);
grid.g.push(mg);
grid.b.push(mb);
grid.lum.push((0.2126 * mr + 0.7152 * mg + 0.0722 * mb) / 255);
}
}
return grid;
}
// ── 글자 램프 — 성긴 글자에서 짙은 글자 순서 열여섯 개. n 단계 램프는 이 줄에서 고르게 n 개를 뽑는다 ──
const RAMP_FULL = ' .,:;-~=+*ox#%&@';
function rampOf(n: number): string {
let s = '';
for (let i = 0; i < n; i += 1) { s += RAMP_FULL.charAt(Math.round(i * (RAMP_FULL.length - 1) / (n - 1))); }
return s;
}
// 밝기 v(0 에서 1)를 램프 번호로 — 단계 폭 1/n 으로 나눠 내림
function glyphIndex(v: number, n: number): number { return Math.min(n - 1, Math.max(0, Math.floor(v * n))); }
// 칸 크기 — 글자 크기 fp 를 주면 폭은 글꼴에서 잰 글자 폭, 높이는 줄 높이
function cellW(fp: number): number { return advRatio * fp; }
function cellH(fp: number): number { return LINE_K * fp; }
function measureAdv(): void {
ctx.font = '100px ' + MONO;
const m = ctx.measureText('M').width / 100;
advRatio = m > 0.3 && m < 0.9 ? m : 0.6;
}
// ── 그리기 도구 ──
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
function paintPage(): void {
ctx.fillStyle = rgba(PAGE, 1);
ctx.fillRect(0, 0, W, H);
}
// 글자 판 바탕 — 짙은 남보라 둥근 사각형
function panel(x: number, y: number, w: number, h: number): void {
box(x - 1, y - 1, w + 2, h + 2, 11, BOARD_EDGE, 1);
box(x, y, w, h, 10, BOARD, 1);
}
// 글자 판 그리기 — chars 는 칸마다 글자 하나(왼쪽 위부터 줄 순서). colorIdx 가 있으면 칸마다 팔레트 번호, 없으면 모두 pal[0].
// 같은 색이 이어지는 칸은 한 번에 찍어 한 판에 fillText 가 줄 수 × 색 조각 수만큼만 불린다
function drawGlyphs(x: number, y: number, fp: number, g: Grid, chars: string[], colorIdx: number[] | null, pal: RGB[]): void {
const cw = cellW(fp);
const ch = cellH(fp);
ctx.font = '600 ' + fp + 'px ' + MONO;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
for (let r = 0; r < g.rows; r += 1) {
let c = 0;
while (c < g.cols) {
const ci = colorIdx ? colorIdx[r * g.cols + c] : 0;
let run = '';
let e = c;
while (e < g.cols && (colorIdx ? colorIdx[r * g.cols + e] : 0) === ci) { run += chars[r * g.cols + e]; e += 1; }
if (run.trim().length > 0) {
ctx.fillStyle = rgba(pal[ci], 1);
ctx.fillText(run, x + c * cw, y + (r + 0.5) * ch + 0.5);
}
c = e;
}
}
}
// 판의 글자를 줄바꿈으로 이은 텍스트 — 복사 단추가 이 글을 클립보드에 넣는다
function plainOf(g: Grid, chars: string[]): string {
const lines: string[] = [];
for (let r = 0; r < g.rows; r += 1) {
lines.push(chars.slice(r * g.cols, (r + 1) * g.cols).join('').replace(/\s+$/, ''));
}
return lines.join('\n');
}
// 쓰인 서로 다른 글자 수(공백 포함)와 글자별 횟수
function usage(chars: string[], ramp: string): number[] {
const out: number[] = [];
for (let i = 0; i < ramp.length; i += 1) { out.push(0); }
for (const c of chars) {
const k = ramp.indexOf(c);
if (k >= 0) { out[k] += 1; }
}
return out;
}
function kinds(u: number[]): number {
let n = 0;
for (const v of u) { if (v > 0) { n += 1; } }
return n;
}
// 글자별 사용 횟수 막대 — 판 아래 띠에 램프 순서대로
function usageBars(x: number, y: number, w: number, h: number, u: number[], c: RGB): void {
let mx = 1;
for (const v of u) { if (v > mx) { mx = v; } }
const bw = w / u.length;
for (let i = 0; i < u.length; i += 1) {
const bh = Math.max(u[i] > 0 ? 2 : 0, h * u[i] / mx);
box(x + i * bw + 1, y + h - bh, bw - 2, bh, 2, c, 0.95);
}
}
// 두 번째 캔버스에 쓰는 회색 장면 도구 — 중심에서 번지는 둥근 빛
function glow(x: number, y: number, r: number, l: number, a: number): void {
const g = sctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, grayStr(l, a));
g.addColorStop(1, grayStr(l, 0));
sctx.fillStyle = g;
sctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function vgrad(w: number, h: number, l0: number, l1: number): void {
const g = sctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, grayStr(l0, 1));
g.addColorStop(1, grayStr(l1, 1));
sctx.fillStyle = g;
sctx.fillRect(0, 0, w, h);
}
// 판 배치 — 위 46 은 상태 줄·단추 자리. 판 둘이면 가로 228, 하나면 464
const PY = 46;
const PH = 246;
const PW2 = 228;
const PW1 = 464;
const PX_L = 8;
const PX_R = 244;
// 복사 단추를 누른 뒤 상태 줄에 잠깐 뜨는 말
const COPY_OK = '글자 판을 텍스트로 복사했다';
const COPY_FAIL = '복사하지 못했다 — 글자 판을 직접 선택해 복사';
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const copyEl = root.querySelector('[data-role="copy"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !copyEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const copyBtn: HTMLButtonElement = copyEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
measureAdv();
}
// 지금 화면에 찍힌 글자 판 — 복사 단추가 이 텍스트를 넣는다
let plainText = '';
let copyFlash = 0;
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
if (copyFlash > 0) { copyFlash = Math.max(0, copyFlash - dt); }
if (sceneT >= LAP_S) { sceneT -= LAP_S; lap += 1; }
}
const DT_MAX = 0.05;
const STEP_S = 0.4;
function showNow(): void {
draw();
pushStatus(copyFlash > 0 ? copyNote : statusText());
checkDone();
}
let copyNote = '';
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 장면이 한창인 순간의 정지 장면 한 장
function showRest(): void {
stopLoop();
sceneT = REST_S;
showNow();
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
function restart(): void {
wake();
sceneT = START_S;
lap = 0;
doneSaid = false;
showNow();
}
// 한 걸음 — 멈춘 채 장면 시계를 0.4 초 앞으로
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
advance(STEP_S);
showNow();
}
// 복사 — 화면에 있는 글자 판을 텍스트로 클립보드에. 임시 입력칸을 만들어 선택하고 복사 명령을 부른다
function copyBoard(): void {
wake();
const ta = document.createElement('textarea');
ta.value = plainText;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
let ok = false;
try { ok = document.execCommand('copy'); } catch (err) { ok = false; }
document.body.removeChild(ta);
copyNote = ok ? COPY_OK : COPY_FAIL;
copyFlash = 1.6;
pushStatus(copyNote);
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
copyBtn.addEventListener('click', copyBoard, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space·P 는 일시정지, N 은 한 걸음, R 은 처음부터, C 는 글자 판 복사
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
} else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
} else if (k === 'r' || k === 'R') {
e.preventDefault();
restart();
} else if (k === 'c' || k === 'C') {
e.preventDefault();
copyBoard();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 일시정지 토글
pad.addEventListener('click', function (): void { togglePause(); }, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { sceneT = START_S; showNow(); if (!paused) { startLoop(); } }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 04 두 글자만으로 명암을 표현하는 순서 디더 ────────────────────────────
const LAP_S = 10;
const START_S = 0.6;
const REST_S = 3.4;
const FONT_PX = 10;
// 규칙 격자 — ORDER_N×ORDER_N 문턱 표를 칸 위치로 되풀이해 깐다. 값 0 에서 15 가 한 번씩, 문턱은 (값 + 0.5) / 16
const ORDER_N = 4;
const ORDER_4 = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];
// 대비판은 문턱 하나로만 자른다
const THRESHOLD = 0.5;
// 쓰는 두 글자 — 옅은 점과 채운 글자
const TWO_GLYPH = '.@';
let inkA = 0;
let inkB = 0;
// 원본 장면 — 부드럽게 이어지는 명암: 대각 그라데이션 위로 빛 셋이 떠다닌다
function paintScene(w: number, h: number, a: number): void {
const g = sctx.createLinearGradient(0, 0, w, h);
g.addColorStop(0, grayStr(0.02, 1));
g.addColorStop(1, grayStr(0.75, 1));
sctx.fillStyle = g;
sctx.fillRect(0, 0, w, h);
glow(w * (0.5 + 0.34 * Math.cos(a)), h * (0.4 + 0.3 * Math.sin(a)), 100, 1, 0.8);
glow(w * (0.5 - 0.3 * Math.sin(a)), h * (0.6 - 0.28 * Math.cos(2 * a)), 80, 0.2, 0.9);
glow(w * (0.5 + 0.36 * Math.sin(2 * a)), h * (0.78 + 0.1 * Math.cos(a)), 70, 0.95, 0.7);
}
function draw(): void {
paintPage();
const a = sceneT / LAP_S * TAU;
setSrc(PW2, PH, 1);
paintScene(PW2, PH, a);
const cw = cellW(FONT_PX);
const ch = cellH(FONT_PX);
const cols = Math.floor(PW2 / cw);
const rows = Math.floor(PH / ch);
const g = readGrid(cols, rows, cw, ch);
const cA: string[] = [];
const cB: string[] = [];
let nA = 0;
let nB = 0;
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const v = g.lum[r * cols + c];
const t = (ORDER_4[(r % ORDER_N) * ORDER_N + (c % ORDER_N)] + 0.5) / (ORDER_N * ORDER_N);
if (v > t) { cA.push(TWO_GLYPH.charAt(1)); nA += 1; } else { cA.push(TWO_GLYPH.charAt(0)); }
if (v > THRESHOLD) { cB.push(TWO_GLYPH.charAt(1)); nB += 1; } else { cB.push(TWO_GLYPH.charAt(0)); }
}
}
inkA = Math.round(100 * nA / (rows * cols));
inkB = Math.round(100 * nB / (rows * cols));
plainText = plainOf(g, cA);
const ox = (PW2 - cols * cw) / 2;
const oy = (PH - rows * ch) / 2;
panel(PX_L, PY, PW2, PH);
drawGlyphs(PX_L + ox, PY + oy, FONT_PX, g, cA, null, [GLYPH]);
panel(PX_R, PY, PW2, PH);
drawGlyphs(PX_R + ox, PY + oy, FONT_PX, g, cB, null, [GLYPH]);
tag('규칙 격자 {n}×{n}'.replace('{n}', String(ORDER_N)).replace('{n}', String(ORDER_N)), PX_L + 8, PY + 14, PAPER, INK, 10, 'left');
tag('문턱 하나 {t}'.replace('{t}', THRESHOLD.toFixed(1)), PX_R + 8, PY + 14, ROSE, INK, 10, 'left');
}
function statusText(): string {
return '짙은 글자 {a}% · 대비판 {b}%'.replace('{a}', String(inkA)).replace('{b}', String(inkB));
}
fitCanvas();
if (still) { showRest(); } else { sceneT = START_S; showNow(); startLoop(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'아스키 아트 시연 무대 — Enter 나 Space 로 일시정지, N 으로 한 걸음, R 로 처음부터, C 로 글자 판 복사'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'부드럽게 이어지는 명암 장면을 점과 채운 글자 두 개로만 찍는다. 왼쪽 판은 4×4 문턱 표를 칸 위치로 되풀이해 비교해 밝기가 점의 밀도로 읽히고, 오른쪽 판은 문턱 하나로 잘라 경계만 남는다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'아스키 아트 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
<button className={cx("vj__copy")} data-role="copy" type="button">{'복사'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}04 · 二文字だけで明暗を表す順序ディザ — 計測 累積面積 13.044% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const v = g.lum[r * cols + c];
const t = (ORDER_4[(r % ORDER_N) * ORDER_N + (c % ORDER_N)] + 0.5) / (ORDER_N * ORDER_N);
if (v > t) {
cA.push(TWO_GLYPH.charAt(1));
nA += 1;
}
else {
cA.push(TWO_GLYPH.charAt(0));
}
if (v > THRESHOLD) {
cB.push(TWO_GLYPH.charAt(1));
nB += 1;
}
else {
cB.push(TWO_GLYPH.charAt(0));
}
}
}
05明るさが変わる向きを読んでスラッシュや縦線を選ぶ輪郭
輪郭がはっきり必要なロゴや図形を文字にするときに使います。gradientAt が左右のセルの明るさの差と上下のセルの明るさの差から明るさの勾配を求め、強さが EDGE_MIN 0.012を超えたセルは接線の角を4区間に分けて、横線・バックスラッシュ・縦線・スラッシュのどれかを打ちます。セル760個のうち境界の記号は平均275セルで、記号4種がすべて出ており、右の盤は明るさの段階表だけを読むので輪郭がにじみます。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="アスキーアートの舞台 — Enter か Space で一時停止、N で一歩、R でやり直し、C で文字盤をコピー">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="回る六角形と三角形、動く円と棒のある場面で、左の盤は隣のセルとの明るさの差の向きを読んで輪郭に横線・縦線・スラッシュ・バックスラッシュの記号を打ち、右の盤は明るさだけを読んで輪郭がにじみます"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">アスキーアートのデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
<button class="vj__copy" data-role="copy" type="button">コピー</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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-cream;
}
// 무대 상자 — 여기서 시작한 터치만 칸 누르기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step,
.vj__copy {
padding: $sp-1 $sp-2;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.vj__step,
.vj__copy {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover,
.vj__copy:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active,
.vj__copy:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible,
.vj__copy:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 214px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__copy,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step,
.vj__copy { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active,
.vj__copy:hover,
.vj__copy:active { transform: none !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);
}
.vj {
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: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step,
.vj__copy {
padding: 4px 8px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__step,
.vj__copy {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover,
.vj__copy:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active,
.vj__copy:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible,
.vj__copy:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 214px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__copy,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step,
.vj__copy {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active,
.vj__copy:hover,
.vj__copy:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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(a, b, v) { const k = clamp((v - a) / (b - a), 0, 1); return k * k * (3 - 2 * k); }
// 파스텔 팔레트 — 글자 판은 짙은 남보라 바탕 위에 옅은 라벤더 글자(대비 12 이상), 바깥 무대는 크림 라벤더
const PAGE = [246, 243, 252];
const BOARD = [34, 30, 56];
const BOARD_EDGE = [74, 68, 112];
const GLYPH = [226, 219, 250];
const MINT = [168, 226, 204];
const ROSE = [248, 176, 198];
const BUTTER = [250, 226, 150];
const SKY = [170, 208, 248];
const LAV = [196, 184, 244];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function grayStr(l, a) {
const v = Math.round(clamp(l, 0, 1) * 255);
return 'rgba(' + v + ',' + v + ',' + v + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초) — 한 바퀴 LAP_S 초이고 장면의 모든 움직임은 위상 ph = sceneT / LAP_S 의 정수배라 바퀴 끝에서 이음매가 없다
let sceneT = 0;
let lap = 0;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 고정폭 글꼴 목록만
const MONO = 'ui-monospace, "Cascadia Mono", Consolas, Menlo, "DejaVu Sans Mono", "Courier New", monospace';
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
// 글자 한 줄 높이 = 글자 크기의 1.2 배. 폭은 글꼴에서 잰 값(advRatio)을 쓴다
const LINE_K = 1.2;
let advRatio = 0.6;
// ── 원본 장면 캔버스 — 화면에 붙이지 않는 두 번째 캔버스. 장면은 여기 그리고 화소를 읽는다 ──
const srcCv = document.createElement('canvas');
const srcRaw = srcCv.getContext('2d', { willReadFrequently: true });
if (!srcRaw) {
return;
}
const sctx = srcRaw;
// ss = 원본 화소 / 판 화소. 1 이면 판 크기 그대로, 0.5 면 가로세로 절반 해상도로 계산해 읽는다
let srcSs = 1;
function setSrc(w, h, ss) {
const sw = Math.round(w * ss);
const sh = Math.round(h * ss);
if (srcCv.width !== sw || srcCv.height !== sh) {
srcCv.width = sw;
srcCv.height = sh;
}
srcSs = ss;
sctx.setTransform(ss, 0, 0, ss, 0, 0);
}
// cols×rows 칸을 왼쪽 위부터 읽는다. 칸 하나가 원본에서 덮는 영역은 가로 cw, 세로 sh (판 화소 단위)
function readGrid(cols, rows, cw, sh) {
const sw = srcCv.width;
const shp = srcCv.height;
const data = sctx.getImageData(0, 0, sw, shp).data;
const grid = { cols: cols, rows: rows, cw: cw, sh: sh, lum: [], r: [], g: [], b: [] };
for (let cy = 0; cy < rows; cy += 1) {
const y0 = Math.min(shp - 1, Math.floor(cy * sh * srcSs));
const y1 = Math.max(y0 + 1, Math.min(shp, Math.floor((cy + 1) * sh * srcSs)));
for (let cx = 0; cx < cols; cx += 1) {
const x0 = Math.min(sw - 1, Math.floor(cx * cw * srcSs));
const x1 = Math.max(x0 + 1, Math.min(sw, Math.floor((cx + 1) * cw * srcSs)));
let sr = 0;
let sg = 0;
let sb = 0;
let n = 0;
for (let y = y0; y < y1; y += 1) {
let p = (y * sw + x0) * 4;
for (let x = x0; x < x1; x += 1) {
sr += data[p];
sg += data[p + 1];
sb += data[p + 2];
n += 1;
p += 4;
}
}
const mr = sr / n;
const mg = sg / n;
const mb = sb / n;
grid.r.push(mr);
grid.g.push(mg);
grid.b.push(mb);
grid.lum.push((0.2126 * mr + 0.7152 * mg + 0.0722 * mb) / 255);
}
}
return grid;
}
// ── 글자 램프 — 성긴 글자에서 짙은 글자 순서 열여섯 개. n 단계 램프는 이 줄에서 고르게 n 개를 뽑는다 ──
const RAMP_FULL = ' .,:;-~=+*ox#%&@';
function rampOf(n) {
let s = '';
for (let i = 0; i < n; i += 1) {
s += RAMP_FULL.charAt(Math.round(i * (RAMP_FULL.length - 1) / (n - 1)));
}
return s;
}
// 밝기 v(0 에서 1)를 램프 번호로 — 단계 폭 1/n 으로 나눠 내림
function glyphIndex(v, n) { return Math.min(n - 1, Math.max(0, Math.floor(v * n))); }
// 칸 크기 — 글자 크기 fp 를 주면 폭은 글꼴에서 잰 글자 폭, 높이는 줄 높이
function cellW(fp) { return advRatio * fp; }
function cellH(fp) { return LINE_K * fp; }
function measureAdv() {
ctx.font = '100px ' + MONO;
const m = ctx.measureText('M').width / 100;
advRatio = m > 0.3 && m < 0.9 ? m : 0.6;
}
// ── 그리기 도구 ──
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
function paintPage() {
ctx.fillStyle = rgba(PAGE, 1);
ctx.fillRect(0, 0, W, H);
}
// 글자 판 바탕 — 짙은 남보라 둥근 사각형
function panel(x, y, w, h) {
box(x - 1, y - 1, w + 2, h + 2, 11, BOARD_EDGE, 1);
box(x, y, w, h, 10, BOARD, 1);
}
// 글자 판 그리기 — chars 는 칸마다 글자 하나(왼쪽 위부터 줄 순서). colorIdx 가 있으면 칸마다 팔레트 번호, 없으면 모두 pal[0].
// 같은 색이 이어지는 칸은 한 번에 찍어 한 판에 fillText 가 줄 수 × 색 조각 수만큼만 불린다
function drawGlyphs(x, y, fp, g, chars, colorIdx, pal) {
const cw = cellW(fp);
const ch = cellH(fp);
ctx.font = '600 ' + fp + 'px ' + MONO;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
for (let r = 0; r < g.rows; r += 1) {
let c = 0;
while (c < g.cols) {
const ci = colorIdx ? colorIdx[r * g.cols + c] : 0;
let run = '';
let e = c;
while (e < g.cols && (colorIdx ? colorIdx[r * g.cols + e] : 0) === ci) {
run += chars[r * g.cols + e];
e += 1;
}
if (run.trim().length > 0) {
ctx.fillStyle = rgba(pal[ci], 1);
ctx.fillText(run, x + c * cw, y + (r + 0.5) * ch + 0.5);
}
c = e;
}
}
}
// 판의 글자를 줄바꿈으로 이은 텍스트 — コピー 단추가 이 글을 클립보드에 넣는다
function plainOf(g, chars) {
const lines = [];
for (let r = 0; r < g.rows; r += 1) {
lines.push(chars.slice(r * g.cols, (r + 1) * g.cols).join('').replace(/\s+$/, ''));
}
return lines.join('\n');
}
// 쓰인 서로 다른 글자 수(공백 포함)와 글자별 횟수
function usage(chars, ramp) {
const out = [];
for (let i = 0; i < ramp.length; i += 1) {
out.push(0);
}
for (const c of chars) {
const k = ramp.indexOf(c);
if (k >= 0) {
out[k] += 1;
}
}
return out;
}
function kinds(u) {
let n = 0;
for (const v of u) {
if (v > 0) {
n += 1;
}
}
return n;
}
// 글자별 사용 횟수 막대 — 판 아래 띠에 램프 순서대로
function usageBars(x, y, w, h, u, c) {
let mx = 1;
for (const v of u) {
if (v > mx) {
mx = v;
}
}
const bw = w / u.length;
for (let i = 0; i < u.length; i += 1) {
const bh = Math.max(u[i] > 0 ? 2 : 0, h * u[i] / mx);
box(x + i * bw + 1, y + h - bh, bw - 2, bh, 2, c, 0.95);
}
}
// 두 번째 캔버스에 쓰는 회색 장면 도구 — 중심에서 번지는 둥근 빛
function glow(x, y, r, l, a) {
const g = sctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, grayStr(l, a));
g.addColorStop(1, grayStr(l, 0));
sctx.fillStyle = g;
sctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function vgrad(w, h, l0, l1) {
const g = sctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, grayStr(l0, 1));
g.addColorStop(1, grayStr(l1, 1));
sctx.fillStyle = g;
sctx.fillRect(0, 0, w, h);
}
// 판 배치 — 위 46 은 상태 줄·단추 자리. 판 둘이면 가로 228, 하나면 464
const PY = 46;
const PH = 246;
const PW2 = 228;
const PW1 = 464;
const PX_L = 8;
const PX_R = 244;
// コピー 단추를 누른 뒤 상태 줄에 잠깐 뜨는 말
const COPY_OK = '文字盤をテキストでコピーしました';
const COPY_FAIL = 'コピーできませんでした — 文字盤を選んで手でコピーしてください';
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const copyEl = root.querySelector('[data-role="copy"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !copyEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const copyBtn = copyEl;
const doneP = doneEl;
const pad = padEl;
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
measureAdv();
}
// 지금 화면에 찍힌 글자 판 — コピー 단추가 이 텍스트를 넣는다
let plainText = '';
let copyFlash = 0;
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
if (copyFlash > 0) {
copyFlash = Math.max(0, copyFlash - dt);
}
if (sceneT >= LAP_S) {
sceneT -= LAP_S;
lap += 1;
}
}
const DT_MAX = 0.05;
const STEP_S = 0.4;
function showNow() {
draw();
pushStatus(copyFlash > 0 ? copyNote : statusText());
checkDone();
}
let copyNote = '';
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 장면이 한창인 순간의 정지 장면 한 장
function showRest() {
stopLoop();
sceneT = REST_S;
showNow();
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
function restart() {
wake();
sceneT = START_S;
lap = 0;
doneSaid = false;
showNow();
}
// 一歩進める — 멈춘 채 장면 시계를 0.4 초 앞으로
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
advance(STEP_S);
showNow();
}
// コピー — 화면에 있는 글자 판을 텍스트로 클립보드에. 임시 입력칸을 만들어 선택하고 コピー 명령을 부른다
function copyBoard() {
wake();
const ta = document.createElement('textarea');
ta.value = plainText;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
let ok = false;
try {
ok = document.execCommand('copy');
}
catch (err) {
ok = false;
}
document.body.removeChild(ta);
copyNote = ok ? COPY_OK : COPY_FAIL;
copyFlash = 1.6;
pushStatus(copyNote);
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
copyBtn.addEventListener('click', copyBoard, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space·P 는 一時停止, N 은 一歩進める, R 은 처음부터, C 는 글자 판 コピー
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
}
else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
}
else if (k === 'r' || k === 'R') {
e.preventDefault();
restart();
}
else if (k === 'c' || k === 'C') {
e.preventDefault();
copyBoard();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 一時停止 토글
pad.addEventListener('click', function () { togglePause(); }, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
sceneT = START_S;
showNow();
if (!paused) {
startLoop();
}
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 05 밝기가 변하는 방향을 읽어 슬래시와 세로선을 고르는 가장자리 ───────────
const LAP_S = 10;
const START_S = 0.6;
const REST_S = 2.9;
// 칸 크기 — 글자 한 줄의 높이(px). 글자 크기는 이 값을 줄 높이 비율로 나눈 값이고 칸 폭은 글꼴에서 잰다
const CELL_PX = 12;
const FONT_PX = CELL_PX / LINE_K;
// 경계로 치는 최소 세기 — 픽셀 하나당 밝기 변화량
const EDGE_MIN = 0.012;
// 방향 구간 수 — 접선 방향을 180°를 4 등분해 가로선 · 역슬래시 · 세로선 · 슬래시 중 하나로
const ANGLE_BIN = 4;
const EDGE_SYMBOLS = '-\\|/';
const FILL_RAMP = ' .:';
const RAMP = rampOf(10);
let edgeCount = 0;
let symbolKinds = 0;
let cellPx = CELL_PX;
// 이웃 칸과의 밝기 차 — 가로는 좌우 칸, 세로는 위아래 칸의 평균 밝기 차를 칸의 화소 거리로 나눈다(칸이 가로세로 다르므로 따로)
function gradientAt(g, c, r, ch) {
const x0 = Math.max(0, c - 1);
const x1 = Math.min(g.cols - 1, c + 1);
const y0 = Math.max(0, r - 1);
const y1 = Math.min(g.rows - 1, r + 1);
const gx = (g.lum[r * g.cols + x1] - g.lum[r * g.cols + x0]) / ((x1 - x0) * g.cw);
const gy = (g.lum[y1 * g.cols + c] - g.lum[y0 * g.cols + c]) / ((y1 - y0) * ch);
return [gx, gy];
}
function polygon(cx, cy, rad, n, rot) {
sctx.beginPath();
for (let k = 0; k < n; k += 1) {
const an = rot + k * TAU / n;
if (k === 0) {
sctx.moveTo(cx + rad * Math.cos(an), cy + rad * Math.sin(an));
}
else {
sctx.lineTo(cx + rad * Math.cos(an), cy + rad * Math.sin(an));
}
}
sctx.closePath();
}
// 원본 장면 — 어두운 바탕 위 육각형·삼각형·원·막대. 윤곽 방향이 모든 각도로 나온다
function paintScene(w, h, a) {
vgrad(w, h, 0.1, 0.2);
polygon(w * 0.32, h * 0.28, 44, 6, a);
sctx.fillStyle = grayStr(0.82, 1);
sctx.fill();
sctx.beginPath();
sctx.arc(w * 0.7 + 16 * Math.cos(a), h * 0.26 + 14 * Math.sin(2 * a), 30, 0, TAU);
sctx.fillStyle = grayStr(0.62, 1);
sctx.fill();
polygon(w * 0.5, h * 0.7, 52, 3, -a * 2);
sctx.fillStyle = grayStr(0.92, 1);
sctx.fill();
sctx.fillStyle = grayStr(0.7, 1);
sctx.fillRect(w * 0.08 + 10 * Math.sin(a), h * 0.58, 22, 80);
sctx.fillRect(w * 0.82 - 10 * Math.sin(a), h * 0.55, 22, 80);
}
function draw() {
paintPage();
const a = sceneT / LAP_S * TAU;
setSrc(PW2, PH, 1);
paintScene(PW2, PH, a);
const cw = cellW(FONT_PX);
const ch = cellH(FONT_PX);
cellPx = ch;
const cols = Math.floor(PW2 / cw);
const rows = Math.floor(PH / ch);
const g = readGrid(cols, rows, cw, ch);
const cA = [];
const cB = [];
let edges = 0;
const seen = [false, false, false, false];
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const v = g.lum[r * cols + c];
const gr = gradientAt(g, c, r, ch);
const mag = Math.hypot(gr[0], gr[1]);
if (mag > EDGE_MIN) {
// 접선 = 기울기 방향 + 90°. 0 에서 180° 로 접어 구간 번호를 구한다
let phi = Math.atan2(gr[1], gr[0]) + Math.PI / 2;
phi = ((phi % Math.PI) + Math.PI) % Math.PI;
const bin = Math.floor((phi + Math.PI / (2 * ANGLE_BIN)) / (Math.PI / ANGLE_BIN)) % ANGLE_BIN;
cA.push(EDGE_SYMBOLS.charAt(bin));
seen[bin] = true;
edges += 1;
}
else {
cA.push(FILL_RAMP.charAt(glyphIndex(v, FILL_RAMP.length)));
}
cB.push(RAMP.charAt(glyphIndex(v, RAMP.length)));
}
}
edgeCount = edges;
symbolKinds = 0;
for (const s of seen) {
if (s) {
symbolKinds += 1;
}
}
plainText = plainOf(g, cA);
const ox = (PW2 - cols * cw) / 2;
const oy = (PH - rows * ch) / 2;
panel(PX_L, PY, PW2, PH);
drawGlyphs(PX_L + ox, PY + oy, FONT_PX, g, cA, null, [GLYPH]);
panel(PX_R, PY, PW2, PH);
drawGlyphs(PX_R + ox, PY + oy, FONT_PX, g, cB, null, [GLYPH]);
tag('向きの記号', PX_L + 8, PY + 14, PAPER, INK, 10, 'left');
tag('明るさだけ (輪郭がにじむ)', PX_R + 8, PY + 14, ROSE, INK, 10, 'left');
}
function statusText() {
return 'セルの高さ {c}px・輪郭 {e}セル・記号 {k}種'.replace('{c}', cellPx.toFixed(0)).replace('{e}', String(edgeCount)).replace('{k}', String(symbolKinds));
}
fitCanvas();
if (still) {
showRest();
}
else {
sceneT = START_S;
showNow();
startLoop();
}
return function () {
stopLoop();
off.abort();
};
})();// GradientEdgeSlash - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./GradientEdgeSlash.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function GradientEdgeSlash() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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(a: number, b: number, v: number): number { const k = clamp((v - a) / (b - a), 0, 1); return k * k * (3 - 2 * k); }
// 파스텔 팔레트 — 글자 판은 짙은 남보라 바탕 위에 옅은 라벤더 글자(대비 12 이상), 바깥 무대는 크림 라벤더
const PAGE: RGB = [246, 243, 252];
const BOARD: RGB = [34, 30, 56];
const BOARD_EDGE: RGB = [74, 68, 112];
const GLYPH: RGB = [226, 219, 250];
const MINT: RGB = [168, 226, 204];
const ROSE: RGB = [248, 176, 198];
const BUTTER: RGB = [250, 226, 150];
const SKY: RGB = [170, 208, 248];
const LAV: RGB = [196, 184, 244];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function grayStr(l: number, a: number): string {
const v = Math.round(clamp(l, 0, 1) * 255);
return 'rgba(' + v + ',' + v + ',' + v + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초) — 한 바퀴 LAP_S 초이고 장면의 모든 움직임은 위상 ph = sceneT / LAP_S 의 정수배라 바퀴 끝에서 이음매가 없다
let sceneT = 0;
let lap = 0;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 고정폭 글꼴 목록만
const MONO = 'ui-monospace, "Cascadia Mono", Consolas, Menlo, "DejaVu Sans Mono", "Courier New", monospace';
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
// 글자 한 줄 높이 = 글자 크기의 1.2 배. 폭은 글꼴에서 잰 값(advRatio)을 쓴다
const LINE_K = 1.2;
let advRatio = 0.6;
// ── 원본 장면 캔버스 — 화면에 붙이지 않는 두 번째 캔버스. 장면은 여기 그리고 화소를 읽는다 ──
const srcCv: HTMLCanvasElement = document.createElement('canvas');
const srcRaw = srcCv.getContext('2d', { willReadFrequently: true });
if (!srcRaw) { return; }
const sctx: CanvasRenderingContext2D = srcRaw;
// ss = 원본 화소 / 판 화소. 1 이면 판 크기 그대로, 0.5 면 가로세로 절반 해상도로 계산해 읽는다
let srcSs = 1;
function setSrc(w: number, h: number, ss: number): void {
const sw = Math.round(w * ss);
const sh = Math.round(h * ss);
if (srcCv.width !== sw || srcCv.height !== sh) {
srcCv.width = sw;
srcCv.height = sh;
}
srcSs = ss;
sctx.setTransform(ss, 0, 0, ss, 0, 0);
}
// 칸 격자 — 칸마다 원본 화소의 평균 밝기(lum, 0 에서 1)와 평균 색을 담는다
type Grid = { cols: number; rows: number; cw: number; sh: number; lum: number[]; r: number[]; g: number[]; b: number[] };
// cols×rows 칸을 왼쪽 위부터 읽는다. 칸 하나가 원본에서 덮는 영역은 가로 cw, 세로 sh (판 화소 단위)
function readGrid(cols: number, rows: number, cw: number, sh: number): Grid {
const sw = srcCv.width;
const shp = srcCv.height;
const data = sctx.getImageData(0, 0, sw, shp).data;
const grid: Grid = { cols: cols, rows: rows, cw: cw, sh: sh, lum: [], r: [], g: [], b: [] };
for (let cy = 0; cy < rows; cy += 1) {
const y0 = Math.min(shp - 1, Math.floor(cy * sh * srcSs));
const y1 = Math.max(y0 + 1, Math.min(shp, Math.floor((cy + 1) * sh * srcSs)));
for (let cx = 0; cx < cols; cx += 1) {
const x0 = Math.min(sw - 1, Math.floor(cx * cw * srcSs));
const x1 = Math.max(x0 + 1, Math.min(sw, Math.floor((cx + 1) * cw * srcSs)));
let sr = 0;
let sg = 0;
let sb = 0;
let n = 0;
for (let y = y0; y < y1; y += 1) {
let p = (y * sw + x0) * 4;
for (let x = x0; x < x1; x += 1) {
sr += data[p];
sg += data[p + 1];
sb += data[p + 2];
n += 1;
p += 4;
}
}
const mr = sr / n;
const mg = sg / n;
const mb = sb / n;
grid.r.push(mr);
grid.g.push(mg);
grid.b.push(mb);
grid.lum.push((0.2126 * mr + 0.7152 * mg + 0.0722 * mb) / 255);
}
}
return grid;
}
// ── 글자 램프 — 성긴 글자에서 짙은 글자 순서 열여섯 개. n 단계 램프는 이 줄에서 고르게 n 개를 뽑는다 ──
const RAMP_FULL = ' .,:;-~=+*ox#%&@';
function rampOf(n: number): string {
let s = '';
for (let i = 0; i < n; i += 1) { s += RAMP_FULL.charAt(Math.round(i * (RAMP_FULL.length - 1) / (n - 1))); }
return s;
}
// 밝기 v(0 에서 1)를 램프 번호로 — 단계 폭 1/n 으로 나눠 내림
function glyphIndex(v: number, n: number): number { return Math.min(n - 1, Math.max(0, Math.floor(v * n))); }
// 칸 크기 — 글자 크기 fp 를 주면 폭은 글꼴에서 잰 글자 폭, 높이는 줄 높이
function cellW(fp: number): number { return advRatio * fp; }
function cellH(fp: number): number { return LINE_K * fp; }
function measureAdv(): void {
ctx.font = '100px ' + MONO;
const m = ctx.measureText('M').width / 100;
advRatio = m > 0.3 && m < 0.9 ? m : 0.6;
}
// ── 그리기 도구 ──
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
function paintPage(): void {
ctx.fillStyle = rgba(PAGE, 1);
ctx.fillRect(0, 0, W, H);
}
// 글자 판 바탕 — 짙은 남보라 둥근 사각형
function panel(x: number, y: number, w: number, h: number): void {
box(x - 1, y - 1, w + 2, h + 2, 11, BOARD_EDGE, 1);
box(x, y, w, h, 10, BOARD, 1);
}
// 글자 판 그리기 — chars 는 칸마다 글자 하나(왼쪽 위부터 줄 순서). colorIdx 가 있으면 칸마다 팔레트 번호, 없으면 모두 pal[0].
// 같은 색이 이어지는 칸은 한 번에 찍어 한 판에 fillText 가 줄 수 × 색 조각 수만큼만 불린다
function drawGlyphs(x: number, y: number, fp: number, g: Grid, chars: string[], colorIdx: number[] | null, pal: RGB[]): void {
const cw = cellW(fp);
const ch = cellH(fp);
ctx.font = '600 ' + fp + 'px ' + MONO;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
for (let r = 0; r < g.rows; r += 1) {
let c = 0;
while (c < g.cols) {
const ci = colorIdx ? colorIdx[r * g.cols + c] : 0;
let run = '';
let e = c;
while (e < g.cols && (colorIdx ? colorIdx[r * g.cols + e] : 0) === ci) { run += chars[r * g.cols + e]; e += 1; }
if (run.trim().length > 0) {
ctx.fillStyle = rgba(pal[ci], 1);
ctx.fillText(run, x + c * cw, y + (r + 0.5) * ch + 0.5);
}
c = e;
}
}
}
// 판의 글자를 줄바꿈으로 이은 텍스트 — 복사 단추가 이 글을 클립보드에 넣는다
function plainOf(g: Grid, chars: string[]): string {
const lines: string[] = [];
for (let r = 0; r < g.rows; r += 1) {
lines.push(chars.slice(r * g.cols, (r + 1) * g.cols).join('').replace(/\s+$/, ''));
}
return lines.join('\n');
}
// 쓰인 서로 다른 글자 수(공백 포함)와 글자별 횟수
function usage(chars: string[], ramp: string): number[] {
const out: number[] = [];
for (let i = 0; i < ramp.length; i += 1) { out.push(0); }
for (const c of chars) {
const k = ramp.indexOf(c);
if (k >= 0) { out[k] += 1; }
}
return out;
}
function kinds(u: number[]): number {
let n = 0;
for (const v of u) { if (v > 0) { n += 1; } }
return n;
}
// 글자별 사용 횟수 막대 — 판 아래 띠에 램프 순서대로
function usageBars(x: number, y: number, w: number, h: number, u: number[], c: RGB): void {
let mx = 1;
for (const v of u) { if (v > mx) { mx = v; } }
const bw = w / u.length;
for (let i = 0; i < u.length; i += 1) {
const bh = Math.max(u[i] > 0 ? 2 : 0, h * u[i] / mx);
box(x + i * bw + 1, y + h - bh, bw - 2, bh, 2, c, 0.95);
}
}
// 두 번째 캔버스에 쓰는 회색 장면 도구 — 중심에서 번지는 둥근 빛
function glow(x: number, y: number, r: number, l: number, a: number): void {
const g = sctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, grayStr(l, a));
g.addColorStop(1, grayStr(l, 0));
sctx.fillStyle = g;
sctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function vgrad(w: number, h: number, l0: number, l1: number): void {
const g = sctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, grayStr(l0, 1));
g.addColorStop(1, grayStr(l1, 1));
sctx.fillStyle = g;
sctx.fillRect(0, 0, w, h);
}
// 판 배치 — 위 46 은 상태 줄·단추 자리. 판 둘이면 가로 228, 하나면 464
const PY = 46;
const PH = 246;
const PW2 = 228;
const PW1 = 464;
const PX_L = 8;
const PX_R = 244;
// 복사 단추를 누른 뒤 상태 줄에 잠깐 뜨는 말
const COPY_OK = '글자 판을 텍스트로 복사했다';
const COPY_FAIL = '복사하지 못했다 — 글자 판을 직접 선택해 복사';
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const copyEl = root.querySelector('[data-role="copy"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !copyEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const copyBtn: HTMLButtonElement = copyEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
measureAdv();
}
// 지금 화면에 찍힌 글자 판 — 복사 단추가 이 텍스트를 넣는다
let plainText = '';
let copyFlash = 0;
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
if (copyFlash > 0) { copyFlash = Math.max(0, copyFlash - dt); }
if (sceneT >= LAP_S) { sceneT -= LAP_S; lap += 1; }
}
const DT_MAX = 0.05;
const STEP_S = 0.4;
function showNow(): void {
draw();
pushStatus(copyFlash > 0 ? copyNote : statusText());
checkDone();
}
let copyNote = '';
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 장면이 한창인 순간의 정지 장면 한 장
function showRest(): void {
stopLoop();
sceneT = REST_S;
showNow();
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
function restart(): void {
wake();
sceneT = START_S;
lap = 0;
doneSaid = false;
showNow();
}
// 한 걸음 — 멈춘 채 장면 시계를 0.4 초 앞으로
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
advance(STEP_S);
showNow();
}
// 복사 — 화면에 있는 글자 판을 텍스트로 클립보드에. 임시 입력칸을 만들어 선택하고 복사 명령을 부른다
function copyBoard(): void {
wake();
const ta = document.createElement('textarea');
ta.value = plainText;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
let ok = false;
try { ok = document.execCommand('copy'); } catch (err) { ok = false; }
document.body.removeChild(ta);
copyNote = ok ? COPY_OK : COPY_FAIL;
copyFlash = 1.6;
pushStatus(copyNote);
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
copyBtn.addEventListener('click', copyBoard, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space·P 는 일시정지, N 은 한 걸음, R 은 처음부터, C 는 글자 판 복사
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
} else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
} else if (k === 'r' || k === 'R') {
e.preventDefault();
restart();
} else if (k === 'c' || k === 'C') {
e.preventDefault();
copyBoard();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 일시정지 토글
pad.addEventListener('click', function (): void { togglePause(); }, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { sceneT = START_S; showNow(); if (!paused) { startLoop(); } }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 05 밝기가 변하는 방향을 읽어 슬래시와 세로선을 고르는 가장자리 ───────────
const LAP_S = 10;
const START_S = 0.6;
const REST_S = 2.9;
// 칸 크기 — 글자 한 줄의 높이(px). 글자 크기는 이 값을 줄 높이 비율로 나눈 값이고 칸 폭은 글꼴에서 잰다
const CELL_PX = 12;
const FONT_PX = CELL_PX / LINE_K;
// 경계로 치는 최소 세기 — 픽셀 하나당 밝기 변화량
const EDGE_MIN = 0.012;
// 방향 구간 수 — 접선 방향을 180°를 4 등분해 가로선 · 역슬래시 · 세로선 · 슬래시 중 하나로
const ANGLE_BIN = 4;
const EDGE_SYMBOLS = '-\\|/';
const FILL_RAMP = ' .:';
const RAMP = rampOf(10);
let edgeCount = 0;
let symbolKinds = 0;
let cellPx = CELL_PX;
// 이웃 칸과의 밝기 차 — 가로는 좌우 칸, 세로는 위아래 칸의 평균 밝기 차를 칸의 화소 거리로 나눈다(칸이 가로세로 다르므로 따로)
function gradientAt(g: Grid, c: number, r: number, ch: number): number[] {
const x0 = Math.max(0, c - 1);
const x1 = Math.min(g.cols - 1, c + 1);
const y0 = Math.max(0, r - 1);
const y1 = Math.min(g.rows - 1, r + 1);
const gx = (g.lum[r * g.cols + x1] - g.lum[r * g.cols + x0]) / ((x1 - x0) * g.cw);
const gy = (g.lum[y1 * g.cols + c] - g.lum[y0 * g.cols + c]) / ((y1 - y0) * ch);
return [gx, gy];
}
function polygon(cx: number, cy: number, rad: number, n: number, rot: number): void {
sctx.beginPath();
for (let k = 0; k < n; k += 1) {
const an = rot + k * TAU / n;
if (k === 0) { sctx.moveTo(cx + rad * Math.cos(an), cy + rad * Math.sin(an)); } else { sctx.lineTo(cx + rad * Math.cos(an), cy + rad * Math.sin(an)); }
}
sctx.closePath();
}
// 원본 장면 — 어두운 바탕 위 육각형·삼각형·원·막대. 윤곽 방향이 모든 각도로 나온다
function paintScene(w: number, h: number, a: number): void {
vgrad(w, h, 0.1, 0.2);
polygon(w * 0.32, h * 0.28, 44, 6, a);
sctx.fillStyle = grayStr(0.82, 1);
sctx.fill();
sctx.beginPath();
sctx.arc(w * 0.7 + 16 * Math.cos(a), h * 0.26 + 14 * Math.sin(2 * a), 30, 0, TAU);
sctx.fillStyle = grayStr(0.62, 1);
sctx.fill();
polygon(w * 0.5, h * 0.7, 52, 3, -a * 2);
sctx.fillStyle = grayStr(0.92, 1);
sctx.fill();
sctx.fillStyle = grayStr(0.7, 1);
sctx.fillRect(w * 0.08 + 10 * Math.sin(a), h * 0.58, 22, 80);
sctx.fillRect(w * 0.82 - 10 * Math.sin(a), h * 0.55, 22, 80);
}
function draw(): void {
paintPage();
const a = sceneT / LAP_S * TAU;
setSrc(PW2, PH, 1);
paintScene(PW2, PH, a);
const cw = cellW(FONT_PX);
const ch = cellH(FONT_PX);
cellPx = ch;
const cols = Math.floor(PW2 / cw);
const rows = Math.floor(PH / ch);
const g = readGrid(cols, rows, cw, ch);
const cA: string[] = [];
const cB: string[] = [];
let edges = 0;
const seen: boolean[] = [false, false, false, false];
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const v = g.lum[r * cols + c];
const gr = gradientAt(g, c, r, ch);
const mag = Math.hypot(gr[0], gr[1]);
if (mag > EDGE_MIN) {
// 접선 = 기울기 방향 + 90°. 0 에서 180° 로 접어 구간 번호를 구한다
let phi = Math.atan2(gr[1], gr[0]) + Math.PI / 2;
phi = ((phi % Math.PI) + Math.PI) % Math.PI;
const bin = Math.floor((phi + Math.PI / (2 * ANGLE_BIN)) / (Math.PI / ANGLE_BIN)) % ANGLE_BIN;
cA.push(EDGE_SYMBOLS.charAt(bin));
seen[bin] = true;
edges += 1;
} else {
cA.push(FILL_RAMP.charAt(glyphIndex(v, FILL_RAMP.length)));
}
cB.push(RAMP.charAt(glyphIndex(v, RAMP.length)));
}
}
edgeCount = edges;
symbolKinds = 0;
for (const s of seen) { if (s) { symbolKinds += 1; } }
plainText = plainOf(g, cA);
const ox = (PW2 - cols * cw) / 2;
const oy = (PH - rows * ch) / 2;
panel(PX_L, PY, PW2, PH);
drawGlyphs(PX_L + ox, PY + oy, FONT_PX, g, cA, null, [GLYPH]);
panel(PX_R, PY, PW2, PH);
drawGlyphs(PX_R + ox, PY + oy, FONT_PX, g, cB, null, [GLYPH]);
tag('방향 기호', PX_L + 8, PY + 14, PAPER, INK, 10, 'left');
tag('밝기만 (윤곽이 번짐)', PX_R + 8, PY + 14, ROSE, INK, 10, 'left');
}
function statusText(): string {
return '칸 높이 {c}px · 경계 {e}칸 · 기호 {k}종'.replace('{c}', cellPx.toFixed(0)).replace('{e}', String(edgeCount)).replace('{k}', String(symbolKinds));
}
fitCanvas();
if (still) { showRest(); } else { sceneT = START_S; showNow(); startLoop(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'아스키 아트 시연 무대 — Enter 나 Space 로 일시정지, N 으로 한 걸음, R 로 처음부터, C 로 글자 판 복사'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'회전하는 육각형과 삼각형, 움직이는 원과 막대가 있는 장면에서 이웃 칸과의 밝기 차 방향을 읽어 윤곽에 가로선, 세로선, 슬래시, 역슬래시 기호를 찍은 왼쪽 판과 밝기만 읽어 윤곽이 번진 오른쪽 판'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'아스키 아트 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
<button className={cx("vj__copy")} data-role="copy" type="button">{'복사'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}05 · 明るさが変わる向きを読んでスラッシュや縦線を選ぶ輪郭 — 計測 累積面積 15.004% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const v = g.lum[r * cols + c];
const gr = gradientAt(g, c, r, ch);
const mag = Math.hypot(gr[0], gr[1]);
if (mag > EDGE_MIN) {
// 接線 = 勾配の向き + 90°。0から180°へ折りたたんで区間の番号を求める
let phi = Math.atan2(gr[1], gr[0]) + Math.PI / 2;
phi = ((phi % Math.PI) + Math.PI) % Math.PI;
const bin = Math.floor((phi + Math.PI / (2 * ANGLE_BIN)) / (Math.PI / ANGLE_BIN)) % ANGLE_BIN;
cA.push(EDGE_SYMBOLS.charAt(bin));
seen[bin] = true;
edges += 1;
}
else {
cA.push(FILL_RAMP.charAt(glyphIndex(v, FILL_RAMP.length)));
}
cB.push(RAMP.charAt(glyphIndex(v, RAMP.length)));
}
}
06動くプラズマをフレームごとに文字として読み直す文字盤
ターミナル風のランディング背景やローディング画面に使います。sineSum が横・縦・斜めの正弦波と、動く中心から広がる輪の正弦波を足した値を半分の解像度の元の絵に書き、readGrid がそれを読み直して79×20セルの12段階の文字にします。12段階の文字は平均で12種すべて使われ、一コマの draw はこのPCのChromeで平均約2.1msかかりました。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="アスキーアートの舞台 — Enter か Space で一時停止、N で一歩、R でやり直し、C で文字盤をコピー">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="横・縦・斜めの波と動く中心から広がる輪の四つの正弦波を足したプラズマの場を、フレームごとに読み直して十二段階の文字で打ち、明るさの帯ごとに文字の色を上げた盤がひとつあります"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">アスキーアートのデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
<button class="vj__copy" data-role="copy" type="button">コピー</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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-cream;
}
// 무대 상자 — 여기서 시작한 터치만 칸 누르기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step,
.vj__copy {
padding: $sp-1 $sp-2;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.vj__step,
.vj__copy {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover,
.vj__copy:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active,
.vj__copy:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible,
.vj__copy:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 214px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__copy,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step,
.vj__copy { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active,
.vj__copy:hover,
.vj__copy:active { transform: none !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);
}
.vj {
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: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step,
.vj__copy {
padding: 4px 8px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__step,
.vj__copy {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover,
.vj__copy:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active,
.vj__copy:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible,
.vj__copy:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 214px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__copy,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step,
.vj__copy {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active,
.vj__copy:hover,
.vj__copy:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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(a, b, v) { const k = clamp((v - a) / (b - a), 0, 1); return k * k * (3 - 2 * k); }
// 파스텔 팔레트 — 글자 판은 짙은 남보라 바탕 위에 옅은 라벤더 글자(대비 12 이상), 바깥 무대는 크림 라벤더
const PAGE = [246, 243, 252];
const BOARD = [34, 30, 56];
const BOARD_EDGE = [74, 68, 112];
const GLYPH = [226, 219, 250];
const MINT = [168, 226, 204];
const ROSE = [248, 176, 198];
const BUTTER = [250, 226, 150];
const SKY = [170, 208, 248];
const LAV = [196, 184, 244];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function grayStr(l, a) {
const v = Math.round(clamp(l, 0, 1) * 255);
return 'rgba(' + v + ',' + v + ',' + v + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초) — 한 바퀴 LAP_S 초이고 장면의 모든 움직임은 위상 ph = sceneT / LAP_S 의 정수배라 바퀴 끝에서 이음매가 없다
let sceneT = 0;
let lap = 0;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 고정폭 글꼴 목록만
const MONO = 'ui-monospace, "Cascadia Mono", Consolas, Menlo, "DejaVu Sans Mono", "Courier New", monospace';
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
// 글자 한 줄 높이 = 글자 크기의 1.2 배. 폭은 글꼴에서 잰 값(advRatio)을 쓴다
const LINE_K = 1.2;
let advRatio = 0.6;
// ── 원본 장면 캔버스 — 화면에 붙이지 않는 두 번째 캔버스. 장면은 여기 그리고 화소를 읽는다 ──
const srcCv = document.createElement('canvas');
const srcRaw = srcCv.getContext('2d', { willReadFrequently: true });
if (!srcRaw) {
return;
}
const sctx = srcRaw;
// ss = 원본 화소 / 판 화소. 1 이면 판 크기 그대로, 0.5 면 가로세로 절반 해상도로 계산해 읽는다
let srcSs = 1;
function setSrc(w, h, ss) {
const sw = Math.round(w * ss);
const sh = Math.round(h * ss);
if (srcCv.width !== sw || srcCv.height !== sh) {
srcCv.width = sw;
srcCv.height = sh;
}
srcSs = ss;
sctx.setTransform(ss, 0, 0, ss, 0, 0);
}
// cols×rows 칸을 왼쪽 위부터 읽는다. 칸 하나가 원본에서 덮는 영역은 가로 cw, 세로 sh (판 화소 단위)
function readGrid(cols, rows, cw, sh) {
const sw = srcCv.width;
const shp = srcCv.height;
const data = sctx.getImageData(0, 0, sw, shp).data;
const grid = { cols: cols, rows: rows, cw: cw, sh: sh, lum: [], r: [], g: [], b: [] };
for (let cy = 0; cy < rows; cy += 1) {
const y0 = Math.min(shp - 1, Math.floor(cy * sh * srcSs));
const y1 = Math.max(y0 + 1, Math.min(shp, Math.floor((cy + 1) * sh * srcSs)));
for (let cx = 0; cx < cols; cx += 1) {
const x0 = Math.min(sw - 1, Math.floor(cx * cw * srcSs));
const x1 = Math.max(x0 + 1, Math.min(sw, Math.floor((cx + 1) * cw * srcSs)));
let sr = 0;
let sg = 0;
let sb = 0;
let n = 0;
for (let y = y0; y < y1; y += 1) {
let p = (y * sw + x0) * 4;
for (let x = x0; x < x1; x += 1) {
sr += data[p];
sg += data[p + 1];
sb += data[p + 2];
n += 1;
p += 4;
}
}
const mr = sr / n;
const mg = sg / n;
const mb = sb / n;
grid.r.push(mr);
grid.g.push(mg);
grid.b.push(mb);
grid.lum.push((0.2126 * mr + 0.7152 * mg + 0.0722 * mb) / 255);
}
}
return grid;
}
// ── 글자 램프 — 성긴 글자에서 짙은 글자 순서 열여섯 개. n 단계 램프는 이 줄에서 고르게 n 개를 뽑는다 ──
const RAMP_FULL = ' .,:;-~=+*ox#%&@';
function rampOf(n) {
let s = '';
for (let i = 0; i < n; i += 1) {
s += RAMP_FULL.charAt(Math.round(i * (RAMP_FULL.length - 1) / (n - 1)));
}
return s;
}
// 밝기 v(0 에서 1)를 램프 번호로 — 단계 폭 1/n 으로 나눠 내림
function glyphIndex(v, n) { return Math.min(n - 1, Math.max(0, Math.floor(v * n))); }
// 칸 크기 — 글자 크기 fp 를 주면 폭은 글꼴에서 잰 글자 폭, 높이는 줄 높이
function cellW(fp) { return advRatio * fp; }
function cellH(fp) { return LINE_K * fp; }
function measureAdv() {
ctx.font = '100px ' + MONO;
const m = ctx.measureText('M').width / 100;
advRatio = m > 0.3 && m < 0.9 ? m : 0.6;
}
// ── 그리기 도구 ──
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
function paintPage() {
ctx.fillStyle = rgba(PAGE, 1);
ctx.fillRect(0, 0, W, H);
}
// 글자 판 바탕 — 짙은 남보라 둥근 사각형
function panel(x, y, w, h) {
box(x - 1, y - 1, w + 2, h + 2, 11, BOARD_EDGE, 1);
box(x, y, w, h, 10, BOARD, 1);
}
// 글자 판 그리기 — chars 는 칸마다 글자 하나(왼쪽 위부터 줄 순서). colorIdx 가 있으면 칸마다 팔레트 번호, 없으면 모두 pal[0].
// 같은 색이 이어지는 칸은 한 번에 찍어 한 판에 fillText 가 줄 수 × 색 조각 수만큼만 불린다
function drawGlyphs(x, y, fp, g, chars, colorIdx, pal) {
const cw = cellW(fp);
const ch = cellH(fp);
ctx.font = '600 ' + fp + 'px ' + MONO;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
for (let r = 0; r < g.rows; r += 1) {
let c = 0;
while (c < g.cols) {
const ci = colorIdx ? colorIdx[r * g.cols + c] : 0;
let run = '';
let e = c;
while (e < g.cols && (colorIdx ? colorIdx[r * g.cols + e] : 0) === ci) {
run += chars[r * g.cols + e];
e += 1;
}
if (run.trim().length > 0) {
ctx.fillStyle = rgba(pal[ci], 1);
ctx.fillText(run, x + c * cw, y + (r + 0.5) * ch + 0.5);
}
c = e;
}
}
}
// 판의 글자를 줄바꿈으로 이은 텍스트 — コピー 단추가 이 글을 클립보드에 넣는다
function plainOf(g, chars) {
const lines = [];
for (let r = 0; r < g.rows; r += 1) {
lines.push(chars.slice(r * g.cols, (r + 1) * g.cols).join('').replace(/\s+$/, ''));
}
return lines.join('\n');
}
// 쓰인 서로 다른 글자 수(공백 포함)와 글자별 횟수
function usage(chars, ramp) {
const out = [];
for (let i = 0; i < ramp.length; i += 1) {
out.push(0);
}
for (const c of chars) {
const k = ramp.indexOf(c);
if (k >= 0) {
out[k] += 1;
}
}
return out;
}
function kinds(u) {
let n = 0;
for (const v of u) {
if (v > 0) {
n += 1;
}
}
return n;
}
// 글자별 사용 횟수 막대 — 판 아래 띠에 램프 순서대로
function usageBars(x, y, w, h, u, c) {
let mx = 1;
for (const v of u) {
if (v > mx) {
mx = v;
}
}
const bw = w / u.length;
for (let i = 0; i < u.length; i += 1) {
const bh = Math.max(u[i] > 0 ? 2 : 0, h * u[i] / mx);
box(x + i * bw + 1, y + h - bh, bw - 2, bh, 2, c, 0.95);
}
}
// 두 번째 캔버스에 쓰는 회색 장면 도구 — 중심에서 번지는 둥근 빛
function glow(x, y, r, l, a) {
const g = sctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, grayStr(l, a));
g.addColorStop(1, grayStr(l, 0));
sctx.fillStyle = g;
sctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function vgrad(w, h, l0, l1) {
const g = sctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, grayStr(l0, 1));
g.addColorStop(1, grayStr(l1, 1));
sctx.fillStyle = g;
sctx.fillRect(0, 0, w, h);
}
// 판 배치 — 위 46 은 상태 줄·단추 자리. 판 둘이면 가로 228, 하나면 464
const PY = 46;
const PH = 246;
const PW2 = 228;
const PW1 = 464;
const PX_L = 8;
const PX_R = 244;
// コピー 단추를 누른 뒤 상태 줄에 잠깐 뜨는 말
const COPY_OK = '文字盤をテキストでコピーしました';
const COPY_FAIL = 'コピーできませんでした — 文字盤を選んで手でコピーしてください';
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const copyEl = root.querySelector('[data-role="copy"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !copyEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const copyBtn = copyEl;
const doneP = doneEl;
const pad = padEl;
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
measureAdv();
}
// 지금 화면에 찍힌 글자 판 — コピー 단추가 이 텍스트를 넣는다
let plainText = '';
let copyFlash = 0;
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
if (copyFlash > 0) {
copyFlash = Math.max(0, copyFlash - dt);
}
if (sceneT >= LAP_S) {
sceneT -= LAP_S;
lap += 1;
}
}
const DT_MAX = 0.05;
const STEP_S = 0.4;
function showNow() {
draw();
pushStatus(copyFlash > 0 ? copyNote : statusText());
checkDone();
}
let copyNote = '';
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 장면이 한창인 순간의 정지 장면 한 장
function showRest() {
stopLoop();
sceneT = REST_S;
showNow();
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
function restart() {
wake();
sceneT = START_S;
lap = 0;
doneSaid = false;
showNow();
}
// 一歩進める — 멈춘 채 장면 시계를 0.4 초 앞으로
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
advance(STEP_S);
showNow();
}
// コピー — 화면에 있는 글자 판을 텍스트로 클립보드에. 임시 입력칸을 만들어 선택하고 コピー 명령을 부른다
function copyBoard() {
wake();
const ta = document.createElement('textarea');
ta.value = plainText;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
let ok = false;
try {
ok = document.execCommand('copy');
}
catch (err) {
ok = false;
}
document.body.removeChild(ta);
copyNote = ok ? COPY_OK : COPY_FAIL;
copyFlash = 1.6;
pushStatus(copyNote);
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
copyBtn.addEventListener('click', copyBoard, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space·P 는 一時停止, N 은 一歩進める, R 은 처음부터, C 는 글자 판 コピー
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
}
else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
}
else if (k === 'r' || k === 'R') {
e.preventDefault();
restart();
}
else if (k === 'c' || k === 'C') {
e.preventDefault();
copyBoard();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 一時停止 토글
pad.addEventListener('click', function () { togglePause(); }, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
sceneT = START_S;
showNow();
if (!paused) {
startLoop();
}
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 06 움직이는 플라즈마를 프레임마다 글자로 다시 읽는 파동 장 ───────────────
const LAP_S = 10;
const START_S = 0.6;
const REST_S = 3.6;
const FONT_PX = 10;
const RAMP_LEN = 12;
const RAMP = rampOf(RAMP_LEN);
// 원본 장면은 절반 해상도로 계산해 읽는다 — 화소 수가 4분의 1
const FIELD_SS = 0.5;
// 파동 겹 수 — sineSum 이 더하는 사인 항의 개수
const SINE_TERMS = 4;
// 색 단계 — 밝기를 다섯 구간으로 나눠 구간마다 글자 색을 올린다(어두운 구간도 판 바탕 대비 4.5 이상)
const SHADES = [[150, 140, 214], [176, 164, 236], [196, 184, 244], [214, 222, 250], [168, 226, 204]];
let img = null;
let lastCols = 0;
let lastRows = 0;
let lastKinds = 0;
// 판 좌표 (x, y) 와 위상 a 에서의 플라즈마 값 — 가로 물결·세로 물결·대각 물결·움직이는 중심에서 퍼지는 고리의 합, 0 에서 1
function sineSum(x, y, a) {
const s1 = Math.sin(x * 0.04 + a);
const s2 = Math.sin(y * 0.065 - a);
const s3 = Math.sin((x + y) * 0.028 + 2 * a);
const s4 = Math.sin(Math.hypot(x - 232 + 120 * Math.cos(a), y - 126 + 60 * Math.sin(a)) * 0.05 - 2 * a);
return clamp(((s1 + s2 + s3 + s4) / SINE_TERMS) * 0.5 * 1.7 + 0.5, 0, 1);
}
function draw() {
paintPage();
const a = sceneT / LAP_S * TAU;
setSrc(PW1, PH, FIELD_SS);
const sw = srcCv.width;
const sh = srcCv.height;
if (!img || img.width !== sw || img.height !== sh) {
img = sctx.createImageData(sw, sh);
}
const d = img.data;
for (let py = 0; py < sh; py += 1) {
for (let px = 0; px < sw; px += 1) {
const v = Math.round(sineSum(px / FIELD_SS, py / FIELD_SS, a) * 255);
const p = (py * sw + px) * 4;
d[p] = v;
d[p + 1] = v;
d[p + 2] = v;
d[p + 3] = 255;
}
}
sctx.putImageData(img, 0, 0);
const cw = cellW(FONT_PX);
const ch = cellH(FONT_PX);
const cols = Math.floor(PW1 / cw);
const rows = Math.floor(PH / ch);
const g = readGrid(cols, rows, cw, ch);
const cA = [];
const col = [];
for (let i = 0; i < g.lum.length; i += 1) {
cA.push(RAMP.charAt(glyphIndex(g.lum[i], RAMP_LEN)));
col.push(Math.min(SHADES.length - 1, Math.floor(g.lum[i] * SHADES.length)));
}
lastCols = cols;
lastRows = rows;
lastKinds = kinds(usage(cA, RAMP));
plainText = plainOf(g, cA);
panel(PX_L, PY, PW1, PH);
drawGlyphs(PX_L + (PW1 - cols * cw) / 2, PY + (PH - rows * ch) / 2, FONT_PX, g, cA, col, SHADES);
}
function statusText() {
return '波 {n}重・{c}×{r}セル・文字 {k}種'.replace('{n}', String(SINE_TERMS)).replace('{c}', String(lastCols)).replace('{r}', String(lastRows)).replace('{k}', String(lastKinds));
}
fitCanvas();
if (still) {
showRest();
}
else {
sceneT = START_S;
showNow();
startLoop();
}
return function () {
stopLoop();
off.abort();
};
})();// PlasmaWaveGlyphField - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./PlasmaWaveGlyphField.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function PlasmaWaveGlyphField() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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(a: number, b: number, v: number): number { const k = clamp((v - a) / (b - a), 0, 1); return k * k * (3 - 2 * k); }
// 파스텔 팔레트 — 글자 판은 짙은 남보라 바탕 위에 옅은 라벤더 글자(대비 12 이상), 바깥 무대는 크림 라벤더
const PAGE: RGB = [246, 243, 252];
const BOARD: RGB = [34, 30, 56];
const BOARD_EDGE: RGB = [74, 68, 112];
const GLYPH: RGB = [226, 219, 250];
const MINT: RGB = [168, 226, 204];
const ROSE: RGB = [248, 176, 198];
const BUTTER: RGB = [250, 226, 150];
const SKY: RGB = [170, 208, 248];
const LAV: RGB = [196, 184, 244];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function grayStr(l: number, a: number): string {
const v = Math.round(clamp(l, 0, 1) * 255);
return 'rgba(' + v + ',' + v + ',' + v + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초) — 한 바퀴 LAP_S 초이고 장면의 모든 움직임은 위상 ph = sceneT / LAP_S 의 정수배라 바퀴 끝에서 이음매가 없다
let sceneT = 0;
let lap = 0;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 고정폭 글꼴 목록만
const MONO = 'ui-monospace, "Cascadia Mono", Consolas, Menlo, "DejaVu Sans Mono", "Courier New", monospace';
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
// 글자 한 줄 높이 = 글자 크기의 1.2 배. 폭은 글꼴에서 잰 값(advRatio)을 쓴다
const LINE_K = 1.2;
let advRatio = 0.6;
// ── 원본 장면 캔버스 — 화면에 붙이지 않는 두 번째 캔버스. 장면은 여기 그리고 화소를 읽는다 ──
const srcCv: HTMLCanvasElement = document.createElement('canvas');
const srcRaw = srcCv.getContext('2d', { willReadFrequently: true });
if (!srcRaw) { return; }
const sctx: CanvasRenderingContext2D = srcRaw;
// ss = 원본 화소 / 판 화소. 1 이면 판 크기 그대로, 0.5 면 가로세로 절반 해상도로 계산해 읽는다
let srcSs = 1;
function setSrc(w: number, h: number, ss: number): void {
const sw = Math.round(w * ss);
const sh = Math.round(h * ss);
if (srcCv.width !== sw || srcCv.height !== sh) {
srcCv.width = sw;
srcCv.height = sh;
}
srcSs = ss;
sctx.setTransform(ss, 0, 0, ss, 0, 0);
}
// 칸 격자 — 칸마다 원본 화소의 평균 밝기(lum, 0 에서 1)와 평균 색을 담는다
type Grid = { cols: number; rows: number; cw: number; sh: number; lum: number[]; r: number[]; g: number[]; b: number[] };
// cols×rows 칸을 왼쪽 위부터 읽는다. 칸 하나가 원본에서 덮는 영역은 가로 cw, 세로 sh (판 화소 단위)
function readGrid(cols: number, rows: number, cw: number, sh: number): Grid {
const sw = srcCv.width;
const shp = srcCv.height;
const data = sctx.getImageData(0, 0, sw, shp).data;
const grid: Grid = { cols: cols, rows: rows, cw: cw, sh: sh, lum: [], r: [], g: [], b: [] };
for (let cy = 0; cy < rows; cy += 1) {
const y0 = Math.min(shp - 1, Math.floor(cy * sh * srcSs));
const y1 = Math.max(y0 + 1, Math.min(shp, Math.floor((cy + 1) * sh * srcSs)));
for (let cx = 0; cx < cols; cx += 1) {
const x0 = Math.min(sw - 1, Math.floor(cx * cw * srcSs));
const x1 = Math.max(x0 + 1, Math.min(sw, Math.floor((cx + 1) * cw * srcSs)));
let sr = 0;
let sg = 0;
let sb = 0;
let n = 0;
for (let y = y0; y < y1; y += 1) {
let p = (y * sw + x0) * 4;
for (let x = x0; x < x1; x += 1) {
sr += data[p];
sg += data[p + 1];
sb += data[p + 2];
n += 1;
p += 4;
}
}
const mr = sr / n;
const mg = sg / n;
const mb = sb / n;
grid.r.push(mr);
grid.g.push(mg);
grid.b.push(mb);
grid.lum.push((0.2126 * mr + 0.7152 * mg + 0.0722 * mb) / 255);
}
}
return grid;
}
// ── 글자 램프 — 성긴 글자에서 짙은 글자 순서 열여섯 개. n 단계 램프는 이 줄에서 고르게 n 개를 뽑는다 ──
const RAMP_FULL = ' .,:;-~=+*ox#%&@';
function rampOf(n: number): string {
let s = '';
for (let i = 0; i < n; i += 1) { s += RAMP_FULL.charAt(Math.round(i * (RAMP_FULL.length - 1) / (n - 1))); }
return s;
}
// 밝기 v(0 에서 1)를 램프 번호로 — 단계 폭 1/n 으로 나눠 내림
function glyphIndex(v: number, n: number): number { return Math.min(n - 1, Math.max(0, Math.floor(v * n))); }
// 칸 크기 — 글자 크기 fp 를 주면 폭은 글꼴에서 잰 글자 폭, 높이는 줄 높이
function cellW(fp: number): number { return advRatio * fp; }
function cellH(fp: number): number { return LINE_K * fp; }
function measureAdv(): void {
ctx.font = '100px ' + MONO;
const m = ctx.measureText('M').width / 100;
advRatio = m > 0.3 && m < 0.9 ? m : 0.6;
}
// ── 그리기 도구 ──
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
function paintPage(): void {
ctx.fillStyle = rgba(PAGE, 1);
ctx.fillRect(0, 0, W, H);
}
// 글자 판 바탕 — 짙은 남보라 둥근 사각형
function panel(x: number, y: number, w: number, h: number): void {
box(x - 1, y - 1, w + 2, h + 2, 11, BOARD_EDGE, 1);
box(x, y, w, h, 10, BOARD, 1);
}
// 글자 판 그리기 — chars 는 칸마다 글자 하나(왼쪽 위부터 줄 순서). colorIdx 가 있으면 칸마다 팔레트 번호, 없으면 모두 pal[0].
// 같은 색이 이어지는 칸은 한 번에 찍어 한 판에 fillText 가 줄 수 × 색 조각 수만큼만 불린다
function drawGlyphs(x: number, y: number, fp: number, g: Grid, chars: string[], colorIdx: number[] | null, pal: RGB[]): void {
const cw = cellW(fp);
const ch = cellH(fp);
ctx.font = '600 ' + fp + 'px ' + MONO;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
for (let r = 0; r < g.rows; r += 1) {
let c = 0;
while (c < g.cols) {
const ci = colorIdx ? colorIdx[r * g.cols + c] : 0;
let run = '';
let e = c;
while (e < g.cols && (colorIdx ? colorIdx[r * g.cols + e] : 0) === ci) { run += chars[r * g.cols + e]; e += 1; }
if (run.trim().length > 0) {
ctx.fillStyle = rgba(pal[ci], 1);
ctx.fillText(run, x + c * cw, y + (r + 0.5) * ch + 0.5);
}
c = e;
}
}
}
// 판의 글자를 줄바꿈으로 이은 텍스트 — 복사 단추가 이 글을 클립보드에 넣는다
function plainOf(g: Grid, chars: string[]): string {
const lines: string[] = [];
for (let r = 0; r < g.rows; r += 1) {
lines.push(chars.slice(r * g.cols, (r + 1) * g.cols).join('').replace(/\s+$/, ''));
}
return lines.join('\n');
}
// 쓰인 서로 다른 글자 수(공백 포함)와 글자별 횟수
function usage(chars: string[], ramp: string): number[] {
const out: number[] = [];
for (let i = 0; i < ramp.length; i += 1) { out.push(0); }
for (const c of chars) {
const k = ramp.indexOf(c);
if (k >= 0) { out[k] += 1; }
}
return out;
}
function kinds(u: number[]): number {
let n = 0;
for (const v of u) { if (v > 0) { n += 1; } }
return n;
}
// 글자별 사용 횟수 막대 — 판 아래 띠에 램프 순서대로
function usageBars(x: number, y: number, w: number, h: number, u: number[], c: RGB): void {
let mx = 1;
for (const v of u) { if (v > mx) { mx = v; } }
const bw = w / u.length;
for (let i = 0; i < u.length; i += 1) {
const bh = Math.max(u[i] > 0 ? 2 : 0, h * u[i] / mx);
box(x + i * bw + 1, y + h - bh, bw - 2, bh, 2, c, 0.95);
}
}
// 두 번째 캔버스에 쓰는 회색 장면 도구 — 중심에서 번지는 둥근 빛
function glow(x: number, y: number, r: number, l: number, a: number): void {
const g = sctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, grayStr(l, a));
g.addColorStop(1, grayStr(l, 0));
sctx.fillStyle = g;
sctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function vgrad(w: number, h: number, l0: number, l1: number): void {
const g = sctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, grayStr(l0, 1));
g.addColorStop(1, grayStr(l1, 1));
sctx.fillStyle = g;
sctx.fillRect(0, 0, w, h);
}
// 판 배치 — 위 46 은 상태 줄·단추 자리. 판 둘이면 가로 228, 하나면 464
const PY = 46;
const PH = 246;
const PW2 = 228;
const PW1 = 464;
const PX_L = 8;
const PX_R = 244;
// 복사 단추를 누른 뒤 상태 줄에 잠깐 뜨는 말
const COPY_OK = '글자 판을 텍스트로 복사했다';
const COPY_FAIL = '복사하지 못했다 — 글자 판을 직접 선택해 복사';
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const copyEl = root.querySelector('[data-role="copy"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !copyEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const copyBtn: HTMLButtonElement = copyEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
measureAdv();
}
// 지금 화면에 찍힌 글자 판 — 복사 단추가 이 텍스트를 넣는다
let plainText = '';
let copyFlash = 0;
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
if (copyFlash > 0) { copyFlash = Math.max(0, copyFlash - dt); }
if (sceneT >= LAP_S) { sceneT -= LAP_S; lap += 1; }
}
const DT_MAX = 0.05;
const STEP_S = 0.4;
function showNow(): void {
draw();
pushStatus(copyFlash > 0 ? copyNote : statusText());
checkDone();
}
let copyNote = '';
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 장면이 한창인 순간의 정지 장면 한 장
function showRest(): void {
stopLoop();
sceneT = REST_S;
showNow();
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
function restart(): void {
wake();
sceneT = START_S;
lap = 0;
doneSaid = false;
showNow();
}
// 한 걸음 — 멈춘 채 장면 시계를 0.4 초 앞으로
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
advance(STEP_S);
showNow();
}
// 복사 — 화면에 있는 글자 판을 텍스트로 클립보드에. 임시 입력칸을 만들어 선택하고 복사 명령을 부른다
function copyBoard(): void {
wake();
const ta = document.createElement('textarea');
ta.value = plainText;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
let ok = false;
try { ok = document.execCommand('copy'); } catch (err) { ok = false; }
document.body.removeChild(ta);
copyNote = ok ? COPY_OK : COPY_FAIL;
copyFlash = 1.6;
pushStatus(copyNote);
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
copyBtn.addEventListener('click', copyBoard, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space·P 는 일시정지, N 은 한 걸음, R 은 처음부터, C 는 글자 판 복사
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
} else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
} else if (k === 'r' || k === 'R') {
e.preventDefault();
restart();
} else if (k === 'c' || k === 'C') {
e.preventDefault();
copyBoard();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 일시정지 토글
pad.addEventListener('click', function (): void { togglePause(); }, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { sceneT = START_S; showNow(); if (!paused) { startLoop(); } }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 06 움직이는 플라즈마를 프레임마다 글자로 다시 읽는 파동 장 ───────────────
const LAP_S = 10;
const START_S = 0.6;
const REST_S = 3.6;
const FONT_PX = 10;
const RAMP_LEN = 12;
const RAMP = rampOf(RAMP_LEN);
// 원본 장면은 절반 해상도로 계산해 읽는다 — 화소 수가 4분의 1
const FIELD_SS = 0.5;
// 파동 겹 수 — sineSum 이 더하는 사인 항의 개수
const SINE_TERMS = 4;
// 색 단계 — 밝기를 다섯 구간으로 나눠 구간마다 글자 색을 올린다(어두운 구간도 판 바탕 대비 4.5 이상)
const SHADES: RGB[] = [[150, 140, 214], [176, 164, 236], [196, 184, 244], [214, 222, 250], [168, 226, 204]];
let img: ImageData | null = null;
let lastCols = 0;
let lastRows = 0;
let lastKinds = 0;
// 판 좌표 (x, y) 와 위상 a 에서의 플라즈마 값 — 가로 물결·세로 물결·대각 물결·움직이는 중심에서 퍼지는 고리의 합, 0 에서 1
function sineSum(x: number, y: number, a: number): number {
const s1 = Math.sin(x * 0.04 + a);
const s2 = Math.sin(y * 0.065 - a);
const s3 = Math.sin((x + y) * 0.028 + 2 * a);
const s4 = Math.sin(Math.hypot(x - 232 + 120 * Math.cos(a), y - 126 + 60 * Math.sin(a)) * 0.05 - 2 * a);
return clamp(((s1 + s2 + s3 + s4) / SINE_TERMS) * 0.5 * 1.7 + 0.5, 0, 1);
}
function draw(): void {
paintPage();
const a = sceneT / LAP_S * TAU;
setSrc(PW1, PH, FIELD_SS);
const sw = srcCv.width;
const sh = srcCv.height;
if (!img || img.width !== sw || img.height !== sh) { img = sctx.createImageData(sw, sh); }
const d = img.data;
for (let py = 0; py < sh; py += 1) {
for (let px = 0; px < sw; px += 1) {
const v = Math.round(sineSum(px / FIELD_SS, py / FIELD_SS, a) * 255);
const p = (py * sw + px) * 4;
d[p] = v;
d[p + 1] = v;
d[p + 2] = v;
d[p + 3] = 255;
}
}
sctx.putImageData(img, 0, 0);
const cw = cellW(FONT_PX);
const ch = cellH(FONT_PX);
const cols = Math.floor(PW1 / cw);
const rows = Math.floor(PH / ch);
const g = readGrid(cols, rows, cw, ch);
const cA: string[] = [];
const col: number[] = [];
for (let i = 0; i < g.lum.length; i += 1) {
cA.push(RAMP.charAt(glyphIndex(g.lum[i], RAMP_LEN)));
col.push(Math.min(SHADES.length - 1, Math.floor(g.lum[i] * SHADES.length)));
}
lastCols = cols;
lastRows = rows;
lastKinds = kinds(usage(cA, RAMP));
plainText = plainOf(g, cA);
panel(PX_L, PY, PW1, PH);
drawGlyphs(PX_L + (PW1 - cols * cw) / 2, PY + (PH - rows * ch) / 2, FONT_PX, g, cA, col, SHADES);
}
function statusText(): string {
return '파동 {n}겹 · {c}×{r}칸 · 글자 {k}종'.replace('{n}', String(SINE_TERMS)).replace('{c}', String(lastCols)).replace('{r}', String(lastRows)).replace('{k}', String(lastKinds));
}
fitCanvas();
if (still) { showRest(); } else { sceneT = START_S; showNow(); startLoop(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'아스키 아트 시연 무대 — Enter 나 Space 로 일시정지, N 으로 한 걸음, R 로 처음부터, C 로 글자 판 복사'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'가로, 세로, 대각 물결과 움직이는 중심에서 퍼지는 고리 네 겹의 사인을 더한 플라즈마 장을 프레임마다 다시 읽어 열두 단계 글자로 찍고 밝기 구간마다 글자 색을 올린 판 하나'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'아스키 아트 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
<button className={cx("vj__copy")} data-role="copy" type="button">{'복사'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}06 · 動くプラズマをフレームごとに文字として読み直す文字盤 — 計測 累積面積 39.769% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function sineSum(x, y, a) {
const s1 = Math.sin(x * 0.04 + a);
const s2 = Math.sin(y * 0.065 - a);
const s3 = Math.sin((x + y) * 0.028 + 2 * a);
const s4 = Math.sin(Math.hypot(x - 232 + 120 * Math.cos(a), y - 126 + 60 * Math.sin(a)) * 0.05 - 2 * a);
return clamp(((s1 + s2 + s3 + s4) / SINE_TERMS) * 0.5 * 1.7 + 0.5, 0, 1);
}
07球の表面の光と影を文字の濃さに移す陰影
立体感のある文字ロゴやヒーローのオブジェクトに使います。画素ごとに球の上の一点とみなして拡散の陰影を求め、normalDot が法線と光の向きの内積が正のときだけ明るさを与え、縞模様の球は一周に2回自転し、月は mz + nz > zBest のときだけ球の手前に描かれて球の裏へ回ると隠れます。12段階の段階表で平均11.6種が使われ、一コマはこのPCのChromeで平均約1.6msです。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="アスキーアートの舞台 — Enter か Space で一時停止、N で一歩、R でやり直し、C で文字盤をコピー">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="縞模様が自転する球とその周りを回る小さな月に回る光が当たる拡散陰影を、十二段階の文字の濃さで打った盤です。光が当たらない面は薄い文字に沈み、背景には淡い波紋が広がります"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">アスキーアートのデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
<button class="vj__copy" data-role="copy" type="button">コピー</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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-cream;
}
// 무대 상자 — 여기서 시작한 터치만 칸 누르기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step,
.vj__copy {
padding: $sp-1 $sp-2;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.vj__step,
.vj__copy {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover,
.vj__copy:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active,
.vj__copy:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible,
.vj__copy:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 214px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__copy,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step,
.vj__copy { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active,
.vj__copy:hover,
.vj__copy:active { transform: none !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);
}
.vj {
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: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step,
.vj__copy {
padding: 4px 8px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__step,
.vj__copy {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover,
.vj__copy:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active,
.vj__copy:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible,
.vj__copy:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 214px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__copy,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step,
.vj__copy {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active,
.vj__copy:hover,
.vj__copy:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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(a, b, v) { const k = clamp((v - a) / (b - a), 0, 1); return k * k * (3 - 2 * k); }
// 파스텔 팔레트 — 글자 판은 짙은 남보라 바탕 위에 옅은 라벤더 글자(대비 12 이상), 바깥 무대는 크림 라벤더
const PAGE = [246, 243, 252];
const BOARD = [34, 30, 56];
const BOARD_EDGE = [74, 68, 112];
const GLYPH = [226, 219, 250];
const MINT = [168, 226, 204];
const ROSE = [248, 176, 198];
const BUTTER = [250, 226, 150];
const SKY = [170, 208, 248];
const LAV = [196, 184, 244];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function grayStr(l, a) {
const v = Math.round(clamp(l, 0, 1) * 255);
return 'rgba(' + v + ',' + v + ',' + v + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초) — 한 바퀴 LAP_S 초이고 장면의 모든 움직임은 위상 ph = sceneT / LAP_S 의 정수배라 바퀴 끝에서 이음매가 없다
let sceneT = 0;
let lap = 0;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 고정폭 글꼴 목록만
const MONO = 'ui-monospace, "Cascadia Mono", Consolas, Menlo, "DejaVu Sans Mono", "Courier New", monospace';
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
// 글자 한 줄 높이 = 글자 크기의 1.2 배. 폭은 글꼴에서 잰 값(advRatio)을 쓴다
const LINE_K = 1.2;
let advRatio = 0.6;
// ── 원본 장면 캔버스 — 화면에 붙이지 않는 두 번째 캔버스. 장면은 여기 그리고 화소를 읽는다 ──
const srcCv = document.createElement('canvas');
const srcRaw = srcCv.getContext('2d', { willReadFrequently: true });
if (!srcRaw) {
return;
}
const sctx = srcRaw;
// ss = 원본 화소 / 판 화소. 1 이면 판 크기 그대로, 0.5 면 가로세로 절반 해상도로 계산해 읽는다
let srcSs = 1;
function setSrc(w, h, ss) {
const sw = Math.round(w * ss);
const sh = Math.round(h * ss);
if (srcCv.width !== sw || srcCv.height !== sh) {
srcCv.width = sw;
srcCv.height = sh;
}
srcSs = ss;
sctx.setTransform(ss, 0, 0, ss, 0, 0);
}
// cols×rows 칸을 왼쪽 위부터 읽는다. 칸 하나가 원본에서 덮는 영역은 가로 cw, 세로 sh (판 화소 단위)
function readGrid(cols, rows, cw, sh) {
const sw = srcCv.width;
const shp = srcCv.height;
const data = sctx.getImageData(0, 0, sw, shp).data;
const grid = { cols: cols, rows: rows, cw: cw, sh: sh, lum: [], r: [], g: [], b: [] };
for (let cy = 0; cy < rows; cy += 1) {
const y0 = Math.min(shp - 1, Math.floor(cy * sh * srcSs));
const y1 = Math.max(y0 + 1, Math.min(shp, Math.floor((cy + 1) * sh * srcSs)));
for (let cx = 0; cx < cols; cx += 1) {
const x0 = Math.min(sw - 1, Math.floor(cx * cw * srcSs));
const x1 = Math.max(x0 + 1, Math.min(sw, Math.floor((cx + 1) * cw * srcSs)));
let sr = 0;
let sg = 0;
let sb = 0;
let n = 0;
for (let y = y0; y < y1; y += 1) {
let p = (y * sw + x0) * 4;
for (let x = x0; x < x1; x += 1) {
sr += data[p];
sg += data[p + 1];
sb += data[p + 2];
n += 1;
p += 4;
}
}
const mr = sr / n;
const mg = sg / n;
const mb = sb / n;
grid.r.push(mr);
grid.g.push(mg);
grid.b.push(mb);
grid.lum.push((0.2126 * mr + 0.7152 * mg + 0.0722 * mb) / 255);
}
}
return grid;
}
// ── 글자 램프 — 성긴 글자에서 짙은 글자 순서 열여섯 개. n 단계 램프는 이 줄에서 고르게 n 개를 뽑는다 ──
const RAMP_FULL = ' .,:;-~=+*ox#%&@';
function rampOf(n) {
let s = '';
for (let i = 0; i < n; i += 1) {
s += RAMP_FULL.charAt(Math.round(i * (RAMP_FULL.length - 1) / (n - 1)));
}
return s;
}
// 밝기 v(0 에서 1)를 램프 번호로 — 단계 폭 1/n 으로 나눠 내림
function glyphIndex(v, n) { return Math.min(n - 1, Math.max(0, Math.floor(v * n))); }
// 칸 크기 — 글자 크기 fp 를 주면 폭은 글꼴에서 잰 글자 폭, 높이는 줄 높이
function cellW(fp) { return advRatio * fp; }
function cellH(fp) { return LINE_K * fp; }
function measureAdv() {
ctx.font = '100px ' + MONO;
const m = ctx.measureText('M').width / 100;
advRatio = m > 0.3 && m < 0.9 ? m : 0.6;
}
// ── 그리기 도구 ──
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
function paintPage() {
ctx.fillStyle = rgba(PAGE, 1);
ctx.fillRect(0, 0, W, H);
}
// 글자 판 바탕 — 짙은 남보라 둥근 사각형
function panel(x, y, w, h) {
box(x - 1, y - 1, w + 2, h + 2, 11, BOARD_EDGE, 1);
box(x, y, w, h, 10, BOARD, 1);
}
// 글자 판 그리기 — chars 는 칸마다 글자 하나(왼쪽 위부터 줄 순서). colorIdx 가 있으면 칸마다 팔레트 번호, 없으면 모두 pal[0].
// 같은 색이 이어지는 칸은 한 번에 찍어 한 판에 fillText 가 줄 수 × 색 조각 수만큼만 불린다
function drawGlyphs(x, y, fp, g, chars, colorIdx, pal) {
const cw = cellW(fp);
const ch = cellH(fp);
ctx.font = '600 ' + fp + 'px ' + MONO;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
for (let r = 0; r < g.rows; r += 1) {
let c = 0;
while (c < g.cols) {
const ci = colorIdx ? colorIdx[r * g.cols + c] : 0;
let run = '';
let e = c;
while (e < g.cols && (colorIdx ? colorIdx[r * g.cols + e] : 0) === ci) {
run += chars[r * g.cols + e];
e += 1;
}
if (run.trim().length > 0) {
ctx.fillStyle = rgba(pal[ci], 1);
ctx.fillText(run, x + c * cw, y + (r + 0.5) * ch + 0.5);
}
c = e;
}
}
}
// 판의 글자를 줄바꿈으로 이은 텍스트 — コピー 단추가 이 글을 클립보드에 넣는다
function plainOf(g, chars) {
const lines = [];
for (let r = 0; r < g.rows; r += 1) {
lines.push(chars.slice(r * g.cols, (r + 1) * g.cols).join('').replace(/\s+$/, ''));
}
return lines.join('\n');
}
// 쓰인 서로 다른 글자 수(공백 포함)와 글자별 횟수
function usage(chars, ramp) {
const out = [];
for (let i = 0; i < ramp.length; i += 1) {
out.push(0);
}
for (const c of chars) {
const k = ramp.indexOf(c);
if (k >= 0) {
out[k] += 1;
}
}
return out;
}
function kinds(u) {
let n = 0;
for (const v of u) {
if (v > 0) {
n += 1;
}
}
return n;
}
// 글자별 사용 횟수 막대 — 판 아래 띠에 램프 순서대로
function usageBars(x, y, w, h, u, c) {
let mx = 1;
for (const v of u) {
if (v > mx) {
mx = v;
}
}
const bw = w / u.length;
for (let i = 0; i < u.length; i += 1) {
const bh = Math.max(u[i] > 0 ? 2 : 0, h * u[i] / mx);
box(x + i * bw + 1, y + h - bh, bw - 2, bh, 2, c, 0.95);
}
}
// 두 번째 캔버스에 쓰는 회색 장면 도구 — 중심에서 번지는 둥근 빛
function glow(x, y, r, l, a) {
const g = sctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, grayStr(l, a));
g.addColorStop(1, grayStr(l, 0));
sctx.fillStyle = g;
sctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function vgrad(w, h, l0, l1) {
const g = sctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, grayStr(l0, 1));
g.addColorStop(1, grayStr(l1, 1));
sctx.fillStyle = g;
sctx.fillRect(0, 0, w, h);
}
// 판 배치 — 위 46 은 상태 줄·단추 자리. 판 둘이면 가로 228, 하나면 464
const PY = 46;
const PH = 246;
const PW2 = 228;
const PW1 = 464;
const PX_L = 8;
const PX_R = 244;
// コピー 단추를 누른 뒤 상태 줄에 잠깐 뜨는 말
const COPY_OK = '文字盤をテキストでコピーしました';
const COPY_FAIL = 'コピーできませんでした — 文字盤を選んで手でコピーしてください';
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const copyEl = root.querySelector('[data-role="copy"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !copyEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const copyBtn = copyEl;
const doneP = doneEl;
const pad = padEl;
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
measureAdv();
}
// 지금 화면에 찍힌 글자 판 — コピー 단추가 이 텍스트를 넣는다
let plainText = '';
let copyFlash = 0;
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
if (copyFlash > 0) {
copyFlash = Math.max(0, copyFlash - dt);
}
if (sceneT >= LAP_S) {
sceneT -= LAP_S;
lap += 1;
}
}
const DT_MAX = 0.05;
const STEP_S = 0.4;
function showNow() {
draw();
pushStatus(copyFlash > 0 ? copyNote : statusText());
checkDone();
}
let copyNote = '';
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 장면이 한창인 순간의 정지 장면 한 장
function showRest() {
stopLoop();
sceneT = REST_S;
showNow();
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
function restart() {
wake();
sceneT = START_S;
lap = 0;
doneSaid = false;
showNow();
}
// 一歩進める — 멈춘 채 장면 시계를 0.4 초 앞으로
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
advance(STEP_S);
showNow();
}
// コピー — 화면에 있는 글자 판을 텍스트로 클립보드에. 임시 입력칸을 만들어 선택하고 コピー 명령을 부른다
function copyBoard() {
wake();
const ta = document.createElement('textarea');
ta.value = plainText;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
let ok = false;
try {
ok = document.execCommand('copy');
}
catch (err) {
ok = false;
}
document.body.removeChild(ta);
copyNote = ok ? COPY_OK : COPY_FAIL;
copyFlash = 1.6;
pushStatus(copyNote);
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
copyBtn.addEventListener('click', copyBoard, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space·P 는 一時停止, N 은 一歩進める, R 은 처음부터, C 는 글자 판 コピー
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
}
else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
}
else if (k === 'r' || k === 'R') {
e.preventDefault();
restart();
}
else if (k === 'c' || k === 'C') {
e.preventDefault();
copyBoard();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 一時停止 토글
pad.addEventListener('click', function () { togglePause(); }, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
sceneT = START_S;
showNow();
if (!paused) {
startLoop();
}
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 07 구 표면의 빛과 그림자를 글자 농도로 옮기는 음영 ───────────────────────
const LAP_S = 10;
const START_S = 0.6;
const REST_S = 3.3;
const FONT_PX = 10;
// 음영 램프 — 어두운 쪽부터 열두 글자
const RAMP_SHADE = rampOf(12);
const FIELD_SS = 0.5;
// 구 반지름(판 화소)과 달 반지름
const SPHERE_R = 98;
const MOON_R = 34;
// 빛 방향이 한 바퀴 도는 각도(도) — 한 바퀴 시연 동안 빛은 이만큼 돌고 구는 두 바퀴 자전한다
const LIGHT_ANGLE = 360;
let img = null;
let lastKinds = 0;
let lightDeg = 0;
// 확산 음영 — 면의 법선과 빛 방향의 내적이 양수일 때만 그 크기만큼 밝다
function normalDot(nx, ny, nz, lx, ly, lz) {
return Math.max(0, nx * lx + ny * ly + nz * lz);
}
function draw() {
paintPage();
const ph = sceneT / LAP_S;
const a = ph * TAU;
lightDeg = ph * LIGHT_ANGLE;
setSrc(PW1, PH, FIELD_SS);
const sw = srcCv.width;
const sh = srcCv.height;
if (!img || img.width !== sw || img.height !== sh) {
img = sctx.createImageData(sw, sh);
}
const d = img.data;
// 빛 방향(길이 1) — 위쪽에서 가로로 한 바퀴 돌며 앞뒤로 기운다
let lx = Math.cos(a) * 0.8;
let ly = -0.35;
let lz = 0.45 + 0.55 * Math.sin(a);
const ln = Math.hypot(lx, ly, lz);
lx /= ln;
ly /= ln;
lz /= ln;
const cx = PW1 / 2;
const cy = PH / 2;
const mx = cx + 168 * Math.cos(a);
const my = cy - 34 * Math.sin(a);
const mz = 170 * Math.sin(a);
const rot = 2 * a;
for (let py = 0; py < sh; py += 1) {
for (let px = 0; px < sw; px += 1) {
const x = px / FIELD_SS;
const y = py / FIELD_SS;
const dist = Math.hypot(x - cx, y - cy);
// 바탕 — 구 둘레로 퍼지는 옅은 파문
let v = (0.05 + 0.12 * (0.5 + 0.5 * Math.sin(dist * 0.09 - 2 * a))) * (1 - clamp(dist / 330, 0, 1));
let zBest = -1e9;
const dx = x - cx;
const dy = y - cy;
const r2 = dx * dx + dy * dy;
if (r2 < SPHERE_R * SPHERE_R) {
const nz = Math.sqrt(SPHERE_R * SPHERE_R - r2);
const nx = dx / SPHERE_R;
const ny = dy / SPHERE_R;
const nzn = nz / SPHERE_R;
// 자전 — 법선을 세로축 둘레로 돌려 위도·경도를 구하고 줄무늬 무늬를 입힌다
const rx = nx * Math.cos(rot) + nzn * Math.sin(rot);
const rz = -nx * Math.sin(rot) + nzn * Math.cos(rot);
const lon = Math.atan2(rx, rz);
const lat = Math.asin(clamp(ny, -1, 1));
const tex = 0.5 + 0.5 * Math.sin(5 * lon + 3 * Math.sin(lat * 3));
const alb = 0.42 + 0.58 * smooth(0.35, 0.65, tex);
v = 0.05 + 0.95 * normalDot(nx, ny, nzn, lx, ly, lz) * alb;
zBest = nz;
}
const ex = x - mx;
const ey = y - my;
const e2 = ex * ex + ey * ey;
if (e2 < MOON_R * MOON_R) {
const nz = Math.sqrt(MOON_R * MOON_R - e2);
if (mz + nz > zBest) {
v = 0.05 + 0.95 * normalDot(ex / MOON_R, ey / MOON_R, nz / MOON_R, lx, ly, lz) * 0.95;
}
}
const g8 = Math.round(clamp(v, 0, 1) * 255);
const p = (py * sw + px) * 4;
d[p] = g8;
d[p + 1] = g8;
d[p + 2] = g8;
d[p + 3] = 255;
}
}
sctx.putImageData(img, 0, 0);
const cw = cellW(FONT_PX);
const ch = cellH(FONT_PX);
const cols = Math.floor(PW1 / cw);
const rows = Math.floor(PH / ch);
const g = readGrid(cols, rows, cw, ch);
const cA = [];
for (let i = 0; i < g.lum.length; i += 1) {
cA.push(RAMP_SHADE.charAt(glyphIndex(g.lum[i], RAMP_SHADE.length)));
}
lastKinds = kinds(usage(cA, RAMP_SHADE));
plainText = plainOf(g, cA);
panel(PX_L, PY, PW1, PH);
drawGlyphs(PX_L + (PW1 - cols * cw) / 2, PY + (PH - rows * ch) / 2, FONT_PX, g, cA, null, [GLYPH]);
}
function statusText() {
return '光 {a}°・文字 {k}種'.replace('{a}', String(Math.round(lightDeg))).replace('{k}', String(lastKinds));
}
fitCanvas();
if (still) {
showRest();
}
else {
sceneT = START_S;
showNow();
startLoop();
}
return function () {
stopLoop();
off.abort();
};
})();// SphereDiffuseShade - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./SphereDiffuseShade.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SphereDiffuseShade() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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(a: number, b: number, v: number): number { const k = clamp((v - a) / (b - a), 0, 1); return k * k * (3 - 2 * k); }
// 파스텔 팔레트 — 글자 판은 짙은 남보라 바탕 위에 옅은 라벤더 글자(대비 12 이상), 바깥 무대는 크림 라벤더
const PAGE: RGB = [246, 243, 252];
const BOARD: RGB = [34, 30, 56];
const BOARD_EDGE: RGB = [74, 68, 112];
const GLYPH: RGB = [226, 219, 250];
const MINT: RGB = [168, 226, 204];
const ROSE: RGB = [248, 176, 198];
const BUTTER: RGB = [250, 226, 150];
const SKY: RGB = [170, 208, 248];
const LAV: RGB = [196, 184, 244];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function grayStr(l: number, a: number): string {
const v = Math.round(clamp(l, 0, 1) * 255);
return 'rgba(' + v + ',' + v + ',' + v + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초) — 한 바퀴 LAP_S 초이고 장면의 모든 움직임은 위상 ph = sceneT / LAP_S 의 정수배라 바퀴 끝에서 이음매가 없다
let sceneT = 0;
let lap = 0;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 고정폭 글꼴 목록만
const MONO = 'ui-monospace, "Cascadia Mono", Consolas, Menlo, "DejaVu Sans Mono", "Courier New", monospace';
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
// 글자 한 줄 높이 = 글자 크기의 1.2 배. 폭은 글꼴에서 잰 값(advRatio)을 쓴다
const LINE_K = 1.2;
let advRatio = 0.6;
// ── 원본 장면 캔버스 — 화면에 붙이지 않는 두 번째 캔버스. 장면은 여기 그리고 화소를 읽는다 ──
const srcCv: HTMLCanvasElement = document.createElement('canvas');
const srcRaw = srcCv.getContext('2d', { willReadFrequently: true });
if (!srcRaw) { return; }
const sctx: CanvasRenderingContext2D = srcRaw;
// ss = 원본 화소 / 판 화소. 1 이면 판 크기 그대로, 0.5 면 가로세로 절반 해상도로 계산해 읽는다
let srcSs = 1;
function setSrc(w: number, h: number, ss: number): void {
const sw = Math.round(w * ss);
const sh = Math.round(h * ss);
if (srcCv.width !== sw || srcCv.height !== sh) {
srcCv.width = sw;
srcCv.height = sh;
}
srcSs = ss;
sctx.setTransform(ss, 0, 0, ss, 0, 0);
}
// 칸 격자 — 칸마다 원본 화소의 평균 밝기(lum, 0 에서 1)와 평균 색을 담는다
type Grid = { cols: number; rows: number; cw: number; sh: number; lum: number[]; r: number[]; g: number[]; b: number[] };
// cols×rows 칸을 왼쪽 위부터 읽는다. 칸 하나가 원본에서 덮는 영역은 가로 cw, 세로 sh (판 화소 단위)
function readGrid(cols: number, rows: number, cw: number, sh: number): Grid {
const sw = srcCv.width;
const shp = srcCv.height;
const data = sctx.getImageData(0, 0, sw, shp).data;
const grid: Grid = { cols: cols, rows: rows, cw: cw, sh: sh, lum: [], r: [], g: [], b: [] };
for (let cy = 0; cy < rows; cy += 1) {
const y0 = Math.min(shp - 1, Math.floor(cy * sh * srcSs));
const y1 = Math.max(y0 + 1, Math.min(shp, Math.floor((cy + 1) * sh * srcSs)));
for (let cx = 0; cx < cols; cx += 1) {
const x0 = Math.min(sw - 1, Math.floor(cx * cw * srcSs));
const x1 = Math.max(x0 + 1, Math.min(sw, Math.floor((cx + 1) * cw * srcSs)));
let sr = 0;
let sg = 0;
let sb = 0;
let n = 0;
for (let y = y0; y < y1; y += 1) {
let p = (y * sw + x0) * 4;
for (let x = x0; x < x1; x += 1) {
sr += data[p];
sg += data[p + 1];
sb += data[p + 2];
n += 1;
p += 4;
}
}
const mr = sr / n;
const mg = sg / n;
const mb = sb / n;
grid.r.push(mr);
grid.g.push(mg);
grid.b.push(mb);
grid.lum.push((0.2126 * mr + 0.7152 * mg + 0.0722 * mb) / 255);
}
}
return grid;
}
// ── 글자 램프 — 성긴 글자에서 짙은 글자 순서 열여섯 개. n 단계 램프는 이 줄에서 고르게 n 개를 뽑는다 ──
const RAMP_FULL = ' .,:;-~=+*ox#%&@';
function rampOf(n: number): string {
let s = '';
for (let i = 0; i < n; i += 1) { s += RAMP_FULL.charAt(Math.round(i * (RAMP_FULL.length - 1) / (n - 1))); }
return s;
}
// 밝기 v(0 에서 1)를 램프 번호로 — 단계 폭 1/n 으로 나눠 내림
function glyphIndex(v: number, n: number): number { return Math.min(n - 1, Math.max(0, Math.floor(v * n))); }
// 칸 크기 — 글자 크기 fp 를 주면 폭은 글꼴에서 잰 글자 폭, 높이는 줄 높이
function cellW(fp: number): number { return advRatio * fp; }
function cellH(fp: number): number { return LINE_K * fp; }
function measureAdv(): void {
ctx.font = '100px ' + MONO;
const m = ctx.measureText('M').width / 100;
advRatio = m > 0.3 && m < 0.9 ? m : 0.6;
}
// ── 그리기 도구 ──
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
function paintPage(): void {
ctx.fillStyle = rgba(PAGE, 1);
ctx.fillRect(0, 0, W, H);
}
// 글자 판 바탕 — 짙은 남보라 둥근 사각형
function panel(x: number, y: number, w: number, h: number): void {
box(x - 1, y - 1, w + 2, h + 2, 11, BOARD_EDGE, 1);
box(x, y, w, h, 10, BOARD, 1);
}
// 글자 판 그리기 — chars 는 칸마다 글자 하나(왼쪽 위부터 줄 순서). colorIdx 가 있으면 칸마다 팔레트 번호, 없으면 모두 pal[0].
// 같은 색이 이어지는 칸은 한 번에 찍어 한 판에 fillText 가 줄 수 × 색 조각 수만큼만 불린다
function drawGlyphs(x: number, y: number, fp: number, g: Grid, chars: string[], colorIdx: number[] | null, pal: RGB[]): void {
const cw = cellW(fp);
const ch = cellH(fp);
ctx.font = '600 ' + fp + 'px ' + MONO;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
for (let r = 0; r < g.rows; r += 1) {
let c = 0;
while (c < g.cols) {
const ci = colorIdx ? colorIdx[r * g.cols + c] : 0;
let run = '';
let e = c;
while (e < g.cols && (colorIdx ? colorIdx[r * g.cols + e] : 0) === ci) { run += chars[r * g.cols + e]; e += 1; }
if (run.trim().length > 0) {
ctx.fillStyle = rgba(pal[ci], 1);
ctx.fillText(run, x + c * cw, y + (r + 0.5) * ch + 0.5);
}
c = e;
}
}
}
// 판의 글자를 줄바꿈으로 이은 텍스트 — 복사 단추가 이 글을 클립보드에 넣는다
function plainOf(g: Grid, chars: string[]): string {
const lines: string[] = [];
for (let r = 0; r < g.rows; r += 1) {
lines.push(chars.slice(r * g.cols, (r + 1) * g.cols).join('').replace(/\s+$/, ''));
}
return lines.join('\n');
}
// 쓰인 서로 다른 글자 수(공백 포함)와 글자별 횟수
function usage(chars: string[], ramp: string): number[] {
const out: number[] = [];
for (let i = 0; i < ramp.length; i += 1) { out.push(0); }
for (const c of chars) {
const k = ramp.indexOf(c);
if (k >= 0) { out[k] += 1; }
}
return out;
}
function kinds(u: number[]): number {
let n = 0;
for (const v of u) { if (v > 0) { n += 1; } }
return n;
}
// 글자별 사용 횟수 막대 — 판 아래 띠에 램프 순서대로
function usageBars(x: number, y: number, w: number, h: number, u: number[], c: RGB): void {
let mx = 1;
for (const v of u) { if (v > mx) { mx = v; } }
const bw = w / u.length;
for (let i = 0; i < u.length; i += 1) {
const bh = Math.max(u[i] > 0 ? 2 : 0, h * u[i] / mx);
box(x + i * bw + 1, y + h - bh, bw - 2, bh, 2, c, 0.95);
}
}
// 두 번째 캔버스에 쓰는 회색 장면 도구 — 중심에서 번지는 둥근 빛
function glow(x: number, y: number, r: number, l: number, a: number): void {
const g = sctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, grayStr(l, a));
g.addColorStop(1, grayStr(l, 0));
sctx.fillStyle = g;
sctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function vgrad(w: number, h: number, l0: number, l1: number): void {
const g = sctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, grayStr(l0, 1));
g.addColorStop(1, grayStr(l1, 1));
sctx.fillStyle = g;
sctx.fillRect(0, 0, w, h);
}
// 판 배치 — 위 46 은 상태 줄·단추 자리. 판 둘이면 가로 228, 하나면 464
const PY = 46;
const PH = 246;
const PW2 = 228;
const PW1 = 464;
const PX_L = 8;
const PX_R = 244;
// 복사 단추를 누른 뒤 상태 줄에 잠깐 뜨는 말
const COPY_OK = '글자 판을 텍스트로 복사했다';
const COPY_FAIL = '복사하지 못했다 — 글자 판을 직접 선택해 복사';
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const copyEl = root.querySelector('[data-role="copy"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !copyEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const copyBtn: HTMLButtonElement = copyEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
measureAdv();
}
// 지금 화면에 찍힌 글자 판 — 복사 단추가 이 텍스트를 넣는다
let plainText = '';
let copyFlash = 0;
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
if (copyFlash > 0) { copyFlash = Math.max(0, copyFlash - dt); }
if (sceneT >= LAP_S) { sceneT -= LAP_S; lap += 1; }
}
const DT_MAX = 0.05;
const STEP_S = 0.4;
function showNow(): void {
draw();
pushStatus(copyFlash > 0 ? copyNote : statusText());
checkDone();
}
let copyNote = '';
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 장면이 한창인 순간의 정지 장면 한 장
function showRest(): void {
stopLoop();
sceneT = REST_S;
showNow();
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
function restart(): void {
wake();
sceneT = START_S;
lap = 0;
doneSaid = false;
showNow();
}
// 한 걸음 — 멈춘 채 장면 시계를 0.4 초 앞으로
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
advance(STEP_S);
showNow();
}
// 복사 — 화면에 있는 글자 판을 텍스트로 클립보드에. 임시 입력칸을 만들어 선택하고 복사 명령을 부른다
function copyBoard(): void {
wake();
const ta = document.createElement('textarea');
ta.value = plainText;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
let ok = false;
try { ok = document.execCommand('copy'); } catch (err) { ok = false; }
document.body.removeChild(ta);
copyNote = ok ? COPY_OK : COPY_FAIL;
copyFlash = 1.6;
pushStatus(copyNote);
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
copyBtn.addEventListener('click', copyBoard, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space·P 는 일시정지, N 은 한 걸음, R 은 처음부터, C 는 글자 판 복사
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
} else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
} else if (k === 'r' || k === 'R') {
e.preventDefault();
restart();
} else if (k === 'c' || k === 'C') {
e.preventDefault();
copyBoard();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 일시정지 토글
pad.addEventListener('click', function (): void { togglePause(); }, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { sceneT = START_S; showNow(); if (!paused) { startLoop(); } }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 07 구 표면의 빛과 그림자를 글자 농도로 옮기는 음영 ───────────────────────
const LAP_S = 10;
const START_S = 0.6;
const REST_S = 3.3;
const FONT_PX = 10;
// 음영 램프 — 어두운 쪽부터 열두 글자
const RAMP_SHADE = rampOf(12);
const FIELD_SS = 0.5;
// 구 반지름(판 화소)과 달 반지름
const SPHERE_R = 98;
const MOON_R = 34;
// 빛 방향이 한 바퀴 도는 각도(도) — 한 바퀴 시연 동안 빛은 이만큼 돌고 구는 두 바퀴 자전한다
const LIGHT_ANGLE = 360;
let img: ImageData | null = null;
let lastKinds = 0;
let lightDeg = 0;
// 확산 음영 — 면의 법선과 빛 방향의 내적이 양수일 때만 그 크기만큼 밝다
function normalDot(nx: number, ny: number, nz: number, lx: number, ly: number, lz: number): number {
return Math.max(0, nx * lx + ny * ly + nz * lz);
}
function draw(): void {
paintPage();
const ph = sceneT / LAP_S;
const a = ph * TAU;
lightDeg = ph * LIGHT_ANGLE;
setSrc(PW1, PH, FIELD_SS);
const sw = srcCv.width;
const sh = srcCv.height;
if (!img || img.width !== sw || img.height !== sh) { img = sctx.createImageData(sw, sh); }
const d = img.data;
// 빛 방향(길이 1) — 위쪽에서 가로로 한 바퀴 돌며 앞뒤로 기운다
let lx = Math.cos(a) * 0.8;
let ly = -0.35;
let lz = 0.45 + 0.55 * Math.sin(a);
const ln = Math.hypot(lx, ly, lz);
lx /= ln;
ly /= ln;
lz /= ln;
const cx = PW1 / 2;
const cy = PH / 2;
const mx = cx + 168 * Math.cos(a);
const my = cy - 34 * Math.sin(a);
const mz = 170 * Math.sin(a);
const rot = 2 * a;
for (let py = 0; py < sh; py += 1) {
for (let px = 0; px < sw; px += 1) {
const x = px / FIELD_SS;
const y = py / FIELD_SS;
const dist = Math.hypot(x - cx, y - cy);
// 바탕 — 구 둘레로 퍼지는 옅은 파문
let v = (0.05 + 0.12 * (0.5 + 0.5 * Math.sin(dist * 0.09 - 2 * a))) * (1 - clamp(dist / 330, 0, 1));
let zBest = -1e9;
const dx = x - cx;
const dy = y - cy;
const r2 = dx * dx + dy * dy;
if (r2 < SPHERE_R * SPHERE_R) {
const nz = Math.sqrt(SPHERE_R * SPHERE_R - r2);
const nx = dx / SPHERE_R;
const ny = dy / SPHERE_R;
const nzn = nz / SPHERE_R;
// 자전 — 법선을 세로축 둘레로 돌려 위도·경도를 구하고 줄무늬 무늬를 입힌다
const rx = nx * Math.cos(rot) + nzn * Math.sin(rot);
const rz = -nx * Math.sin(rot) + nzn * Math.cos(rot);
const lon = Math.atan2(rx, rz);
const lat = Math.asin(clamp(ny, -1, 1));
const tex = 0.5 + 0.5 * Math.sin(5 * lon + 3 * Math.sin(lat * 3));
const alb = 0.42 + 0.58 * smooth(0.35, 0.65, tex);
v = 0.05 + 0.95 * normalDot(nx, ny, nzn, lx, ly, lz) * alb;
zBest = nz;
}
const ex = x - mx;
const ey = y - my;
const e2 = ex * ex + ey * ey;
if (e2 < MOON_R * MOON_R) {
const nz = Math.sqrt(MOON_R * MOON_R - e2);
if (mz + nz > zBest) {
v = 0.05 + 0.95 * normalDot(ex / MOON_R, ey / MOON_R, nz / MOON_R, lx, ly, lz) * 0.95;
}
}
const g8 = Math.round(clamp(v, 0, 1) * 255);
const p = (py * sw + px) * 4;
d[p] = g8;
d[p + 1] = g8;
d[p + 2] = g8;
d[p + 3] = 255;
}
}
sctx.putImageData(img, 0, 0);
const cw = cellW(FONT_PX);
const ch = cellH(FONT_PX);
const cols = Math.floor(PW1 / cw);
const rows = Math.floor(PH / ch);
const g = readGrid(cols, rows, cw, ch);
const cA: string[] = [];
for (let i = 0; i < g.lum.length; i += 1) { cA.push(RAMP_SHADE.charAt(glyphIndex(g.lum[i], RAMP_SHADE.length))); }
lastKinds = kinds(usage(cA, RAMP_SHADE));
plainText = plainOf(g, cA);
panel(PX_L, PY, PW1, PH);
drawGlyphs(PX_L + (PW1 - cols * cw) / 2, PY + (PH - rows * ch) / 2, FONT_PX, g, cA, null, [GLYPH]);
}
function statusText(): string {
return '빛 {a}° · 글자 {k}종'.replace('{a}', String(Math.round(lightDeg))).replace('{k}', String(lastKinds));
}
fitCanvas();
if (still) { showRest(); } else { sceneT = START_S; showNow(); startLoop(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'아스키 아트 시연 무대 — Enter 나 Space 로 일시정지, N 으로 한 걸음, R 로 처음부터, C 로 글자 판 복사'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'줄무늬가 자전하는 구와 그 둘레를 도는 작은 달에 도는 빛이 비추는 확산 음영을 열두 단계 글자 농도로 찍은 판. 빛이 닿지 않는 면은 성긴 글자로 가라앉고 바탕에는 옅은 파문이 퍼진다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'아스키 아트 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
<button className={cx("vj__copy")} data-role="copy" type="button">{'복사'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}07 · 球の表面の光と影を文字の濃さに移す陰影 — 計測 累積面積 15.562% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
if (r2 < SPHERE_R * SPHERE_R) {
const nz = Math.sqrt(SPHERE_R * SPHERE_R - r2);
const nx = dx / SPHERE_R;
const ny = dy / SPHERE_R;
const nzn = nz / SPHERE_R;
// 自転 — 法線を縦軸のまわりで回して緯度・経度を求め、縞模様をのせる
const rx = nx * Math.cos(rot) + nzn * Math.sin(rot);
const rz = -nx * Math.sin(rot) + nzn * Math.cos(rot);
const lon = Math.atan2(rx, rz);
const lat = Math.asin(clamp(ny, -1, 1));
const tex = 0.5 + 0.5 * Math.sin(5 * lon + 3 * Math.sin(lat * 3));
const alb = 0.42 + 0.58 * smooth(0.35, 0.65, tex);
v = 0.05 + 0.95 * normalDot(nx, ny, nzn, lx, ly, lz) * alb;
zBest = nz;
}
08セルの平均の色を文字ごとにのせるパレットの着色
アスキーアート かわいい 雰囲気のポスターやプロフィール画像に、文字絵へ色を加えて使います。chroma でセルの平均の色から明るさを除いた色の向きを作り、nearestColor がパステル六色の PALETTE から最も近い番号を選ぶので、光の塊が重なって混ざった色も六色のなかに落ちます。平均5.6色が使われ、ある時刻にはミント169・ピンク153・バター0・空色341・ラベンダー533・灰白384セルが打たれました。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="アスキーアートの舞台 — Enter か Space で一時停止、N で一歩、R でやり直し、C で文字盤をコピー">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="暗い背景に五つの光の塊が漂って重なる場面のセルごとに平均の色を求め、パステル六色のうち最も近い色をそのセルの文字にのせた盤です。明るさは文字の濃さに、色は文字の色になります"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">アスキーアートのデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
<button class="vj__copy" data-role="copy" type="button">コピー</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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-cream;
}
// 무대 상자 — 여기서 시작한 터치만 칸 누르기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step,
.vj__copy {
padding: $sp-1 $sp-2;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.vj__step,
.vj__copy {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover,
.vj__copy:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active,
.vj__copy:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible,
.vj__copy:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 214px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__copy,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step,
.vj__copy { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active,
.vj__copy:hover,
.vj__copy:active { transform: none !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);
}
.vj {
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: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step,
.vj__copy {
padding: 4px 8px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__step,
.vj__copy {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover,
.vj__copy:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active,
.vj__copy:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible,
.vj__copy:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 214px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__copy,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step,
.vj__copy {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active,
.vj__copy:hover,
.vj__copy:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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(a, b, v) { const k = clamp((v - a) / (b - a), 0, 1); return k * k * (3 - 2 * k); }
// 파스텔 팔레트 — 글자 판은 짙은 남보라 바탕 위에 옅은 라벤더 글자(대비 12 이상), 바깥 무대는 크림 라벤더
const PAGE = [246, 243, 252];
const BOARD = [34, 30, 56];
const BOARD_EDGE = [74, 68, 112];
const GLYPH = [226, 219, 250];
const MINT = [168, 226, 204];
const ROSE = [248, 176, 198];
const BUTTER = [250, 226, 150];
const SKY = [170, 208, 248];
const LAV = [196, 184, 244];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function grayStr(l, a) {
const v = Math.round(clamp(l, 0, 1) * 255);
return 'rgba(' + v + ',' + v + ',' + v + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초) — 한 바퀴 LAP_S 초이고 장면의 모든 움직임은 위상 ph = sceneT / LAP_S 의 정수배라 바퀴 끝에서 이음매가 없다
let sceneT = 0;
let lap = 0;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 고정폭 글꼴 목록만
const MONO = 'ui-monospace, "Cascadia Mono", Consolas, Menlo, "DejaVu Sans Mono", "Courier New", monospace';
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
// 글자 한 줄 높이 = 글자 크기의 1.2 배. 폭은 글꼴에서 잰 값(advRatio)을 쓴다
const LINE_K = 1.2;
let advRatio = 0.6;
// ── 원본 장면 캔버스 — 화면에 붙이지 않는 두 번째 캔버스. 장면은 여기 그리고 화소를 읽는다 ──
const srcCv = document.createElement('canvas');
const srcRaw = srcCv.getContext('2d', { willReadFrequently: true });
if (!srcRaw) {
return;
}
const sctx = srcRaw;
// ss = 원본 화소 / 판 화소. 1 이면 판 크기 그대로, 0.5 면 가로세로 절반 해상도로 계산해 읽는다
let srcSs = 1;
function setSrc(w, h, ss) {
const sw = Math.round(w * ss);
const sh = Math.round(h * ss);
if (srcCv.width !== sw || srcCv.height !== sh) {
srcCv.width = sw;
srcCv.height = sh;
}
srcSs = ss;
sctx.setTransform(ss, 0, 0, ss, 0, 0);
}
// cols×rows 칸을 왼쪽 위부터 읽는다. 칸 하나가 원본에서 덮는 영역은 가로 cw, 세로 sh (판 화소 단위)
function readGrid(cols, rows, cw, sh) {
const sw = srcCv.width;
const shp = srcCv.height;
const data = sctx.getImageData(0, 0, sw, shp).data;
const grid = { cols: cols, rows: rows, cw: cw, sh: sh, lum: [], r: [], g: [], b: [] };
for (let cy = 0; cy < rows; cy += 1) {
const y0 = Math.min(shp - 1, Math.floor(cy * sh * srcSs));
const y1 = Math.max(y0 + 1, Math.min(shp, Math.floor((cy + 1) * sh * srcSs)));
for (let cx = 0; cx < cols; cx += 1) {
const x0 = Math.min(sw - 1, Math.floor(cx * cw * srcSs));
const x1 = Math.max(x0 + 1, Math.min(sw, Math.floor((cx + 1) * cw * srcSs)));
let sr = 0;
let sg = 0;
let sb = 0;
let n = 0;
for (let y = y0; y < y1; y += 1) {
let p = (y * sw + x0) * 4;
for (let x = x0; x < x1; x += 1) {
sr += data[p];
sg += data[p + 1];
sb += data[p + 2];
n += 1;
p += 4;
}
}
const mr = sr / n;
const mg = sg / n;
const mb = sb / n;
grid.r.push(mr);
grid.g.push(mg);
grid.b.push(mb);
grid.lum.push((0.2126 * mr + 0.7152 * mg + 0.0722 * mb) / 255);
}
}
return grid;
}
// ── 글자 램프 — 성긴 글자에서 짙은 글자 순서 열여섯 개. n 단계 램프는 이 줄에서 고르게 n 개를 뽑는다 ──
const RAMP_FULL = ' .,:;-~=+*ox#%&@';
function rampOf(n) {
let s = '';
for (let i = 0; i < n; i += 1) {
s += RAMP_FULL.charAt(Math.round(i * (RAMP_FULL.length - 1) / (n - 1)));
}
return s;
}
// 밝기 v(0 에서 1)를 램프 번호로 — 단계 폭 1/n 으로 나눠 내림
function glyphIndex(v, n) { return Math.min(n - 1, Math.max(0, Math.floor(v * n))); }
// 칸 크기 — 글자 크기 fp 를 주면 폭은 글꼴에서 잰 글자 폭, 높이는 줄 높이
function cellW(fp) { return advRatio * fp; }
function cellH(fp) { return LINE_K * fp; }
function measureAdv() {
ctx.font = '100px ' + MONO;
const m = ctx.measureText('M').width / 100;
advRatio = m > 0.3 && m < 0.9 ? m : 0.6;
}
// ── 그리기 도구 ──
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
function paintPage() {
ctx.fillStyle = rgba(PAGE, 1);
ctx.fillRect(0, 0, W, H);
}
// 글자 판 바탕 — 짙은 남보라 둥근 사각형
function panel(x, y, w, h) {
box(x - 1, y - 1, w + 2, h + 2, 11, BOARD_EDGE, 1);
box(x, y, w, h, 10, BOARD, 1);
}
// 글자 판 그리기 — chars 는 칸마다 글자 하나(왼쪽 위부터 줄 순서). colorIdx 가 있으면 칸마다 팔레트 번호, 없으면 모두 pal[0].
// 같은 색이 이어지는 칸은 한 번에 찍어 한 판에 fillText 가 줄 수 × 색 조각 수만큼만 불린다
function drawGlyphs(x, y, fp, g, chars, colorIdx, pal) {
const cw = cellW(fp);
const ch = cellH(fp);
ctx.font = '600 ' + fp + 'px ' + MONO;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
for (let r = 0; r < g.rows; r += 1) {
let c = 0;
while (c < g.cols) {
const ci = colorIdx ? colorIdx[r * g.cols + c] : 0;
let run = '';
let e = c;
while (e < g.cols && (colorIdx ? colorIdx[r * g.cols + e] : 0) === ci) {
run += chars[r * g.cols + e];
e += 1;
}
if (run.trim().length > 0) {
ctx.fillStyle = rgba(pal[ci], 1);
ctx.fillText(run, x + c * cw, y + (r + 0.5) * ch + 0.5);
}
c = e;
}
}
}
// 판의 글자를 줄바꿈으로 이은 텍스트 — コピー 단추가 이 글을 클립보드에 넣는다
function plainOf(g, chars) {
const lines = [];
for (let r = 0; r < g.rows; r += 1) {
lines.push(chars.slice(r * g.cols, (r + 1) * g.cols).join('').replace(/\s+$/, ''));
}
return lines.join('\n');
}
// 쓰인 서로 다른 글자 수(공백 포함)와 글자별 횟수
function usage(chars, ramp) {
const out = [];
for (let i = 0; i < ramp.length; i += 1) {
out.push(0);
}
for (const c of chars) {
const k = ramp.indexOf(c);
if (k >= 0) {
out[k] += 1;
}
}
return out;
}
function kinds(u) {
let n = 0;
for (const v of u) {
if (v > 0) {
n += 1;
}
}
return n;
}
// 글자별 사용 횟수 막대 — 판 아래 띠에 램프 순서대로
function usageBars(x, y, w, h, u, c) {
let mx = 1;
for (const v of u) {
if (v > mx) {
mx = v;
}
}
const bw = w / u.length;
for (let i = 0; i < u.length; i += 1) {
const bh = Math.max(u[i] > 0 ? 2 : 0, h * u[i] / mx);
box(x + i * bw + 1, y + h - bh, bw - 2, bh, 2, c, 0.95);
}
}
// 두 번째 캔버스에 쓰는 회색 장면 도구 — 중심에서 번지는 둥근 빛
function glow(x, y, r, l, a) {
const g = sctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, grayStr(l, a));
g.addColorStop(1, grayStr(l, 0));
sctx.fillStyle = g;
sctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function vgrad(w, h, l0, l1) {
const g = sctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, grayStr(l0, 1));
g.addColorStop(1, grayStr(l1, 1));
sctx.fillStyle = g;
sctx.fillRect(0, 0, w, h);
}
// 판 배치 — 위 46 은 상태 줄·단추 자리. 판 둘이면 가로 228, 하나면 464
const PY = 46;
const PH = 246;
const PW2 = 228;
const PW1 = 464;
const PX_L = 8;
const PX_R = 244;
// コピー 단추를 누른 뒤 상태 줄에 잠깐 뜨는 말
const COPY_OK = '文字盤をテキストでコピーしました';
const COPY_FAIL = 'コピーできませんでした — 文字盤を選んで手でコピーしてください';
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const copyEl = root.querySelector('[data-role="copy"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !copyEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const copyBtn = copyEl;
const doneP = doneEl;
const pad = padEl;
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
measureAdv();
}
// 지금 화면에 찍힌 글자 판 — コピー 단추가 이 텍스트를 넣는다
let plainText = '';
let copyFlash = 0;
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
if (copyFlash > 0) {
copyFlash = Math.max(0, copyFlash - dt);
}
if (sceneT >= LAP_S) {
sceneT -= LAP_S;
lap += 1;
}
}
const DT_MAX = 0.05;
const STEP_S = 0.4;
function showNow() {
draw();
pushStatus(copyFlash > 0 ? copyNote : statusText());
checkDone();
}
let copyNote = '';
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 장면이 한창인 순간의 정지 장면 한 장
function showRest() {
stopLoop();
sceneT = REST_S;
showNow();
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
function restart() {
wake();
sceneT = START_S;
lap = 0;
doneSaid = false;
showNow();
}
// 一歩進める — 멈춘 채 장면 시계를 0.4 초 앞으로
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
advance(STEP_S);
showNow();
}
// コピー — 화면에 있는 글자 판을 텍스트로 클립보드에. 임시 입력칸을 만들어 선택하고 コピー 명령을 부른다
function copyBoard() {
wake();
const ta = document.createElement('textarea');
ta.value = plainText;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
let ok = false;
try {
ok = document.execCommand('copy');
}
catch (err) {
ok = false;
}
document.body.removeChild(ta);
copyNote = ok ? COPY_OK : COPY_FAIL;
copyFlash = 1.6;
pushStatus(copyNote);
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
copyBtn.addEventListener('click', copyBoard, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space·P 는 一時停止, N 은 一歩進める, R 은 처음부터, C 는 글자 판 コピー
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
}
else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
}
else if (k === 'r' || k === 'R') {
e.preventDefault();
restart();
}
else if (k === 'c' || k === 'C') {
e.preventDefault();
copyBoard();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 一時停止 토글
pad.addEventListener('click', function () { togglePause(); }, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
sceneT = START_S;
showNow();
if (!paused) {
startLoop();
}
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 08 칸의 평균 색에 가장 가까운 팔레트 색을 글자마다 입히는 틴트 ───────────────
const LAP_S = 10;
const START_S = 0.6;
const REST_S = 2.6;
const FONT_PX = 10;
const RAMP = rampOf(10);
// 글자 색 팔레트 — 파스텔 여섯 색. 칸의 평균 색에서 밝기를 덜어 낸 색 방향이 가장 가까운 것을 고른다
const PALETTE = [MINT, ROSE, BUTTER, SKY, LAV, [236, 236, 244]];
const PALETTE_N = PALETTE.length;
let colorUse = [0, 0, 0, 0, 0, 0];
let lastKinds = 0;
// 색 방향 — 가장 큰 채널이 255 가 되게 맞춘 색
function chroma(r, g, b) {
const m = Math.max(r, g, b, 1);
return [r * 255 / m, g * 255 / m, b * 255 / m];
}
const PAL_DIR = PALETTE.map(function (c) { return chroma(c[0], c[1], c[2]); });
function nearestColor(r, g, b) {
const c = chroma(r, g, b);
let best = 0;
let bd = 1e9;
for (let i = 0; i < PALETTE_N; i += 1) {
const dr = c[0] - PAL_DIR[i][0];
const dg = c[1] - PAL_DIR[i][1];
const db = c[2] - PAL_DIR[i][2];
const dd = dr * dr + dg * dg + db * db;
if (dd < bd) {
bd = dd;
best = i;
}
}
return best;
}
// 원본 장면 — 어두운 바탕 위에 다섯 가지 빛 덩어리가 떠다니며 겹친다(겹치면 색이 섞인다)
function paintScene(w, h, a) {
sctx.globalCompositeOperation = 'source-over';
sctx.fillStyle = 'rgb(26,24,42)';
sctx.fillRect(0, 0, w, h);
sctx.globalCompositeOperation = 'lighter';
for (let k = 0; k < PALETTE_N - 1; k += 1) {
const c = PALETTE[k];
const x = w * (0.5 + 0.36 * Math.cos(a * (1 + (k % 2)) + k * 1.3));
const y = h * (0.5 + 0.34 * Math.sin(a * (1 + ((k + 1) % 2)) + k * 2.1));
const g = sctx.createRadialGradient(x, y, 0, x, y, 120);
g.addColorStop(0, 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',0.95)');
g.addColorStop(1, 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',0)');
sctx.fillStyle = g;
sctx.fillRect(x - 120, y - 120, 240, 240);
}
sctx.globalCompositeOperation = 'source-over';
}
function draw() {
paintPage();
const a = sceneT / LAP_S * TAU;
setSrc(PW1, PH, 1);
paintScene(PW1, PH, a);
const cw = cellW(FONT_PX);
const ch = cellH(FONT_PX);
const cols = Math.floor(PW1 / cw);
const rows = Math.floor(PH / ch);
const g = readGrid(cols, rows, cw, ch);
const cA = [];
const col = [];
colorUse = [0, 0, 0, 0, 0, 0];
for (let i = 0; i < g.lum.length; i += 1) {
const gi = glyphIndex(Math.min(1, g.lum[i] * 1.5), RAMP.length);
cA.push(RAMP.charAt(gi));
const ci = nearestColor(g.r[i], g.g[i], g.b[i]);
col.push(ci);
if (gi > 0) {
colorUse[ci] += 1;
}
}
lastKinds = 0;
for (const n of colorUse) {
if (n > 0) {
lastKinds += 1;
}
}
plainText = plainOf(g, cA);
panel(PX_L, PY, PW1, PH);
drawGlyphs(PX_L + (PW1 - cols * cw) / 2, PY + (PH - rows * ch) / 2, FONT_PX, g, cA, col, PALETTE);
// 범례 — 팔레트 여섯 칸
box(PX_L + 8, PY + PH - 26, PALETTE_N * 18 + 8, 20, 10, BOARD, 0.9);
for (let k = 0; k < PALETTE_N; k += 1) {
box(PX_L + 12 + k * 18, PY + PH - 22, 14, 12, 4, PALETTE[k], 1);
}
}
function statusText() {
return 'パレット {n}色・使われた色 {k}種'.replace('{n}', String(PALETTE_N)).replace('{k}', String(lastKinds));
}
fitCanvas();
if (still) {
showRest();
}
else {
sceneT = START_S;
showNow();
startLoop();
}
return function () {
stopLoop();
off.abort();
};
})();// PerGlyphColorTint - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./PerGlyphColorTint.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function PerGlyphColorTint() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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(a: number, b: number, v: number): number { const k = clamp((v - a) / (b - a), 0, 1); return k * k * (3 - 2 * k); }
// 파스텔 팔레트 — 글자 판은 짙은 남보라 바탕 위에 옅은 라벤더 글자(대비 12 이상), 바깥 무대는 크림 라벤더
const PAGE: RGB = [246, 243, 252];
const BOARD: RGB = [34, 30, 56];
const BOARD_EDGE: RGB = [74, 68, 112];
const GLYPH: RGB = [226, 219, 250];
const MINT: RGB = [168, 226, 204];
const ROSE: RGB = [248, 176, 198];
const BUTTER: RGB = [250, 226, 150];
const SKY: RGB = [170, 208, 248];
const LAV: RGB = [196, 184, 244];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function grayStr(l: number, a: number): string {
const v = Math.round(clamp(l, 0, 1) * 255);
return 'rgba(' + v + ',' + v + ',' + v + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초) — 한 바퀴 LAP_S 초이고 장면의 모든 움직임은 위상 ph = sceneT / LAP_S 의 정수배라 바퀴 끝에서 이음매가 없다
let sceneT = 0;
let lap = 0;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 고정폭 글꼴 목록만
const MONO = 'ui-monospace, "Cascadia Mono", Consolas, Menlo, "DejaVu Sans Mono", "Courier New", monospace';
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
// 글자 한 줄 높이 = 글자 크기의 1.2 배. 폭은 글꼴에서 잰 값(advRatio)을 쓴다
const LINE_K = 1.2;
let advRatio = 0.6;
// ── 원본 장면 캔버스 — 화면에 붙이지 않는 두 번째 캔버스. 장면은 여기 그리고 화소를 읽는다 ──
const srcCv: HTMLCanvasElement = document.createElement('canvas');
const srcRaw = srcCv.getContext('2d', { willReadFrequently: true });
if (!srcRaw) { return; }
const sctx: CanvasRenderingContext2D = srcRaw;
// ss = 원본 화소 / 판 화소. 1 이면 판 크기 그대로, 0.5 면 가로세로 절반 해상도로 계산해 읽는다
let srcSs = 1;
function setSrc(w: number, h: number, ss: number): void {
const sw = Math.round(w * ss);
const sh = Math.round(h * ss);
if (srcCv.width !== sw || srcCv.height !== sh) {
srcCv.width = sw;
srcCv.height = sh;
}
srcSs = ss;
sctx.setTransform(ss, 0, 0, ss, 0, 0);
}
// 칸 격자 — 칸마다 원본 화소의 평균 밝기(lum, 0 에서 1)와 평균 색을 담는다
type Grid = { cols: number; rows: number; cw: number; sh: number; lum: number[]; r: number[]; g: number[]; b: number[] };
// cols×rows 칸을 왼쪽 위부터 읽는다. 칸 하나가 원본에서 덮는 영역은 가로 cw, 세로 sh (판 화소 단위)
function readGrid(cols: number, rows: number, cw: number, sh: number): Grid {
const sw = srcCv.width;
const shp = srcCv.height;
const data = sctx.getImageData(0, 0, sw, shp).data;
const grid: Grid = { cols: cols, rows: rows, cw: cw, sh: sh, lum: [], r: [], g: [], b: [] };
for (let cy = 0; cy < rows; cy += 1) {
const y0 = Math.min(shp - 1, Math.floor(cy * sh * srcSs));
const y1 = Math.max(y0 + 1, Math.min(shp, Math.floor((cy + 1) * sh * srcSs)));
for (let cx = 0; cx < cols; cx += 1) {
const x0 = Math.min(sw - 1, Math.floor(cx * cw * srcSs));
const x1 = Math.max(x0 + 1, Math.min(sw, Math.floor((cx + 1) * cw * srcSs)));
let sr = 0;
let sg = 0;
let sb = 0;
let n = 0;
for (let y = y0; y < y1; y += 1) {
let p = (y * sw + x0) * 4;
for (let x = x0; x < x1; x += 1) {
sr += data[p];
sg += data[p + 1];
sb += data[p + 2];
n += 1;
p += 4;
}
}
const mr = sr / n;
const mg = sg / n;
const mb = sb / n;
grid.r.push(mr);
grid.g.push(mg);
grid.b.push(mb);
grid.lum.push((0.2126 * mr + 0.7152 * mg + 0.0722 * mb) / 255);
}
}
return grid;
}
// ── 글자 램프 — 성긴 글자에서 짙은 글자 순서 열여섯 개. n 단계 램프는 이 줄에서 고르게 n 개를 뽑는다 ──
const RAMP_FULL = ' .,:;-~=+*ox#%&@';
function rampOf(n: number): string {
let s = '';
for (let i = 0; i < n; i += 1) { s += RAMP_FULL.charAt(Math.round(i * (RAMP_FULL.length - 1) / (n - 1))); }
return s;
}
// 밝기 v(0 에서 1)를 램프 번호로 — 단계 폭 1/n 으로 나눠 내림
function glyphIndex(v: number, n: number): number { return Math.min(n - 1, Math.max(0, Math.floor(v * n))); }
// 칸 크기 — 글자 크기 fp 를 주면 폭은 글꼴에서 잰 글자 폭, 높이는 줄 높이
function cellW(fp: number): number { return advRatio * fp; }
function cellH(fp: number): number { return LINE_K * fp; }
function measureAdv(): void {
ctx.font = '100px ' + MONO;
const m = ctx.measureText('M').width / 100;
advRatio = m > 0.3 && m < 0.9 ? m : 0.6;
}
// ── 그리기 도구 ──
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
function paintPage(): void {
ctx.fillStyle = rgba(PAGE, 1);
ctx.fillRect(0, 0, W, H);
}
// 글자 판 바탕 — 짙은 남보라 둥근 사각형
function panel(x: number, y: number, w: number, h: number): void {
box(x - 1, y - 1, w + 2, h + 2, 11, BOARD_EDGE, 1);
box(x, y, w, h, 10, BOARD, 1);
}
// 글자 판 그리기 — chars 는 칸마다 글자 하나(왼쪽 위부터 줄 순서). colorIdx 가 있으면 칸마다 팔레트 번호, 없으면 모두 pal[0].
// 같은 색이 이어지는 칸은 한 번에 찍어 한 판에 fillText 가 줄 수 × 색 조각 수만큼만 불린다
function drawGlyphs(x: number, y: number, fp: number, g: Grid, chars: string[], colorIdx: number[] | null, pal: RGB[]): void {
const cw = cellW(fp);
const ch = cellH(fp);
ctx.font = '600 ' + fp + 'px ' + MONO;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
for (let r = 0; r < g.rows; r += 1) {
let c = 0;
while (c < g.cols) {
const ci = colorIdx ? colorIdx[r * g.cols + c] : 0;
let run = '';
let e = c;
while (e < g.cols && (colorIdx ? colorIdx[r * g.cols + e] : 0) === ci) { run += chars[r * g.cols + e]; e += 1; }
if (run.trim().length > 0) {
ctx.fillStyle = rgba(pal[ci], 1);
ctx.fillText(run, x + c * cw, y + (r + 0.5) * ch + 0.5);
}
c = e;
}
}
}
// 판의 글자를 줄바꿈으로 이은 텍스트 — 복사 단추가 이 글을 클립보드에 넣는다
function plainOf(g: Grid, chars: string[]): string {
const lines: string[] = [];
for (let r = 0; r < g.rows; r += 1) {
lines.push(chars.slice(r * g.cols, (r + 1) * g.cols).join('').replace(/\s+$/, ''));
}
return lines.join('\n');
}
// 쓰인 서로 다른 글자 수(공백 포함)와 글자별 횟수
function usage(chars: string[], ramp: string): number[] {
const out: number[] = [];
for (let i = 0; i < ramp.length; i += 1) { out.push(0); }
for (const c of chars) {
const k = ramp.indexOf(c);
if (k >= 0) { out[k] += 1; }
}
return out;
}
function kinds(u: number[]): number {
let n = 0;
for (const v of u) { if (v > 0) { n += 1; } }
return n;
}
// 글자별 사용 횟수 막대 — 판 아래 띠에 램프 순서대로
function usageBars(x: number, y: number, w: number, h: number, u: number[], c: RGB): void {
let mx = 1;
for (const v of u) { if (v > mx) { mx = v; } }
const bw = w / u.length;
for (let i = 0; i < u.length; i += 1) {
const bh = Math.max(u[i] > 0 ? 2 : 0, h * u[i] / mx);
box(x + i * bw + 1, y + h - bh, bw - 2, bh, 2, c, 0.95);
}
}
// 두 번째 캔버스에 쓰는 회색 장면 도구 — 중심에서 번지는 둥근 빛
function glow(x: number, y: number, r: number, l: number, a: number): void {
const g = sctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, grayStr(l, a));
g.addColorStop(1, grayStr(l, 0));
sctx.fillStyle = g;
sctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function vgrad(w: number, h: number, l0: number, l1: number): void {
const g = sctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, grayStr(l0, 1));
g.addColorStop(1, grayStr(l1, 1));
sctx.fillStyle = g;
sctx.fillRect(0, 0, w, h);
}
// 판 배치 — 위 46 은 상태 줄·단추 자리. 판 둘이면 가로 228, 하나면 464
const PY = 46;
const PH = 246;
const PW2 = 228;
const PW1 = 464;
const PX_L = 8;
const PX_R = 244;
// 복사 단추를 누른 뒤 상태 줄에 잠깐 뜨는 말
const COPY_OK = '글자 판을 텍스트로 복사했다';
const COPY_FAIL = '복사하지 못했다 — 글자 판을 직접 선택해 복사';
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const copyEl = root.querySelector('[data-role="copy"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !copyEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const copyBtn: HTMLButtonElement = copyEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
measureAdv();
}
// 지금 화면에 찍힌 글자 판 — 복사 단추가 이 텍스트를 넣는다
let plainText = '';
let copyFlash = 0;
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
if (copyFlash > 0) { copyFlash = Math.max(0, copyFlash - dt); }
if (sceneT >= LAP_S) { sceneT -= LAP_S; lap += 1; }
}
const DT_MAX = 0.05;
const STEP_S = 0.4;
function showNow(): void {
draw();
pushStatus(copyFlash > 0 ? copyNote : statusText());
checkDone();
}
let copyNote = '';
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 장면이 한창인 순간의 정지 장면 한 장
function showRest(): void {
stopLoop();
sceneT = REST_S;
showNow();
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
function restart(): void {
wake();
sceneT = START_S;
lap = 0;
doneSaid = false;
showNow();
}
// 한 걸음 — 멈춘 채 장면 시계를 0.4 초 앞으로
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
advance(STEP_S);
showNow();
}
// 복사 — 화면에 있는 글자 판을 텍스트로 클립보드에. 임시 입력칸을 만들어 선택하고 복사 명령을 부른다
function copyBoard(): void {
wake();
const ta = document.createElement('textarea');
ta.value = plainText;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
let ok = false;
try { ok = document.execCommand('copy'); } catch (err) { ok = false; }
document.body.removeChild(ta);
copyNote = ok ? COPY_OK : COPY_FAIL;
copyFlash = 1.6;
pushStatus(copyNote);
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
copyBtn.addEventListener('click', copyBoard, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space·P 는 일시정지, N 은 한 걸음, R 은 처음부터, C 는 글자 판 복사
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
} else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
} else if (k === 'r' || k === 'R') {
e.preventDefault();
restart();
} else if (k === 'c' || k === 'C') {
e.preventDefault();
copyBoard();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 일시정지 토글
pad.addEventListener('click', function (): void { togglePause(); }, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { sceneT = START_S; showNow(); if (!paused) { startLoop(); } }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 08 칸의 평균 색에 가장 가까운 팔레트 색을 글자마다 입히는 틴트 ───────────────
const LAP_S = 10;
const START_S = 0.6;
const REST_S = 2.6;
const FONT_PX = 10;
const RAMP = rampOf(10);
// 글자 색 팔레트 — 파스텔 여섯 색. 칸의 평균 색에서 밝기를 덜어 낸 색 방향이 가장 가까운 것을 고른다
const PALETTE: RGB[] = [MINT, ROSE, BUTTER, SKY, LAV, [236, 236, 244]];
const PALETTE_N = PALETTE.length;
let colorUse: number[] = [0, 0, 0, 0, 0, 0];
let lastKinds = 0;
// 색 방향 — 가장 큰 채널이 255 가 되게 맞춘 색
function chroma(r: number, g: number, b: number): RGB {
const m = Math.max(r, g, b, 1);
return [r * 255 / m, g * 255 / m, b * 255 / m];
}
const PAL_DIR: RGB[] = PALETTE.map(function (c: RGB): RGB { return chroma(c[0], c[1], c[2]); });
function nearestColor(r: number, g: number, b: number): number {
const c = chroma(r, g, b);
let best = 0;
let bd = 1e9;
for (let i = 0; i < PALETTE_N; i += 1) {
const dr = c[0] - PAL_DIR[i][0];
const dg = c[1] - PAL_DIR[i][1];
const db = c[2] - PAL_DIR[i][2];
const dd = dr * dr + dg * dg + db * db;
if (dd < bd) { bd = dd; best = i; }
}
return best;
}
// 원본 장면 — 어두운 바탕 위에 다섯 가지 빛 덩어리가 떠다니며 겹친다(겹치면 색이 섞인다)
function paintScene(w: number, h: number, a: number): void {
sctx.globalCompositeOperation = 'source-over';
sctx.fillStyle = 'rgb(26,24,42)';
sctx.fillRect(0, 0, w, h);
sctx.globalCompositeOperation = 'lighter';
for (let k = 0; k < PALETTE_N - 1; k += 1) {
const c = PALETTE[k];
const x = w * (0.5 + 0.36 * Math.cos(a * (1 + (k % 2)) + k * 1.3));
const y = h * (0.5 + 0.34 * Math.sin(a * (1 + ((k + 1) % 2)) + k * 2.1));
const g = sctx.createRadialGradient(x, y, 0, x, y, 120);
g.addColorStop(0, 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',0.95)');
g.addColorStop(1, 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',0)');
sctx.fillStyle = g;
sctx.fillRect(x - 120, y - 120, 240, 240);
}
sctx.globalCompositeOperation = 'source-over';
}
function draw(): void {
paintPage();
const a = sceneT / LAP_S * TAU;
setSrc(PW1, PH, 1);
paintScene(PW1, PH, a);
const cw = cellW(FONT_PX);
const ch = cellH(FONT_PX);
const cols = Math.floor(PW1 / cw);
const rows = Math.floor(PH / ch);
const g = readGrid(cols, rows, cw, ch);
const cA: string[] = [];
const col: number[] = [];
colorUse = [0, 0, 0, 0, 0, 0];
for (let i = 0; i < g.lum.length; i += 1) {
const gi = glyphIndex(Math.min(1, g.lum[i] * 1.5), RAMP.length);
cA.push(RAMP.charAt(gi));
const ci = nearestColor(g.r[i], g.g[i], g.b[i]);
col.push(ci);
if (gi > 0) { colorUse[ci] += 1; }
}
lastKinds = 0;
for (const n of colorUse) { if (n > 0) { lastKinds += 1; } }
plainText = plainOf(g, cA);
panel(PX_L, PY, PW1, PH);
drawGlyphs(PX_L + (PW1 - cols * cw) / 2, PY + (PH - rows * ch) / 2, FONT_PX, g, cA, col, PALETTE);
// 범례 — 팔레트 여섯 칸
box(PX_L + 8, PY + PH - 26, PALETTE_N * 18 + 8, 20, 10, BOARD, 0.9);
for (let k = 0; k < PALETTE_N; k += 1) { box(PX_L + 12 + k * 18, PY + PH - 22, 14, 12, 4, PALETTE[k], 1); }
}
function statusText(): string {
return '팔레트 {n}색 · 쓰인 색 {k}종'.replace('{n}', String(PALETTE_N)).replace('{k}', String(lastKinds));
}
fitCanvas();
if (still) { showRest(); } else { sceneT = START_S; showNow(); startLoop(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'아스키 아트 시연 무대 — Enter 나 Space 로 일시정지, N 으로 한 걸음, R 로 처음부터, C 로 글자 판 복사'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'어두운 바탕 위에 다섯 빛 덩어리가 떠다니며 겹치는 장면의 칸마다 평균 색을 구해, 파스텔 여섯 색 가운데 가장 가까운 색을 그 칸의 글자에 입힌 판. 밝기는 글자 농도로, 색은 글자 색으로 읽힌다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'아스키 아트 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
<button className={cx("vj__copy")} data-role="copy" type="button">{'복사'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}08 · セルの平均の色を文字ごとにのせるパレットの着色 — 計測 累積面積 45.06% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 色の向き — いちばん大きいチャンネルが255になるよう合わせた色
function chroma(r, g, b) {
const m = Math.max(r, g, b, 1);
return [r * 255 / m, g * 255 / m, b * 255 / m];
}
const PAL_DIR = PALETTE.map(function (c) { return chroma(c[0], c[1], c[2]); });
function nearestColor(r, g, b) {
const c = chroma(r, g, b);
let best = 0;
let bd = 1e9;
for (let i = 0; i < PALETTE_N; i += 1) {
const dr = c[0] - PAL_DIR[i][0];
const dg = c[1] - PAL_DIR[i][1];
const db = c[2] - PAL_DIR[i][2];
const dd = dr * dr + dg * dg + db * db;
if (dd < bd) {
bd = dd;
best = i;
}
}
return best;
}
09波打つ文字バナーでつくるヒーロー
紹介画面やイベント案内の上部に置く文字バナーに使います。行ごとに位相が ROW_PHASE 0.5ずつずれた正弦波に WAVE_COLS 4セルを掛けて丸めた分だけ横にずらして読むので波打ち、元の絵の両側に PAD_COLS 5セルを足してずれた行が端で切れません。20行の盤で一周 loopCols 68セルが10秒で流れ、driftX は秒あたり6.8セルです。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="アスキーアートの舞台 — Enter か Space で一時停止、N で一歩、R でやり直し、C で文字盤をコピー">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="大きな ASCII の文字の行と小さな ascii art の文字の行が逆向きに流れるバナーを文字で打ち、文字の行ごとに位相がずれた正弦波で横にずらして波打たせたヒーローの盤です"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">アスキーアートのデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
<button class="vj__copy" data-role="copy" type="button">コピー</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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-cream;
}
// 무대 상자 — 여기서 시작한 터치만 칸 누르기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step,
.vj__copy {
padding: $sp-1 $sp-2;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.vj__step,
.vj__copy {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover,
.vj__copy:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active,
.vj__copy:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible,
.vj__copy:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 214px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__copy,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step,
.vj__copy { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active,
.vj__copy:hover,
.vj__copy:active { transform: none !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);
}
.vj {
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: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step,
.vj__copy {
padding: 4px 8px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__step,
.vj__copy {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover,
.vj__copy:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active,
.vj__copy:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible,
.vj__copy:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 214px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__copy,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step,
.vj__copy {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active,
.vj__copy:hover,
.vj__copy:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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(a, b, v) { const k = clamp((v - a) / (b - a), 0, 1); return k * k * (3 - 2 * k); }
// 파스텔 팔레트 — 글자 판은 짙은 남보라 바탕 위에 옅은 라벤더 글자(대비 12 이상), 바깥 무대는 크림 라벤더
const PAGE = [246, 243, 252];
const BOARD = [34, 30, 56];
const BOARD_EDGE = [74, 68, 112];
const GLYPH = [226, 219, 250];
const MINT = [168, 226, 204];
const ROSE = [248, 176, 198];
const BUTTER = [250, 226, 150];
const SKY = [170, 208, 248];
const LAV = [196, 184, 244];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function grayStr(l, a) {
const v = Math.round(clamp(l, 0, 1) * 255);
return 'rgba(' + v + ',' + v + ',' + v + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초) — 한 바퀴 LAP_S 초이고 장면의 모든 움직임은 위상 ph = sceneT / LAP_S 의 정수배라 바퀴 끝에서 이음매가 없다
let sceneT = 0;
let lap = 0;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 고정폭 글꼴 목록만
const MONO = 'ui-monospace, "Cascadia Mono", Consolas, Menlo, "DejaVu Sans Mono", "Courier New", monospace';
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
// 글자 한 줄 높이 = 글자 크기의 1.2 배. 폭은 글꼴에서 잰 값(advRatio)을 쓴다
const LINE_K = 1.2;
let advRatio = 0.6;
// ── 원본 장면 캔버스 — 화면에 붙이지 않는 두 번째 캔버스. 장면은 여기 그리고 화소를 읽는다 ──
const srcCv = document.createElement('canvas');
const srcRaw = srcCv.getContext('2d', { willReadFrequently: true });
if (!srcRaw) {
return;
}
const sctx = srcRaw;
// ss = 원본 화소 / 판 화소. 1 이면 판 크기 그대로, 0.5 면 가로세로 절반 해상도로 계산해 읽는다
let srcSs = 1;
function setSrc(w, h, ss) {
const sw = Math.round(w * ss);
const sh = Math.round(h * ss);
if (srcCv.width !== sw || srcCv.height !== sh) {
srcCv.width = sw;
srcCv.height = sh;
}
srcSs = ss;
sctx.setTransform(ss, 0, 0, ss, 0, 0);
}
// cols×rows 칸을 왼쪽 위부터 읽는다. 칸 하나가 원본에서 덮는 영역은 가로 cw, 세로 sh (판 화소 단위)
function readGrid(cols, rows, cw, sh) {
const sw = srcCv.width;
const shp = srcCv.height;
const data = sctx.getImageData(0, 0, sw, shp).data;
const grid = { cols: cols, rows: rows, cw: cw, sh: sh, lum: [], r: [], g: [], b: [] };
for (let cy = 0; cy < rows; cy += 1) {
const y0 = Math.min(shp - 1, Math.floor(cy * sh * srcSs));
const y1 = Math.max(y0 + 1, Math.min(shp, Math.floor((cy + 1) * sh * srcSs)));
for (let cx = 0; cx < cols; cx += 1) {
const x0 = Math.min(sw - 1, Math.floor(cx * cw * srcSs));
const x1 = Math.max(x0 + 1, Math.min(sw, Math.floor((cx + 1) * cw * srcSs)));
let sr = 0;
let sg = 0;
let sb = 0;
let n = 0;
for (let y = y0; y < y1; y += 1) {
let p = (y * sw + x0) * 4;
for (let x = x0; x < x1; x += 1) {
sr += data[p];
sg += data[p + 1];
sb += data[p + 2];
n += 1;
p += 4;
}
}
const mr = sr / n;
const mg = sg / n;
const mb = sb / n;
grid.r.push(mr);
grid.g.push(mg);
grid.b.push(mb);
grid.lum.push((0.2126 * mr + 0.7152 * mg + 0.0722 * mb) / 255);
}
}
return grid;
}
// ── 글자 램프 — 성긴 글자에서 짙은 글자 순서 열여섯 개. n 단계 램프는 이 줄에서 고르게 n 개를 뽑는다 ──
const RAMP_FULL = ' .,:;-~=+*ox#%&@';
function rampOf(n) {
let s = '';
for (let i = 0; i < n; i += 1) {
s += RAMP_FULL.charAt(Math.round(i * (RAMP_FULL.length - 1) / (n - 1)));
}
return s;
}
// 밝기 v(0 에서 1)를 램프 번호로 — 단계 폭 1/n 으로 나눠 내림
function glyphIndex(v, n) { return Math.min(n - 1, Math.max(0, Math.floor(v * n))); }
// 칸 크기 — 글자 크기 fp 를 주면 폭은 글꼴에서 잰 글자 폭, 높이는 줄 높이
function cellW(fp) { return advRatio * fp; }
function cellH(fp) { return LINE_K * fp; }
function measureAdv() {
ctx.font = '100px ' + MONO;
const m = ctx.measureText('M').width / 100;
advRatio = m > 0.3 && m < 0.9 ? m : 0.6;
}
// ── 그리기 도구 ──
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
function paintPage() {
ctx.fillStyle = rgba(PAGE, 1);
ctx.fillRect(0, 0, W, H);
}
// 글자 판 바탕 — 짙은 남보라 둥근 사각형
function panel(x, y, w, h) {
box(x - 1, y - 1, w + 2, h + 2, 11, BOARD_EDGE, 1);
box(x, y, w, h, 10, BOARD, 1);
}
// 글자 판 그리기 — chars 는 칸마다 글자 하나(왼쪽 위부터 줄 순서). colorIdx 가 있으면 칸마다 팔레트 번호, 없으면 모두 pal[0].
// 같은 색이 이어지는 칸은 한 번에 찍어 한 판에 fillText 가 줄 수 × 색 조각 수만큼만 불린다
function drawGlyphs(x, y, fp, g, chars, colorIdx, pal) {
const cw = cellW(fp);
const ch = cellH(fp);
ctx.font = '600 ' + fp + 'px ' + MONO;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
for (let r = 0; r < g.rows; r += 1) {
let c = 0;
while (c < g.cols) {
const ci = colorIdx ? colorIdx[r * g.cols + c] : 0;
let run = '';
let e = c;
while (e < g.cols && (colorIdx ? colorIdx[r * g.cols + e] : 0) === ci) {
run += chars[r * g.cols + e];
e += 1;
}
if (run.trim().length > 0) {
ctx.fillStyle = rgba(pal[ci], 1);
ctx.fillText(run, x + c * cw, y + (r + 0.5) * ch + 0.5);
}
c = e;
}
}
}
// 판의 글자를 줄바꿈으로 이은 텍스트 — コピー 단추가 이 글을 클립보드에 넣는다
function plainOf(g, chars) {
const lines = [];
for (let r = 0; r < g.rows; r += 1) {
lines.push(chars.slice(r * g.cols, (r + 1) * g.cols).join('').replace(/\s+$/, ''));
}
return lines.join('\n');
}
// 쓰인 서로 다른 글자 수(공백 포함)와 글자별 횟수
function usage(chars, ramp) {
const out = [];
for (let i = 0; i < ramp.length; i += 1) {
out.push(0);
}
for (const c of chars) {
const k = ramp.indexOf(c);
if (k >= 0) {
out[k] += 1;
}
}
return out;
}
function kinds(u) {
let n = 0;
for (const v of u) {
if (v > 0) {
n += 1;
}
}
return n;
}
// 글자별 사용 횟수 막대 — 판 아래 띠에 램프 순서대로
function usageBars(x, y, w, h, u, c) {
let mx = 1;
for (const v of u) {
if (v > mx) {
mx = v;
}
}
const bw = w / u.length;
for (let i = 0; i < u.length; i += 1) {
const bh = Math.max(u[i] > 0 ? 2 : 0, h * u[i] / mx);
box(x + i * bw + 1, y + h - bh, bw - 2, bh, 2, c, 0.95);
}
}
// 두 번째 캔버스에 쓰는 회색 장면 도구 — 중심에서 번지는 둥근 빛
function glow(x, y, r, l, a) {
const g = sctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, grayStr(l, a));
g.addColorStop(1, grayStr(l, 0));
sctx.fillStyle = g;
sctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function vgrad(w, h, l0, l1) {
const g = sctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, grayStr(l0, 1));
g.addColorStop(1, grayStr(l1, 1));
sctx.fillStyle = g;
sctx.fillRect(0, 0, w, h);
}
// 판 배치 — 위 46 은 상태 줄·단추 자리. 판 둘이면 가로 228, 하나면 464
const PY = 46;
const PH = 246;
const PW2 = 228;
const PW1 = 464;
const PX_L = 8;
const PX_R = 244;
// コピー 단추를 누른 뒤 상태 줄에 잠깐 뜨는 말
const COPY_OK = '文字盤をテキストでコピーしました';
const COPY_FAIL = 'コピーできませんでした — 文字盤を選んで手でコピーしてください';
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const copyEl = root.querySelector('[data-role="copy"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !copyEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const copyBtn = copyEl;
const doneP = doneEl;
const pad = padEl;
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
measureAdv();
}
// 지금 화면에 찍힌 글자 판 — コピー 단추가 이 텍스트를 넣는다
let plainText = '';
let copyFlash = 0;
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
if (copyFlash > 0) {
copyFlash = Math.max(0, copyFlash - dt);
}
if (sceneT >= LAP_S) {
sceneT -= LAP_S;
lap += 1;
}
}
const DT_MAX = 0.05;
const STEP_S = 0.4;
function showNow() {
draw();
pushStatus(copyFlash > 0 ? copyNote : statusText());
checkDone();
}
let copyNote = '';
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 장면이 한창인 순간의 정지 장면 한 장
function showRest() {
stopLoop();
sceneT = REST_S;
showNow();
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
function restart() {
wake();
sceneT = START_S;
lap = 0;
doneSaid = false;
showNow();
}
// 一歩進める — 멈춘 채 장면 시계를 0.4 초 앞으로
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
advance(STEP_S);
showNow();
}
// コピー — 화면에 있는 글자 판을 텍스트로 클립보드에. 임시 입력칸을 만들어 선택하고 コピー 명령을 부른다
function copyBoard() {
wake();
const ta = document.createElement('textarea');
ta.value = plainText;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
let ok = false;
try {
ok = document.execCommand('copy');
}
catch (err) {
ok = false;
}
document.body.removeChild(ta);
copyNote = ok ? COPY_OK : COPY_FAIL;
copyFlash = 1.6;
pushStatus(copyNote);
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
copyBtn.addEventListener('click', copyBoard, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space·P 는 一時停止, N 은 一歩進める, R 은 처음부터, C 는 글자 판 コピー
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
}
else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
}
else if (k === 'r' || k === 'R') {
e.preventDefault();
restart();
}
else if (k === 'c' || k === 'C') {
e.preventDefault();
copyBoard();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 一時停止 토글
pad.addEventListener('click', function () { togglePause(); }, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
sceneT = START_S;
showNow();
if (!paused) {
startLoop();
}
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 09 파도치는 글자 배너가 히어로 위로 흘러가는 장면 ─────────────────────────
const LAP_S = 10;
const START_S = 0.6;
const REST_S = 3.0;
const FONT_PX = 10;
const RAMP = rampOf(12);
// 글자 줄마다 이어지는 파도 위상(라디안/줄)과 파도 폭(칸)
const ROW_PHASE = 0.5;
const WAVE_COLS = 4;
// 원본 양쪽에 덧대는 칸 수 — 파도로 줄이 밀려도 읽을 칸이 모자라지 않게
const PAD_COLS = WAVE_COLS + 1;
const BANNER = 'ASCII ';
const BANNER_SMALL = 'ascii art ';
let loopCols = 0;
let driftX = 0;
let lastRows = 0;
function bannerFont(px) { return '800 ' + px + 'px ' + FONT_STACK; }
// 원본 장면 — 옅은 띠가 흐르는 바탕에 큰 글자 줄과 작은 글자 줄이 서로 반대로 흐른다. 한 바퀴에 글자 줄 한 마디만큼 흘러 이음매가 없다
function paintScene(w, h, ph) {
const a = ph * TAU;
vgrad(w, h, 0.08, 0.22);
for (let k = 0; k < 5; k += 1) {
sctx.fillStyle = grayStr(0.34, 0.5);
sctx.fillRect(0, ((k * 56 + ph * 56) % 280) - 20, w, 12);
}
glow(w * (0.5 + 0.3 * Math.cos(a)), h * 0.5, 120, 0.5, 0.6);
sctx.textBaseline = 'middle';
sctx.textAlign = 'left';
sctx.font = bannerFont(124);
const u1 = sctx.measureText(BANNER).width;
sctx.fillStyle = grayStr(0.96, 1);
for (let i = -1; i < Math.ceil(w / u1) + 1; i += 1) {
sctx.fillText(BANNER, i * u1 - ph * u1, h * 0.4);
}
sctx.font = bannerFont(42);
const u2 = sctx.measureText(BANNER_SMALL).width;
sctx.fillStyle = grayStr(0.78, 1);
for (let i = -1; i < Math.ceil(w / u2) + 2; i += 1) {
sctx.fillText(BANNER_SMALL, i * u2 + ph * u2, h * 0.76);
}
loopCols = Math.round(u1 / cellW(FONT_PX));
}
function draw() {
paintPage();
const ph = sceneT / LAP_S;
const a = ph * TAU;
const cw = cellW(FONT_PX);
const ch = cellH(FONT_PX);
const cols = Math.floor(PW1 / cw);
const rows = Math.floor(PH / ch);
const sCols = cols + 2 * PAD_COLS;
setSrc(sCols * cw, PH, 1);
paintScene(sCols * cw, PH, ph);
const g = readGrid(sCols, rows, cw, ch);
const cA = [];
for (let r = 0; r < rows; r += 1) {
// 줄마다 위상이 ROW_PHASE 씩 어긋난 사인으로 가로로 밀린다(칸 단위로 반올림)
const off = Math.round(WAVE_COLS * Math.sin(ROW_PHASE * r - a));
for (let c = 0; c < cols; c += 1) {
cA.push(RAMP.charAt(glyphIndex(g.lum[r * sCols + c + PAD_COLS + off], RAMP.length)));
}
}
lastRows = rows;
driftX = loopCols / LAP_S;
const gv = { cols: cols, rows: rows, cw: cw, sh: ch, lum: [], r: [], g: [], b: [] };
plainText = plainOf(gv, cA);
panel(PX_L, PY, PW1, PH);
drawGlyphs(PX_L + (PW1 - cols * cw) / 2, PY + (PH - rows * ch) / 2, FONT_PX, gv, cA, null, [GLYPH]);
}
function statusText() {
return '{r}行・一周 {c}セル・毎秒 {d}セル'.replace('{r}', String(lastRows)).replace('{c}', String(loopCols)).replace('{d}', driftX.toFixed(1));
}
fitCanvas();
if (still) {
showRest();
}
else {
sceneT = START_S;
showNow();
startLoop();
}
return function () {
stopLoop();
off.abort();
};
})();// WaveBannerScroll - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./WaveBannerScroll.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function WaveBannerScroll() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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(a: number, b: number, v: number): number { const k = clamp((v - a) / (b - a), 0, 1); return k * k * (3 - 2 * k); }
// 파스텔 팔레트 — 글자 판은 짙은 남보라 바탕 위에 옅은 라벤더 글자(대비 12 이상), 바깥 무대는 크림 라벤더
const PAGE: RGB = [246, 243, 252];
const BOARD: RGB = [34, 30, 56];
const BOARD_EDGE: RGB = [74, 68, 112];
const GLYPH: RGB = [226, 219, 250];
const MINT: RGB = [168, 226, 204];
const ROSE: RGB = [248, 176, 198];
const BUTTER: RGB = [250, 226, 150];
const SKY: RGB = [170, 208, 248];
const LAV: RGB = [196, 184, 244];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function grayStr(l: number, a: number): string {
const v = Math.round(clamp(l, 0, 1) * 255);
return 'rgba(' + v + ',' + v + ',' + v + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// 장면 시계(초) — 한 바퀴 LAP_S 초이고 장면의 모든 움직임은 위상 ph = sceneT / LAP_S 의 정수배라 바퀴 끝에서 이음매가 없다
let sceneT = 0;
let lap = 0;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 고정폭 글꼴 목록만
const MONO = 'ui-monospace, "Cascadia Mono", Consolas, Menlo, "DejaVu Sans Mono", "Courier New", monospace';
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
// 글자 한 줄 높이 = 글자 크기의 1.2 배. 폭은 글꼴에서 잰 값(advRatio)을 쓴다
const LINE_K = 1.2;
let advRatio = 0.6;
// ── 원본 장면 캔버스 — 화면에 붙이지 않는 두 번째 캔버스. 장면은 여기 그리고 화소를 읽는다 ──
const srcCv: HTMLCanvasElement = document.createElement('canvas');
const srcRaw = srcCv.getContext('2d', { willReadFrequently: true });
if (!srcRaw) { return; }
const sctx: CanvasRenderingContext2D = srcRaw;
// ss = 원본 화소 / 판 화소. 1 이면 판 크기 그대로, 0.5 면 가로세로 절반 해상도로 계산해 읽는다
let srcSs = 1;
function setSrc(w: number, h: number, ss: number): void {
const sw = Math.round(w * ss);
const sh = Math.round(h * ss);
if (srcCv.width !== sw || srcCv.height !== sh) {
srcCv.width = sw;
srcCv.height = sh;
}
srcSs = ss;
sctx.setTransform(ss, 0, 0, ss, 0, 0);
}
// 칸 격자 — 칸마다 원본 화소의 평균 밝기(lum, 0 에서 1)와 평균 색을 담는다
type Grid = { cols: number; rows: number; cw: number; sh: number; lum: number[]; r: number[]; g: number[]; b: number[] };
// cols×rows 칸을 왼쪽 위부터 읽는다. 칸 하나가 원본에서 덮는 영역은 가로 cw, 세로 sh (판 화소 단위)
function readGrid(cols: number, rows: number, cw: number, sh: number): Grid {
const sw = srcCv.width;
const shp = srcCv.height;
const data = sctx.getImageData(0, 0, sw, shp).data;
const grid: Grid = { cols: cols, rows: rows, cw: cw, sh: sh, lum: [], r: [], g: [], b: [] };
for (let cy = 0; cy < rows; cy += 1) {
const y0 = Math.min(shp - 1, Math.floor(cy * sh * srcSs));
const y1 = Math.max(y0 + 1, Math.min(shp, Math.floor((cy + 1) * sh * srcSs)));
for (let cx = 0; cx < cols; cx += 1) {
const x0 = Math.min(sw - 1, Math.floor(cx * cw * srcSs));
const x1 = Math.max(x0 + 1, Math.min(sw, Math.floor((cx + 1) * cw * srcSs)));
let sr = 0;
let sg = 0;
let sb = 0;
let n = 0;
for (let y = y0; y < y1; y += 1) {
let p = (y * sw + x0) * 4;
for (let x = x0; x < x1; x += 1) {
sr += data[p];
sg += data[p + 1];
sb += data[p + 2];
n += 1;
p += 4;
}
}
const mr = sr / n;
const mg = sg / n;
const mb = sb / n;
grid.r.push(mr);
grid.g.push(mg);
grid.b.push(mb);
grid.lum.push((0.2126 * mr + 0.7152 * mg + 0.0722 * mb) / 255);
}
}
return grid;
}
// ── 글자 램프 — 성긴 글자에서 짙은 글자 순서 열여섯 개. n 단계 램프는 이 줄에서 고르게 n 개를 뽑는다 ──
const RAMP_FULL = ' .,:;-~=+*ox#%&@';
function rampOf(n: number): string {
let s = '';
for (let i = 0; i < n; i += 1) { s += RAMP_FULL.charAt(Math.round(i * (RAMP_FULL.length - 1) / (n - 1))); }
return s;
}
// 밝기 v(0 에서 1)를 램프 번호로 — 단계 폭 1/n 으로 나눠 내림
function glyphIndex(v: number, n: number): number { return Math.min(n - 1, Math.max(0, Math.floor(v * n))); }
// 칸 크기 — 글자 크기 fp 를 주면 폭은 글꼴에서 잰 글자 폭, 높이는 줄 높이
function cellW(fp: number): number { return advRatio * fp; }
function cellH(fp: number): number { return LINE_K * fp; }
function measureAdv(): void {
ctx.font = '100px ' + MONO;
const m = ctx.measureText('M').width / 100;
advRatio = m > 0.3 && m < 0.9 ? m : 0.6;
}
// ── 그리기 도구 ──
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
function paintPage(): void {
ctx.fillStyle = rgba(PAGE, 1);
ctx.fillRect(0, 0, W, H);
}
// 글자 판 바탕 — 짙은 남보라 둥근 사각형
function panel(x: number, y: number, w: number, h: number): void {
box(x - 1, y - 1, w + 2, h + 2, 11, BOARD_EDGE, 1);
box(x, y, w, h, 10, BOARD, 1);
}
// 글자 판 그리기 — chars 는 칸마다 글자 하나(왼쪽 위부터 줄 순서). colorIdx 가 있으면 칸마다 팔레트 번호, 없으면 모두 pal[0].
// 같은 색이 이어지는 칸은 한 번에 찍어 한 판에 fillText 가 줄 수 × 색 조각 수만큼만 불린다
function drawGlyphs(x: number, y: number, fp: number, g: Grid, chars: string[], colorIdx: number[] | null, pal: RGB[]): void {
const cw = cellW(fp);
const ch = cellH(fp);
ctx.font = '600 ' + fp + 'px ' + MONO;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
for (let r = 0; r < g.rows; r += 1) {
let c = 0;
while (c < g.cols) {
const ci = colorIdx ? colorIdx[r * g.cols + c] : 0;
let run = '';
let e = c;
while (e < g.cols && (colorIdx ? colorIdx[r * g.cols + e] : 0) === ci) { run += chars[r * g.cols + e]; e += 1; }
if (run.trim().length > 0) {
ctx.fillStyle = rgba(pal[ci], 1);
ctx.fillText(run, x + c * cw, y + (r + 0.5) * ch + 0.5);
}
c = e;
}
}
}
// 판의 글자를 줄바꿈으로 이은 텍스트 — 복사 단추가 이 글을 클립보드에 넣는다
function plainOf(g: Grid, chars: string[]): string {
const lines: string[] = [];
for (let r = 0; r < g.rows; r += 1) {
lines.push(chars.slice(r * g.cols, (r + 1) * g.cols).join('').replace(/\s+$/, ''));
}
return lines.join('\n');
}
// 쓰인 서로 다른 글자 수(공백 포함)와 글자별 횟수
function usage(chars: string[], ramp: string): number[] {
const out: number[] = [];
for (let i = 0; i < ramp.length; i += 1) { out.push(0); }
for (const c of chars) {
const k = ramp.indexOf(c);
if (k >= 0) { out[k] += 1; }
}
return out;
}
function kinds(u: number[]): number {
let n = 0;
for (const v of u) { if (v > 0) { n += 1; } }
return n;
}
// 글자별 사용 횟수 막대 — 판 아래 띠에 램프 순서대로
function usageBars(x: number, y: number, w: number, h: number, u: number[], c: RGB): void {
let mx = 1;
for (const v of u) { if (v > mx) { mx = v; } }
const bw = w / u.length;
for (let i = 0; i < u.length; i += 1) {
const bh = Math.max(u[i] > 0 ? 2 : 0, h * u[i] / mx);
box(x + i * bw + 1, y + h - bh, bw - 2, bh, 2, c, 0.95);
}
}
// 두 번째 캔버스에 쓰는 회색 장면 도구 — 중심에서 번지는 둥근 빛
function glow(x: number, y: number, r: number, l: number, a: number): void {
const g = sctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, grayStr(l, a));
g.addColorStop(1, grayStr(l, 0));
sctx.fillStyle = g;
sctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function vgrad(w: number, h: number, l0: number, l1: number): void {
const g = sctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, grayStr(l0, 1));
g.addColorStop(1, grayStr(l1, 1));
sctx.fillStyle = g;
sctx.fillRect(0, 0, w, h);
}
// 판 배치 — 위 46 은 상태 줄·단추 자리. 판 둘이면 가로 228, 하나면 464
const PY = 46;
const PH = 246;
const PW2 = 228;
const PW1 = 464;
const PX_L = 8;
const PX_R = 244;
// 복사 단추를 누른 뒤 상태 줄에 잠깐 뜨는 말
const COPY_OK = '글자 판을 텍스트로 복사했다';
const COPY_FAIL = '복사하지 못했다 — 글자 판을 직접 선택해 복사';
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const copyEl = root.querySelector('[data-role="copy"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !copyEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const copyBtn: HTMLButtonElement = copyEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
measureAdv();
}
// 지금 화면에 찍힌 글자 판 — 복사 단추가 이 텍스트를 넣는다
let plainText = '';
let copyFlash = 0;
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
if (copyFlash > 0) { copyFlash = Math.max(0, copyFlash - dt); }
if (sceneT >= LAP_S) { sceneT -= LAP_S; lap += 1; }
}
const DT_MAX = 0.05;
const STEP_S = 0.4;
function showNow(): void {
draw();
pushStatus(copyFlash > 0 ? copyNote : statusText());
checkDone();
}
let copyNote = '';
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 장면이 한창인 순간의 정지 장면 한 장
function showRest(): void {
stopLoop();
sceneT = REST_S;
showNow();
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
function restart(): void {
wake();
sceneT = START_S;
lap = 0;
doneSaid = false;
showNow();
}
// 한 걸음 — 멈춘 채 장면 시계를 0.4 초 앞으로
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
advance(STEP_S);
showNow();
}
// 복사 — 화면에 있는 글자 판을 텍스트로 클립보드에. 임시 입력칸을 만들어 선택하고 복사 명령을 부른다
function copyBoard(): void {
wake();
const ta = document.createElement('textarea');
ta.value = plainText;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
let ok = false;
try { ok = document.execCommand('copy'); } catch (err) { ok = false; }
document.body.removeChild(ta);
copyNote = ok ? COPY_OK : COPY_FAIL;
copyFlash = 1.6;
pushStatus(copyNote);
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
copyBtn.addEventListener('click', copyBoard, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space·P 는 일시정지, N 은 한 걸음, R 은 처음부터, C 는 글자 판 복사
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ' || k === 'p' || k === 'P') {
e.preventDefault();
togglePause();
} else if (k === 'n' || k === 'N') {
e.preventDefault();
stepOnce();
} else if (k === 'r' || k === 'R') {
e.preventDefault();
restart();
} else if (k === 'c' || k === 'C') {
e.preventDefault();
copyBoard();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 일시정지 토글
pad.addEventListener('click', function (): void { togglePause(); }, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { sceneT = START_S; showNow(); if (!paused) { startLoop(); } }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 09 파도치는 글자 배너가 히어로 위로 흘러가는 장면 ─────────────────────────
const LAP_S = 10;
const START_S = 0.6;
const REST_S = 3.0;
const FONT_PX = 10;
const RAMP = rampOf(12);
// 글자 줄마다 이어지는 파도 위상(라디안/줄)과 파도 폭(칸)
const ROW_PHASE = 0.5;
const WAVE_COLS = 4;
// 원본 양쪽에 덧대는 칸 수 — 파도로 줄이 밀려도 읽을 칸이 모자라지 않게
const PAD_COLS = WAVE_COLS + 1;
const BANNER = 'ASCII ';
const BANNER_SMALL = 'ascii art ';
let loopCols = 0;
let driftX = 0;
let lastRows = 0;
function bannerFont(px: number): string { return '800 ' + px + 'px ' + FONT_STACK; }
// 원본 장면 — 옅은 띠가 흐르는 바탕에 큰 글자 줄과 작은 글자 줄이 서로 반대로 흐른다. 한 바퀴에 글자 줄 한 마디만큼 흘러 이음매가 없다
function paintScene(w: number, h: number, ph: number): void {
const a = ph * TAU;
vgrad(w, h, 0.08, 0.22);
for (let k = 0; k < 5; k += 1) {
sctx.fillStyle = grayStr(0.34, 0.5);
sctx.fillRect(0, ((k * 56 + ph * 56) % 280) - 20, w, 12);
}
glow(w * (0.5 + 0.3 * Math.cos(a)), h * 0.5, 120, 0.5, 0.6);
sctx.textBaseline = 'middle';
sctx.textAlign = 'left';
sctx.font = bannerFont(124);
const u1 = sctx.measureText(BANNER).width;
sctx.fillStyle = grayStr(0.96, 1);
for (let i = -1; i < Math.ceil(w / u1) + 1; i += 1) { sctx.fillText(BANNER, i * u1 - ph * u1, h * 0.4); }
sctx.font = bannerFont(42);
const u2 = sctx.measureText(BANNER_SMALL).width;
sctx.fillStyle = grayStr(0.78, 1);
for (let i = -1; i < Math.ceil(w / u2) + 2; i += 1) { sctx.fillText(BANNER_SMALL, i * u2 + ph * u2, h * 0.76); }
loopCols = Math.round(u1 / cellW(FONT_PX));
}
function draw(): void {
paintPage();
const ph = sceneT / LAP_S;
const a = ph * TAU;
const cw = cellW(FONT_PX);
const ch = cellH(FONT_PX);
const cols = Math.floor(PW1 / cw);
const rows = Math.floor(PH / ch);
const sCols = cols + 2 * PAD_COLS;
setSrc(sCols * cw, PH, 1);
paintScene(sCols * cw, PH, ph);
const g = readGrid(sCols, rows, cw, ch);
const cA: string[] = [];
for (let r = 0; r < rows; r += 1) {
// 줄마다 위상이 ROW_PHASE 씩 어긋난 사인으로 가로로 밀린다(칸 단위로 반올림)
const off = Math.round(WAVE_COLS * Math.sin(ROW_PHASE * r - a));
for (let c = 0; c < cols; c += 1) {
cA.push(RAMP.charAt(glyphIndex(g.lum[r * sCols + c + PAD_COLS + off], RAMP.length)));
}
}
lastRows = rows;
driftX = loopCols / LAP_S;
const gv: Grid = { cols: cols, rows: rows, cw: cw, sh: ch, lum: [], r: [], g: [], b: [] };
plainText = plainOf(gv, cA);
panel(PX_L, PY, PW1, PH);
drawGlyphs(PX_L + (PW1 - cols * cw) / 2, PY + (PH - rows * ch) / 2, FONT_PX, gv, cA, null, [GLYPH]);
}
function statusText(): string {
return '{r}줄 · 한 바퀴 {c}칸 · 초당 {d}칸'.replace('{r}', String(lastRows)).replace('{c}', String(loopCols)).replace('{d}', driftX.toFixed(1));
}
fitCanvas();
if (still) { showRest(); } else { sceneT = START_S; showNow(); startLoop(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'아스키 아트 시연 무대 — Enter 나 Space 로 일시정지, N 으로 한 걸음, R 로 처음부터, C 로 글자 판 복사'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'큰 글자 줄 ASCII 와 작은 글자 줄 ascii art 가 서로 반대 방향으로 흘러가는 배너를 글자로 찍고, 글자 줄마다 위상이 어긋난 사인으로 가로로 밀어 파도가 치게 한 히어로 판'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'아스키 아트 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
<button className={cx("vj__copy")} data-role="copy" type="button">{'복사'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}09 · 波打つ文字バナーでつくるヒーロー — 計測 累積面積 28.763% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
const sCols = cols + 2 * PAD_COLS;
setSrc(sCols * cw, PH, 1);
paintScene(sCols * cw, PH, ph);
const g = readGrid(sCols, rows, cw, ch);
const cA = [];
for (let r = 0; r < rows; r += 1) {
// 行ごとに位相がROW_PHASEずつずれた正弦波で横にずらす(セル単位に丸める)
const off = Math.round(WAVE_COLS * Math.sin(ROW_PHASE * r - a));
for (let c = 0; c < cols; c += 1) {
cA.push(RAMP.charAt(glyphIndex(g.lum[r * sCols + c + PAD_COLS + off], RAMP.length)));
}
}
アスキーアートの変換をコードで書くと、文字ひとつはいくつの値で決まるのでしょうか?
文字ひとつはセルの平均の明るさひとつと段階表ひとつで決まり、残りの計算はこの明るさを文字の番号に移す前に直す仕事です。そのため九つの場面は glyphIndex の一つの関数を共有し、場面ごとにその前の値だけを変えます。
セルが持つ値としていること
| 値 | 作られる場所 | すること |
|---|---|---|
lum |
readGrid |
セルの元の画素の平均の明るさ、0から1 |
RAMP_FULL |
ソースの定数 | 薄い文字から濃い文字まで16字 |
glyphIndex |
共通の関数 | 明るさを切り捨てて段階表の番号にする |
GLYPH_RATIO |
02 | 文字セルの縦÷横の比、約2.05 |
curve |
03 | 明るさを引き伸ばして曲げ、番号を均等にする |
段階表は RAMP_FULL の16文字で、rampOf(10) はそのうち10個を均等に取り出して、空白と , : - = + o # % @ の順になります。
ターミナル風のランディング背景とローディング場面
06と09は文字盤が流れ続けるので、ターミナル画面のような背景やローディング場面にそのまま重ねられます。06は1行79セルで20行なので横に長い領域に、09は20行がバナーの文字を波にして押すので紹介文の領域に合います。文字盤の上に文を重ねるときは、盤の地 #221e38 の上で基本の文字のコントラストが12.0:1なので、重ねても読めます。
画像を文字に変換する画面
変換画面は、01の段階表と02の比の補正と03の曲線が一本につながったものです。写真を載せるなら比がゆがまないよう先に02を合わせ、露出が片側に偏った写真は03の曲線を通して文字を選び、04で二文字だけにするか05で輪郭だけを別に打つかの選択肢を置けばよいです。
絵をセルに分け、セルの平均の明るさを求め、曲線で明るさを直し、段階表から文字をひとつ選ぶまで — アスキーアートが一コマ計算される順番です
一コマが通る五つの歩み
- 場面を元のキャンバスに描きます。06・07は画素を直接書き、半分の解像度(
FIELD_SS0.5)で計算します。 cellW・cellHで文字セルの大きさを測ってからセルの数を決めます。盤ひとつは79×20、盤ふたつは38×20セルです。readGridがセルごとに平均の明るさと平均の色を読みます。- 03の曲線、04のしきい値、05の勾配のように、場面がのせた計算を通します。
glyphIndexで文字を選び、drawGlyphsが行ごとに同じ色の塊を一度に打ちます。
盤がふたつの場面は左右を見比べる
01から05は左の盤と右の対比版が並び、違いは文字を選ぶ方法かセルを読む形にあります。二つの盤の差がその場面がのせた計算の効果なので、値を変えて試すときも一画面で比べられます。
アスキーアートが壊れるのは、文字セルが縦に長くて絵が縦に伸びて見えるとき・明るさの曲線がなく文字が片側に偏るとき・セルが大きすぎて輪郭がにじむときです
正方形のセルで読むと縦に伸びる絵
02の右の盤がその例です。文字セルひとつが5.9px × 12.0pxなので、正方形のセルで読むと同じ20行が元の上半分しか覆わず、円は幅80pxに縦が約164pxに伸びます。
曲線なしで読むと数文字しか残らない
03の曲線なしの盤は明るさが狭い帯に詰まり、文字は平均4.9種しか使われず、もっとも多く使われた文字ひとつがセルの42%を占めます。曲線を通すと同じ場面が平均10種に広がり、その割合は21%に下がります。
セルが大きいと輪郭の記号を打つセルの数が少ない
05のセルの高さ12pxでは、セル760個のうち境界の記号は平均275セルです。6pxに縮めるとセル3,157個のうち645セルが境界の記号になり、同じ図形の線がよりはっきりつながります。
この編のコードをファイルで持ち帰るには、下のzipをダウンロードしてamzukyh6で開けば、九つの場面の完成ソースが入っています。
セルの大きさと段階の数とガンマを変えると形と読みやすさはどれだけ変わるのでしょうか — セルの大きさ・段階の数・ガンマを変えて比べます
| 名前 | 変えた値 | 感じ |
|---|---|---|
| 段階をもっと細かく | 01 RAMP_LEN 10 → 16 |
同じ場面で使われる文字が平均10種から15種に増えて陰影が細かく分かれますが、となり合う段階の濃さの差が小さく、小さなセルでは見分けにくくなります。セルの数は38×20 = 760のままです |
| ガンマをもっと強く | 03 GAMMA 1.2 → 2.2 |
曲線を通したセルのうち最も暗い五文字が占める割合が平均55%から77%に増えて、中間の明るさが暗い文字へ下がり、影が濃くなります |
| セルをもっと小さく | 05 CELL_PX 12 → 6 |
セルが760個から3,157個へ約4倍になり、境界の記号のセルも平均275個から645個に増えて線がはっきりしますが、一コマの draw はこのPCのChromeで平均0.64msから1.04msに延びました |
値を変える前に見ること
段階表は長いほどよいわけではありません。16段階で使われる文字は15種なので一文字はこの場面で一度も使われず、となりの段階が似て、小さなセルでは区別がぼやけます。
ガンマは0.6で17.7%、1.2で約55%、1.8で70.4%、2.2で約77%と暗い五文字の割合が上がるので、数字だけで盤を選べます。セルを6pxにしても計算時間は4倍ではなく1.6倍ほどの伸びで、この値はこのPCのヘッドレスChromeで20フレームを平均したものなので、機器ごとに違います。
動きを減らす設定では止まった一枚の文字盤です — 場面の説明と状態の行の数値を文で読み上げ、コピーボタンで文字盤をテキストとして持ち出せるアクセシビリティです
キーボードで止めて一歩ずつ見てコピーする
舞台は role="application" で、フォーカスを置くとEnter・Space・Pが一時停止、Nが場面の時計を0.4秒進める一歩、Rが最初から、Cが文字盤のコピーです。ボタンは一時停止(aria-pressed)・一歩・コピーの三つで、舞台を押すと一時停止が切り替わります。コピーは navigator を使わず、一時的な textarea と execCommand('copy') で行い、成功すると状態の行に1.6秒のあいだ知らせます。
文字盤は読み上げに読まれないので数値を文で渡す
キャンバスは role="img" で場面の説明の aria-label を持ちますが、文字盤の文字は絵なので画面読み上げには読まれません。そこで状態の行がセルの数と文字の種類数を文で知らせ、デモのあいだは aria-live="off"、手を触れると polite に変わります。盤の地 #221e38 の上の基本の文字のコントラストは12.0:1、06のもっとも暗い文字の段階は5.4:1、08のパレットは8.8から13.6:1です。
動きを減らす設定と隠れたタブ
prefers-reduced-motion が有効だとループなしで showRest が REST_S 秒の場面を一枚だけ描き、設定が変わると change リスナーが読み直します。タブが隠れるとループを止め、戻ると最初のフレームの間隔を捨てますが、この場合も一時停止・一歩・コピー・Rは画面に反映されます。
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 設定が変わったら — 有効になれば止まった一枚、無効になればまた動く
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
sceneT = START_S;
showNow();
if (!paused) {
startLoop();
}
}
}, { signal: off.signal });
アスキーアートのよくある質問 — ライブラリなしで作る方法・文字の幅がそろわない書体で崩れる理由・ハングルや絵文字を段階表に入れてよいか
アスキーアートの作り方にライブラリは必要ですか
必要ありません。外部のコードなしでブラウザのキャンバス2Dだけを使い、画素を読む getImageData と文字を打つ fillText の二つの呼び出しが仕事を分けています。
文字の幅がそろわない書体ではなぜ絵が崩れるのですか
セルの格子は文字ひとつの幅が同じという前提で組んであるので、比例書体では行ごとに文字の幅が違って列がずれます。この編は等幅の書体の組を使い、100pxで M の幅を測って比にし、セルの高さは文字の大きさの1.2倍の LINE_K にしています。
ハングルや絵文字を段階表に入れてもよいですか
この編はASCIIの16字だけを使い、ハングルや絵文字は試していません。文字ひとつが英字一つ分のセルより広いと格子がずれ、絵文字は charAt で取り出すと半分だけ出るので、使うなら rampOf を文字単位の配列に変えて幅を測り直す必要があります。
関数の基準はMDNのfillTextの文書、文字の幅はmeasureTextの文書、ループはrequestAnimationFrameの文書、動きを減らす設定はprefers-reduced-motionの文書が基準です。キャンバスで地形を作る値ノイズはキャンバスのノイズ地形の編を続けて見るとよいです。









