04A Row-by-Row Roll When the Phrase Changes
When the phrase changes, the seven rows are not wiped at once; every ROLL_STEP seconds (0.07) the stack moves up one row while the rows of the new phrase come in from below. The top board rolls by whole rows so dots do not smear and it stops exactly on the new phrase, the middle board swaps in one cut halfway through the roll (swapAt), and the bottom board pushes by fractional rows so dots smear.
Best forTransitions for a notice board that cycles several messages
ROLL_STEProwOffsetswapAt

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="LED sign demo stage - Enter or Space pauses, N steps once, R restarts">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="Four phrases OPEN 24H, SALE 30%, NEW MENU and THANK YOU change in turn. The top board rolls the seven rows up one by one at 0.07 second steps, the middle board swaps in one cut, and the bottom board pushes by fractional rows so dots smear."></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">LED sign 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>
</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 {
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.vj__step {
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 {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step: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% - 190px);
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__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step: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 {
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
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 {
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 {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
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__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step: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 ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 파스텔 바깥 무대 — 판 바깥은 연한 면, 글자는 짙은 잉크
const BG_TOP = [248, 244, 252];
const BG_BOT = [228, 234, 250];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const WHITE = [255, 255, 255];
const ROSE = [244, 162, 186];
const MINT = [148, 212, 182];
const SKY = [172, 208, 246];
const BUTTER = [250, 228, 150];
// 전광판 판 — 짙은 남보라 바탕, 꺼진 점은 바탕보다 한 단계 밝은 보라, 켜진 점은 호박색(LED 색은 기능색이라 채도를 유지한다)
const BOARD = [28, 22, 42];
const BOARD_EDGE = [88, 80, 124];
const DOT_OFF = [62, 52, 82];
const AMBER = [255, 184, 72];
const AMBER_HI = [255, 232, 170];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
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) + ')';
}
// 장면 시계(초)·판 반복 번호
let sceneT = 0;
let lap = 0;
// ── 5x7 점 글꼴 — 자체 정의. 글자 하나는 가로 5칸 세로 7칸의 0·1 표이고 1 인 칸이 켜진다 ──
const GLYPH_W = 5;
const GLYPH_H = 7;
// 글자와 글자 사이 빈 col 한 줄
const LETTER_GAP = 1;
const ADV = GLYPH_W + LETTER_GAP;
const GL = {
' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'],
'A': ['01110', '10001', '10001', '11111', '10001', '10001', '10001'],
'B': ['11110', '10001', '10001', '11110', '10001', '10001', '11110'],
'C': ['01110', '10001', '10000', '10000', '10000', '10001', '01110'],
'D': ['11110', '10001', '10001', '10001', '10001', '10001', '11110'],
'E': ['11111', '10000', '10000', '11110', '10000', '10000', '11111'],
'F': ['11111', '10000', '10000', '11110', '10000', '10000', '10000'],
'G': ['01110', '10001', '10000', '10111', '10001', '10001', '01111'],
'H': ['10001', '10001', '10001', '11111', '10001', '10001', '10001'],
'I': ['01110', '00100', '00100', '00100', '00100', '00100', '01110'],
'J': ['00111', '00010', '00010', '00010', '00010', '10010', '01100'],
'K': ['10001', '10010', '10100', '11000', '10100', '10010', '10001'],
'L': ['10000', '10000', '10000', '10000', '10000', '10000', '11111'],
'M': ['10001', '11011', '10101', '10101', '10001', '10001', '10001'],
'N': ['10001', '11001', '10101', '10011', '10001', '10001', '10001'],
'O': ['01110', '10001', '10001', '10001', '10001', '10001', '01110'],
'P': ['11110', '10001', '10001', '11110', '10000', '10000', '10000'],
'Q': ['01110', '10001', '10001', '10001', '10101', '10010', '01101'],
'R': ['11110', '10001', '10001', '11110', '10100', '10010', '10001'],
'S': ['01111', '10000', '10000', '01110', '00001', '00001', '11110'],
'T': ['11111', '00100', '00100', '00100', '00100', '00100', '00100'],
'U': ['10001', '10001', '10001', '10001', '10001', '10001', '01110'],
'V': ['10001', '10001', '10001', '10001', '10001', '01010', '00100'],
'W': ['10001', '10001', '10001', '10101', '10101', '11011', '10001'],
'X': ['10001', '10001', '01010', '00100', '01010', '10001', '10001'],
'Y': ['10001', '10001', '01010', '00100', '00100', '00100', '00100'],
'Z': ['11111', '00001', '00010', '00100', '01000', '10000', '11111'],
'0': ['01110', '10001', '10011', '10101', '11001', '10001', '01110'],
'1': ['00100', '01100', '00100', '00100', '00100', '00100', '01110'],
'2': ['01110', '10001', '00001', '00010', '00100', '01000', '11111'],
'3': ['11110', '00001', '00001', '01110', '00001', '00001', '11110'],
'4': ['00010', '00110', '01010', '10010', '11111', '00010', '00010'],
'5': ['11111', '10000', '11110', '00001', '00001', '10001', '01110'],
'6': ['00110', '01000', '10000', '11110', '10001', '10001', '01110'],
'7': ['11111', '00001', '00010', '00100', '01000', '01000', '01000'],
'8': ['01110', '10001', '10001', '01110', '10001', '10001', '01110'],
'9': ['01110', '10001', '10001', '01111', '00001', '00010', '01100'],
'!': ['00100', '00100', '00100', '00100', '00100', '00000', '00100'],
'.': ['00000', '00000', '00000', '00000', '00000', '01100', '01100'],
'-': ['00000', '00000', '00000', '11111', '00000', '00000', '00000'],
':': ['00000', '01100', '01100', '00000', '01100', '01100', '00000'],
'%': ['11001', '11010', '00010', '00100', '01000', '01011', '10011'],
'+': ['00000', '00100', '00100', '11111', '00100', '00100', '00000'],
'>': ['10000', '01000', '00100', '00010', '00100', '01000', '10000'],
'*': ['00000', '10101', '01110', '11111', '01110', '10101', '00000'],
};
function glyphRows(ch) { return GL[ch] ? GL[ch] : GL[' ']; }
// 문구를 col 목록으로 편다 — col 하나는 7 비트 정수(0 번 비트가 맨 윗줄). 글자 사이 빈 열도 목록에 들어 있고 맨 끝 빈 열은 뺀다
function colsOf(s) {
const out = [];
for (let i = 0; i < s.length; i += 1) {
const rows = glyphRows(s.charAt(i));
for (let c = 0; c < GLYPH_W; c += 1) {
let v = 0;
for (let r = 0; r < GLYPH_H; r += 1) {
if (rows[r].charAt(c) === '1') {
v |= 1 << r;
}
}
out.push(v);
}
if (i < s.length - 1) {
out.push(0);
}
}
return out;
}
function bitOf(cols, c, r) {
return c < 0 || c >= cols.length ? 0 : (cols[c] >> r) & 1;
}
// 순환 목록 — 문구 col 뒤에 gap 개의 빈 열을 이어 한 바퀴를 만든다. 번호는 한 바퀴 길이로 나머지를 취한다
function loopBit(cols, gap, c, r) {
const n = cols.length + gap;
const k = ((c % n) + n) % n;
return k < cols.length ? (cols[k] >> r) & 1 : 0;
}
// ── 판 그리기 도구 ──
let ctxRef = null;
const BOARD_PAD = 6;
function boardW(n, pitch) { return n * pitch + 2 * BOARD_PAD; }
// 점 하나 — halo 가 0 이면 가장자리가 딱 떨어지는 점, 0 보다 크면 그 배율 반지름의 옅은 테두리가 한 겹 더 번진다
function dotOne(cx, cy, rad, round, halo, c, a) {
if (a <= 0.01 || !ctxRef) {
return;
}
const ctx = ctxRef;
if (round) {
if (halo > 0) {
ctx.beginPath();
ctx.arc(cx, cy, rad * halo, 0, TAU);
ctx.fillStyle = rgba(c, a * 0.28);
ctx.fill();
}
ctx.beginPath();
ctx.arc(cx, cy, rad, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
else {
const s = rad * 1.75;
if (halo > 0) {
ctx.fillStyle = rgba(c, a * 0.28);
ctx.fillRect(cx - s * halo / 2, cy - s * halo / 2, s * halo, s * halo);
}
ctx.fillStyle = rgba(c, a);
ctx.fillRect(cx - s / 2, cy - s / 2, s, s);
}
}
// 판 한 장 — 점 cols×rows. lvAt(c, r) 이 0 에서 1 의 밝기를 돌려주고 colAt 이 있으면 열마다 색을 고른다
function board(x, y, cols, rows, pitch, fill, round, halo, lvAt, colAt) {
if (!ctxRef) {
return;
}
const ctx = ctxRef;
const w = boardW(cols, pitch);
const h = boardW(rows, pitch);
ctx.beginPath();
ctx.roundRect(x - 1, y - 1, w + 2, h + 2, 8);
ctx.fillStyle = rgba(BOARD_EDGE, 1);
ctx.fill();
ctx.beginPath();
ctx.roundRect(x, y, w, h, 7);
ctx.fillStyle = rgba(BOARD, 1);
ctx.fill();
const rad = pitch * fill;
for (let c = 0; c < cols; c += 1) {
const col = colAt ? colAt(c) : AMBER;
const cx = x + BOARD_PAD + (c + 0.5) * pitch;
for (let r = 0; r < rows; r += 1) {
const cy = y + BOARD_PAD + (r + 0.5) * pitch;
dotOne(cx, cy, rad, round, 0, DOT_OFF, 1);
const lv = lvAt(c, r);
if (lv > 0.03) {
dotOne(cx, cy, rad * 1.6, true, 0, col, 0.1 * lv);
dotOne(cx, cy, rad, round, halo, col, lv);
if (lv > 0.85 && round) {
dotOne(cx - rad * 0.25, cy - rad * 0.25, rad * 0.34, true, 0, mix(col, [255, 255, 255], 0.6), 0.55 * lv);
}
}
}
}
}
// ── 바깥 무대 도구 ──
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
function box(x, y, w, h, r, c, a) {
if (!ctxRef || w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctxRef.beginPath();
ctxRef.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctxRef.fillStyle = rgba(c, a);
ctxRef.fill();
}
function text(s, x, y, c, a, px, weight, align) {
if (!ctxRef) {
return;
}
ctxRef.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctxRef.textAlign = align;
ctxRef.textBaseline = 'middle';
ctxRef.fillStyle = rgba(c, a);
ctxRef.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
if (!ctxRef) {
return;
}
ctxRef.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctxRef.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 skyFill() {
if (!ctxRef) {
return;
}
const g = ctxRef.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(BG_TOP, 1));
g.addColorStop(1, rgba(BG_BOT, 1));
ctxRef.fillStyle = g;
ctxRef.fillRect(0, 0, W, H);
}
// 판 줄 배치 — 위 46 은 상태 줄·단추 자리. 블록 하나는 이름표 줄 + 판 한 장
const VIEW_X = 10;
const VIEW_COLS = 64;
const PITCH = 7;
const BLOCK_Y = [46, 127, 208];
// 블록 y0 에서 이름표는 y0+7, 판은 y0+16
function blockTag(i, s, bg, fg) { tag(s, VIEW_X + 4, BLOCK_Y[i] + 7, bg, fg, 10, 'left'); }
function blockRight(i, s) { text(s, VIEW_X + boardW(VIEW_COLS, PITCH) - 2, BLOCK_Y[i] + 7, INK, 0.9, 10, 700, 'right'); }
function boardY(i) { return BLOCK_Y[i] + 16; }
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 doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
ctxRef = ctx;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
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);
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
// 화면 낭독기로 가는 문장 — 문구·모드가 바뀔 때만 달라진다. 보이는 상태 줄(statusText)은 따로 두어 계속 바뀌어도 읽히지 않는다
let spoken = '';
function pushSpeak(s) {
if (s === spoken) {
return;
}
spoken = s;
doneP.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 낭독용 문장을 읽히기 시작한다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
pushSpeak(speakText());
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
pushSpeak(speakText());
}
}
function advance(dt) {
sceneT += dt;
if (sceneT >= LAP_S) {
sceneT -= LAP_S;
lap += 1;
build();
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 글자가 이미 흐르는 중인 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
const DT_MAX = 0.05;
const STEP_S = 0.4;
function showNow() {
draw();
pushStatus(statusText());
if (!demo) {
pushSpeak(speakText());
}
checkDone();
}
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();
lap = 0;
sceneT = 0;
build();
runTo(REST_S);
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
function restart() {
wake();
doneSaid = false;
lap += 1;
begin();
showNow();
}
// Step — 멈춘 채 장면 시계를 0.4 초 앞으로
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
runTo(sceneT + STEP_S);
showNow();
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space·P 는 Pause, N 은 Step, R 은 처음부터
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();
}
}, { 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 {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 04 문장이 바뀔 때 행을 밀어 올리는 전환 — 정수 row 굴림 / 한 번에 컷 / 소수 row 이동 ────
// 옛 문구 일곱 row, 빈 row 하나, 새 문구 일곱 행을 위아래로 이어 세운 15 row 더미를 두고 일곱 row 창을 ROLL_STEP 초마다 한 행씩 올린다. 정수 행이라 점이 번지지 않는다
const ROLL_STEP = 0.07;
const HOLD_S = 1.6;
const ROLL_ROWS = GLYPH_H + 1;
const PHRASES = ['OPEN 24H', 'SALE 30%', 'NEW MENU', 'THANK YOU'];
const PH_COLS = PHRASES.map(function (s) { return colsOf(s); });
const PERIOD = HOLD_S + ROLL_ROWS * ROLL_STEP;
const LAP_S = PERIOD * PHRASES.length;
const START_S = HOLD_S + 3 * ROLL_STEP + 0.01;
const REST_S = 0.8;
function build() { return; }
function update(dt) { if (dt < 0) {
return;
} }
// 지금 몇 번째 문구 마디인지와 그 마디 안 경과 시간
function slot() {
const pi = Math.min(PHRASES.length - 1, Math.floor(sceneT / PERIOD));
return { pi: pi, u: sceneT - pi * PERIOD };
}
// 굴림이 시작된 뒤 몇 row 올라갔는지 — 0 에서 ROLL_ROWS 까지 정수
function rowOffset(u) {
return clamp(Math.floor((u - HOLD_S) / ROLL_STEP), 0, ROLL_ROWS);
}
// 컷 교체 — 굴림 구간의 한가운데에서 한 번에 바뀐다
function swapAt(u) { return u >= HOLD_S + (ROLL_ROWS / 2) * ROLL_STEP; }
// 더미 s 번 행의 c 번 col — 0 에서 6 은 옛 문구, 7 은 빈 row, 8 에서 14 는 새 문구
function stackBit(pi, s, c) {
if (s === GLYPH_H || s < 0 || s > GLYPH_H + GLYPH_H) {
return 0;
}
const old = s < GLYPH_H;
const cols = PH_COLS[old ? pi : (pi + 1) % PHRASES.length];
const start = Math.floor((VIEW_COLS - cols.length) / 2);
return bitOf(cols, c - start, old ? s : s - GLYPH_H - 1);
}
function draw() {
skyFill();
const sl = slot();
const o = rowOffset(sl.u);
const p = clamp((sl.u - HOLD_S) / ROLL_STEP, 0, ROLL_ROWS);
const fl = Math.floor(p);
const f = p - fl;
const cut = swapAt(sl.u);
blockTag(0, 'Roll one row at a time - whole rows', MINT, INK);
blockTag(1, 'Swap in one cut', BUTTER, INK);
blockTag(2, 'Push by fractional rows - dots smear', ROSE, INK);
blockRight(0, 'rowOffset ' + o);
blockRight(1, cut ? 'past swapAt' : 'before swapAt');
blockRight(2, 'offset ' + p.toFixed(2));
board(VIEW_X, boardY(0), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c, r) { return stackBit(sl.pi, r + o, c); }, null);
board(VIEW_X, boardY(1), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c, r) { return cut ? stackBit(sl.pi, r + GLYPH_H + 1, c) : stackBit(sl.pi, r, c); }, null);
board(VIEW_X, boardY(2), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c, r) {
return (1 - f) * stackBit(sl.pi, r + fl, c) + f * stackBit(sl.pi, r + fl + 1, c);
}, null);
}
// 화면 낭독기로 보내는 문장 — 지금 표시 문구와 판 이름만 담아 문구가 바뀔 때만 달라진다
function speakText() {
const sl = slot();
const o = rowOffset(sl.u);
return 'Phrase {s} - row roll swap boards'.replace('{s}', PHRASES[o >= ROLL_ROWS ? (sl.pi + 1) % PHRASES.length : sl.pi]);
}
function statusText() {
const sl = slot();
const o = rowOffset(sl.u);
return 'phrase {s} · rolled rows {o} / {n}'.replace('{s}', PHRASES[o >= ROLL_ROWS ? (sl.pi + 1) % PHRASES.length : sl.pi]).replace('{o}', String(o)).replace('{n}', String(ROLL_ROWS));
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// RowRollSwapTransition - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./RowRollSwapTransition.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function RowRollSwapTransition() {
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 ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 파스텔 바깥 무대 — 판 바깥은 연한 면, 글자는 짙은 잉크
const BG_TOP: RGB = [248, 244, 252];
const BG_BOT: RGB = [228, 234, 250];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const WHITE: RGB = [255, 255, 255];
const ROSE: RGB = [244, 162, 186];
const MINT: RGB = [148, 212, 182];
const SKY: RGB = [172, 208, 246];
const BUTTER: RGB = [250, 228, 150];
// 전광판 판 — 짙은 남보라 바탕, 꺼진 점은 바탕보다 한 단계 밝은 보라, 켜진 점은 호박색(LED 색은 기능색이라 채도를 유지한다)
const BOARD: RGB = [28, 22, 42];
const BOARD_EDGE: RGB = [88, 80, 124];
const DOT_OFF: RGB = [62, 52, 82];
const AMBER: RGB = [255, 184, 72];
const AMBER_HI: RGB = [255, 232, 170];
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function 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) + ')';
}
// 장면 시계(초)·판 반복 번호
let sceneT = 0;
let lap = 0;
// ── 5x7 점 글꼴 — 자체 정의. 글자 하나는 가로 5칸 세로 7칸의 0·1 표이고 1 인 칸이 켜진다 ──
const GLYPH_W = 5;
const GLYPH_H = 7;
// 글자와 글자 사이 빈 열 한 줄
const LETTER_GAP = 1;
const ADV = GLYPH_W + LETTER_GAP;
const GL: { [k: string]: string[] } = {
' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'],
'A': ['01110', '10001', '10001', '11111', '10001', '10001', '10001'],
'B': ['11110', '10001', '10001', '11110', '10001', '10001', '11110'],
'C': ['01110', '10001', '10000', '10000', '10000', '10001', '01110'],
'D': ['11110', '10001', '10001', '10001', '10001', '10001', '11110'],
'E': ['11111', '10000', '10000', '11110', '10000', '10000', '11111'],
'F': ['11111', '10000', '10000', '11110', '10000', '10000', '10000'],
'G': ['01110', '10001', '10000', '10111', '10001', '10001', '01111'],
'H': ['10001', '10001', '10001', '11111', '10001', '10001', '10001'],
'I': ['01110', '00100', '00100', '00100', '00100', '00100', '01110'],
'J': ['00111', '00010', '00010', '00010', '00010', '10010', '01100'],
'K': ['10001', '10010', '10100', '11000', '10100', '10010', '10001'],
'L': ['10000', '10000', '10000', '10000', '10000', '10000', '11111'],
'M': ['10001', '11011', '10101', '10101', '10001', '10001', '10001'],
'N': ['10001', '11001', '10101', '10011', '10001', '10001', '10001'],
'O': ['01110', '10001', '10001', '10001', '10001', '10001', '01110'],
'P': ['11110', '10001', '10001', '11110', '10000', '10000', '10000'],
'Q': ['01110', '10001', '10001', '10001', '10101', '10010', '01101'],
'R': ['11110', '10001', '10001', '11110', '10100', '10010', '10001'],
'S': ['01111', '10000', '10000', '01110', '00001', '00001', '11110'],
'T': ['11111', '00100', '00100', '00100', '00100', '00100', '00100'],
'U': ['10001', '10001', '10001', '10001', '10001', '10001', '01110'],
'V': ['10001', '10001', '10001', '10001', '10001', '01010', '00100'],
'W': ['10001', '10001', '10001', '10101', '10101', '11011', '10001'],
'X': ['10001', '10001', '01010', '00100', '01010', '10001', '10001'],
'Y': ['10001', '10001', '01010', '00100', '00100', '00100', '00100'],
'Z': ['11111', '00001', '00010', '00100', '01000', '10000', '11111'],
'0': ['01110', '10001', '10011', '10101', '11001', '10001', '01110'],
'1': ['00100', '01100', '00100', '00100', '00100', '00100', '01110'],
'2': ['01110', '10001', '00001', '00010', '00100', '01000', '11111'],
'3': ['11110', '00001', '00001', '01110', '00001', '00001', '11110'],
'4': ['00010', '00110', '01010', '10010', '11111', '00010', '00010'],
'5': ['11111', '10000', '11110', '00001', '00001', '10001', '01110'],
'6': ['00110', '01000', '10000', '11110', '10001', '10001', '01110'],
'7': ['11111', '00001', '00010', '00100', '01000', '01000', '01000'],
'8': ['01110', '10001', '10001', '01110', '10001', '10001', '01110'],
'9': ['01110', '10001', '10001', '01111', '00001', '00010', '01100'],
'!': ['00100', '00100', '00100', '00100', '00100', '00000', '00100'],
'.': ['00000', '00000', '00000', '00000', '00000', '01100', '01100'],
'-': ['00000', '00000', '00000', '11111', '00000', '00000', '00000'],
':': ['00000', '01100', '01100', '00000', '01100', '01100', '00000'],
'%': ['11001', '11010', '00010', '00100', '01000', '01011', '10011'],
'+': ['00000', '00100', '00100', '11111', '00100', '00100', '00000'],
'>': ['10000', '01000', '00100', '00010', '00100', '01000', '10000'],
'*': ['00000', '10101', '01110', '11111', '01110', '10101', '00000'],
};
function glyphRows(ch: string): string[] { return GL[ch] ? GL[ch] : GL[' ']; }
// 문구를 열 목록으로 편다 — 열 하나는 7 비트 정수(0 번 비트가 맨 윗줄). 글자 사이 빈 열도 목록에 들어 있고 맨 끝 빈 열은 뺀다
function colsOf(s: string): number[] {
const out: number[] = [];
for (let i = 0; i < s.length; i += 1) {
const rows = glyphRows(s.charAt(i));
for (let c = 0; c < GLYPH_W; c += 1) {
let v = 0;
for (let r = 0; r < GLYPH_H; r += 1) {
if (rows[r].charAt(c) === '1') { v |= 1 << r; }
}
out.push(v);
}
if (i < s.length - 1) { out.push(0); }
}
return out;
}
function bitOf(cols: number[], c: number, r: number): number {
return c < 0 || c >= cols.length ? 0 : (cols[c] >> r) & 1;
}
// 순환 목록 — 문구 열 뒤에 gap 개의 빈 열을 이어 한 바퀴를 만든다. 번호는 한 바퀴 길이로 나머지를 취한다
function loopBit(cols: number[], gap: number, c: number, r: number): number {
const n = cols.length + gap;
const k = ((c % n) + n) % n;
return k < cols.length ? (cols[k] >> r) & 1 : 0;
}
// ── 판 그리기 도구 ──
let ctxRef: CanvasRenderingContext2D | null = null;
const BOARD_PAD = 6;
function boardW(n: number, pitch: number): number { return n * pitch + 2 * BOARD_PAD; }
// 점 하나 — halo 가 0 이면 가장자리가 딱 떨어지는 점, 0 보다 크면 그 배율 반지름의 옅은 테두리가 한 겹 더 번진다
function dotOne(cx: number, cy: number, rad: number, round: boolean, halo: number, c: RGB, a: number): void {
if (a <= 0.01 || !ctxRef) { return; }
const ctx = ctxRef;
if (round) {
if (halo > 0) {
ctx.beginPath();
ctx.arc(cx, cy, rad * halo, 0, TAU);
ctx.fillStyle = rgba(c, a * 0.28);
ctx.fill();
}
ctx.beginPath();
ctx.arc(cx, cy, rad, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
} else {
const s = rad * 1.75;
if (halo > 0) {
ctx.fillStyle = rgba(c, a * 0.28);
ctx.fillRect(cx - s * halo / 2, cy - s * halo / 2, s * halo, s * halo);
}
ctx.fillStyle = rgba(c, a);
ctx.fillRect(cx - s / 2, cy - s / 2, s, s);
}
}
// 판 한 장 — 점 cols×rows. lvAt(c, r) 이 0 에서 1 의 밝기를 돌려주고 colAt 이 있으면 열마다 색을 고른다
function board(x: number, y: number, cols: number, rows: number, pitch: number, fill: number, round: boolean, halo: number,
lvAt: (c: number, r: number) => number, colAt: ((c: number) => RGB) | null): void {
if (!ctxRef) { return; }
const ctx = ctxRef;
const w = boardW(cols, pitch);
const h = boardW(rows, pitch);
ctx.beginPath();
ctx.roundRect(x - 1, y - 1, w + 2, h + 2, 8);
ctx.fillStyle = rgba(BOARD_EDGE, 1);
ctx.fill();
ctx.beginPath();
ctx.roundRect(x, y, w, h, 7);
ctx.fillStyle = rgba(BOARD, 1);
ctx.fill();
const rad = pitch * fill;
for (let c = 0; c < cols; c += 1) {
const col = colAt ? colAt(c) : AMBER;
const cx = x + BOARD_PAD + (c + 0.5) * pitch;
for (let r = 0; r < rows; r += 1) {
const cy = y + BOARD_PAD + (r + 0.5) * pitch;
dotOne(cx, cy, rad, round, 0, DOT_OFF, 1);
const lv = lvAt(c, r);
if (lv > 0.03) {
dotOne(cx, cy, rad * 1.6, true, 0, col, 0.1 * lv);
dotOne(cx, cy, rad, round, halo, col, lv);
if (lv > 0.85 && round) { dotOne(cx - rad * 0.25, cy - rad * 0.25, rad * 0.34, true, 0, mix(col, [255, 255, 255], 0.6), 0.55 * lv); }
}
}
}
}
// ── 바깥 무대 도구 ──
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (!ctxRef || w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctxRef.beginPath();
ctxRef.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctxRef.fillStyle = rgba(c, a);
ctxRef.fill();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
if (!ctxRef) { return; }
ctxRef.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctxRef.textAlign = align;
ctxRef.textBaseline = 'middle';
ctxRef.fillStyle = rgba(c, a);
ctxRef.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
if (!ctxRef) { return; }
ctxRef.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctxRef.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 skyFill(): void {
if (!ctxRef) { return; }
const g = ctxRef.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(BG_TOP, 1));
g.addColorStop(1, rgba(BG_BOT, 1));
ctxRef.fillStyle = g;
ctxRef.fillRect(0, 0, W, H);
}
// 판 줄 배치 — 위 46 은 상태 줄·단추 자리. 블록 하나는 이름표 줄 + 판 한 장
const VIEW_X = 10;
const VIEW_COLS = 64;
const PITCH = 7;
const BLOCK_Y = [46, 127, 208];
// 블록 y0 에서 이름표는 y0+7, 판은 y0+16
function blockTag(i: number, s: string, bg: RGB, fg: RGB): void { tag(s, VIEW_X + 4, BLOCK_Y[i] + 7, bg, fg, 10, 'left'); }
function blockRight(i: number, s: string): void { text(s, VIEW_X + boardW(VIEW_COLS, PITCH) - 2, BLOCK_Y[i] + 7, INK, 0.9, 10, 700, 'right'); }
function boardY(i: number): number { return BLOCK_Y[i] + 16; }
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 doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
ctxRef = ctx;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
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);
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
// 화면 낭독기로 가는 문장 — 문구·모드가 바뀔 때만 달라진다. 보이는 상태 줄(statusText)은 따로 두어 계속 바뀌어도 읽히지 않는다
let spoken = '';
function pushSpeak(s: string): void {
if (s === spoken) { return; }
spoken = s;
doneP.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 낭독용 문장을 읽히기 시작한다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
pushSpeak(speakText());
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
pushSpeak(speakText());
}
}
function advance(dt: number): void {
sceneT += dt;
if (sceneT >= LAP_S) { sceneT -= LAP_S; lap += 1; build(); }
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 글자가 이미 흐르는 중인 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
build();
if (lap === 0 && !still) { runTo(START_S); }
}
const DT_MAX = 0.05;
const STEP_S = 0.4;
function showNow(): void {
draw();
pushStatus(statusText());
if (!demo) { pushSpeak(speakText()); }
checkDone();
}
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();
lap = 0;
sceneT = 0;
build();
runTo(REST_S);
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
function restart(): void {
wake();
doneSaid = false;
lap += 1;
begin();
showNow();
}
// 한 걸음 — 멈춘 채 장면 시계를 0.4 초 앞으로
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
runTo(sceneT + STEP_S);
showNow();
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space·P 는 일시정지, N 은 한 걸음, R 은 처음부터
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();
}
}, { 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 { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 04 문장이 바뀔 때 행을 밀어 올리는 전환 — 정수 행 굴림 / 한 번에 컷 / 소수 행 이동 ────
// 옛 문구 일곱 행, 빈 행 하나, 새 문구 일곱 행을 위아래로 이어 세운 15 행 더미를 두고 일곱 행 창을 ROLL_STEP 초마다 한 행씩 올린다. 정수 행이라 점이 번지지 않는다
const ROLL_STEP = 0.07;
const HOLD_S = 1.6;
const ROLL_ROWS = GLYPH_H + 1;
const PHRASES = ['OPEN 24H', 'SALE 30%', 'NEW MENU', 'THANK YOU'];
const PH_COLS = PHRASES.map(function (s: string): number[] { return colsOf(s); });
const PERIOD = HOLD_S + ROLL_ROWS * ROLL_STEP;
const LAP_S = PERIOD * PHRASES.length;
const START_S = HOLD_S + 3 * ROLL_STEP + 0.01;
const REST_S = 0.8;
function build(): void { return; }
function update(dt: number): void { if (dt < 0) { return; } }
// 지금 몇 번째 문구 마디인지와 그 마디 안 경과 시간
function slot(): { pi: number; u: number } {
const pi = Math.min(PHRASES.length - 1, Math.floor(sceneT / PERIOD));
return { pi: pi, u: sceneT - pi * PERIOD };
}
// 굴림이 시작된 뒤 몇 행 올라갔는지 — 0 에서 ROLL_ROWS 까지 정수
function rowOffset(u: number): number {
return clamp(Math.floor((u - HOLD_S) / ROLL_STEP), 0, ROLL_ROWS);
}
// 컷 교체 — 굴림 구간의 한가운데에서 한 번에 바뀐다
function swapAt(u: number): boolean { return u >= HOLD_S + (ROLL_ROWS / 2) * ROLL_STEP; }
// 더미 s 번 행의 c 번 열 — 0 에서 6 은 옛 문구, 7 은 빈 행, 8 에서 14 는 새 문구
function stackBit(pi: number, s: number, c: number): number {
if (s === GLYPH_H || s < 0 || s > GLYPH_H + GLYPH_H) { return 0; }
const old = s < GLYPH_H;
const cols = PH_COLS[old ? pi : (pi + 1) % PHRASES.length];
const start = Math.floor((VIEW_COLS - cols.length) / 2);
return bitOf(cols, c - start, old ? s : s - GLYPH_H - 1);
}
function draw(): void {
skyFill();
const sl = slot();
const o = rowOffset(sl.u);
const p = clamp((sl.u - HOLD_S) / ROLL_STEP, 0, ROLL_ROWS);
const fl = Math.floor(p);
const f = p - fl;
const cut = swapAt(sl.u);
blockTag(0, '행을 한 칸씩 굴린다 — 정수 행', MINT, INK);
blockTag(1, '한 번에 컷으로 바꾼다', BUTTER, INK);
blockTag(2, '소수 행으로 민다 — 점이 번진다', ROSE, INK);
blockRight(0, 'rowOffset ' + o);
blockRight(1, cut ? 'swapAt 지남' : 'swapAt 전');
blockRight(2, 'offset ' + p.toFixed(2));
board(VIEW_X, boardY(0), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c: number, r: number): number { return stackBit(sl.pi, r + o, c); }, null);
board(VIEW_X, boardY(1), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c: number, r: number): number { return cut ? stackBit(sl.pi, r + GLYPH_H + 1, c) : stackBit(sl.pi, r, c); }, null);
board(VIEW_X, boardY(2), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c: number, r: number): number {
return (1 - f) * stackBit(sl.pi, r + fl, c) + f * stackBit(sl.pi, r + fl + 1, c);
}, null);
}
// 화면 낭독기로 보내는 문장 — 지금 표시 문구와 판 이름만 담아 문구가 바뀔 때만 달라진다
function speakText(): string {
const sl = slot();
const o = rowOffset(sl.u);
return '표시 문구 {s} · 행 굴림 전환 판'.replace('{s}', PHRASES[o >= ROLL_ROWS ? (sl.pi + 1) % PHRASES.length : sl.pi]);
}
function statusText(): string {
const sl = slot();
const o = rowOffset(sl.u);
return '표시 문구 {s} · 굴린 행 {o} / {n}'.replace('{s}', PHRASES[o >= ROLL_ROWS ? (sl.pi + 1) % PHRASES.length : sl.pi]).replace('{o}', String(o)).replace('{n}', String(ROLL_ROWS));
}
fitCanvas();
if (still) { showRest(); } else { play(); }
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={'LED 전광판 시연 무대 — Enter 나 Space 로 일시정지, N 으로 한 걸음, R 로 처음부터'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'OPEN 24H, SALE 30%, NEW MENU, THANK YOU 네 문구가 차례로 바뀐다. 위 판은 일곱 행을 0.07초 간격으로 한 행씩 위로 굴려 바꾸고, 가운데 판은 한 번에 컷으로 바꾸며, 아래 판은 소수 행으로 밀어 점이 번진다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'LED 전광판 시연'}</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>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}04 A Row-by-Row Roll When the Phrase Changes — Measured area 20.402% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · Dot Matrix Display: 9 Canvas LED Sign Scenes
The post this demo lives in
Dot Matrix Display: 9 Canvas LED Sign ScenesHow does an LED sign turn a letter into dots? 01 lights only the 1 cells of a 5x7 table, 03 leaves an afterglow on dark See all demos in this post
If you like this effect - similar demos
Feedback05 Last-saved timestamp refresh — the elapsed-time-since-save value recalculates every second and turns into a changing phraseAutosave Indicator: 9 Save-Status Patterns
Feedback09 Letters Whose Depth Value Lifts When the Cursor ArrivesCSS Text Shadow Effects — 9 Patterns
Feedback07 Confetti Burst When the Card Is Fully RevealedScratch Card Reveal: 9 Pixel-Calculated Patterns