01A Ramp That Picks Glyphs From a Density Line by Cell Brightness
The scene is split into grid cells, the mean brightness of the pixels in each cell is taken, and the glyph at the matching index is picked from a line ordered from sparse to dense characters. The board on the right cuts the line to two glyphs so the mid tones vanish as the contrast.
Best forThe base of any image to text conversion screen
RAMP_LENreadGridglyphIndex

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="ASCII art stage: Enter or Space pauses, N steps once, R restarts, C copies the glyph board">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="A scene with a sun crossing in an arc, a breathing ring and wave bands below is split into grid cells; the left board prints each cell mean brightness with a ten step glyph ramp, the right board reads the same means with only two glyphs so the mid tones vanish"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">ASCII art demo</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">Pause</button>
<button class="vj__step" data-role="step" type="button">Step</button>
<button class="vj__copy" data-role="copy" type="button">Copy</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;
}
}
}
// 판의 글자를 줄바꿈으로 이은 텍스트 — Copy 단추가 이 글을 클립보드에 넣는다
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;
// Copy 단추를 누른 뒤 상태 줄에 잠깐 뜨는 말
const COPY_OK = 'Glyph board copied as text';
const COPY_FAIL = 'Copy failed - select the glyph board and copy it by hand';
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();
}
// 지금 화면에 찍힌 글자 판 — Copy 단추가 이 텍스트를 넣는다
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();
}
// Step — 멈춘 채 장면 시계를 0.4 초 앞으로
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
advance(STEP_S);
showNow();
}
// Copy — 화면에 있는 글자 판을 텍스트로 클립보드에. 임시 입력칸을 만들어 선택하고 Copy 명령을 부른다
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 는 Pause, N 은 Step, R 은 처음부터, C 는 글자 판 Copy
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 });
// 마우스·터치 — 무대를 누르면 Pause 토글
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('Ramp of {n}'.replace('{n}', String(RAMP_LEN)), PX_L + 8, PY + 14, PAPER, INK, 10, 'left');
tag('Ramp of {n} (contrast)'.replace('{n}', '2'), PX_R + 8, PY + 14, PAPER, INK, 10, 'left');
}
function statusText() {
return 'Ramp of {n} - {k} glyphs - {c}x{r} cells'.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 A Ramp That Picks Glyphs From a Density Line by Cell Brightness — Measured area 18.842% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · ASCII Art JavaScript: 9 Canvas Glyph Scenes
The post this demo lives in
ASCII Art JavaScript: 9 Canvas Glyph ScenesHow does ASCII art turn a picture into characters? 01 picks a glyph by each cell's mean brightness, 05 chooses slash or See all demos in this post
If you like this effect - similar demos
Creative UI08 ASCII density — brightness choosing glyphs9 Text Particle Effect CSS Ideas From Pixels
Creative UI06 A 3D Dither Cube That Turns Face Brightness into Dot DensityCSS Dither: 9 Scenes Drawn by Threshold Math
Creative UI09 A flock that spells — a sentence assembling while dodging the pointer9 Canvas Boids Algorithm Flocking Birds Simulation