電光掲示板 9種 — 5x7のドット文字から残光と行の走査まで
電光掲示板は文字をどうやって点いくつに変えるのでしょうか。01番は5x7の表で1のマスだけを灯し、03番は消えた点に残光を残し、09番は行を一つずつ点ける走査で一枚に見せます。九つの場面は点の格子のバッファを計算して描くコードを、デモと一緒に解きます。
電光掲示板は画面に文字を書くことではなく、文字一つを横5マス縦7マスの点の表に変えて一マスずつ灯す計算です — 電光掲示板を作るには、文字の表と流れる列番号をどう取るかを決めることから始まります
01 ld-bitmap-font-5x7-dot-grid
02 ld-column-scroll-seamless-loop
03 ld-glow-decay-afterimage-trail
04 ld-row-roll-swap-transition
05 ld-brightness-level-quantize-fade
06 ld-dot-pitch-radius-soft-edge
07 ld-width-measure-scroll-or-hold
08 ld-column-position-color-ramp
09 ld-row-scan-multiplex-persistence
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 5x7のドット文字を格子に打つ
- 02 列単位で途切れなく流れるスクロール
- 03 消えた点に残る残光
- 04 文が替わるとき行を押し上げる切り替え
- 05 明るさを数段階に分けて点け消しする
- 06 点の間隔と半径の比
- 07 幅を測って止めるか流すかを決める
- 08 列の位置で色が変わる点
- 09 行を順に点ける走査駆動
九つの場面の枠は一つです。480x300のキャンバスに点の格子の盤を二枚から三枚描き、盤一つは横64列縦7行、点の間隔 PITCH は7pxが基本です。ライブラリもWebGLもCDNも使わず、九つのファイルとも乱数・日付・fetch・外部アドレスの呼び出しが0件なので、場面の時計の経過時間だけで動きます。
点一つの明るさは引数の関数が決めます
board は盤の位置・点の間隔・半径の比・lvAt(c, r) を受け取って点を塗り、点いた点の明るさは lvAt が返す0から1の値一つだけから出ます。暗い下地の点を先に塗り、明るさが0.03を超えたら薄いにじみの円と点を重ね、明るさが0.85を超える丸い点には小さなハイライトをもう一重足します。この呼び出しは九つの場面を合わせて23回あり、場面ごとに変わるのは lvAt の中の計算だけです。
文字一つは7ビットの整数五つになります
文字の表は英大文字・数字・! . - : % + > *・空白の45文字を、'01110' のような文字列7行で書いたものです。colsOf は文句を列の並びに広げ、列一つは7ビットの整数で0番のビットが一番上の行になり、文字と文字の間に空の列が一つ入って、一番最後の空の列は省きます。
// 文句を列の並びに広げる — 列一つは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;
}
時間表だけで動く自動デモ
場面は入力なしで時間表だけで動きます。最初の盤は START_S 秒ぶん先から始まって、すでに文字が流れている途中の姿で開き、LAP_S が過ぎると最初からくり返します。一フレームで進める時間は DT_MAX の0.05秒を超えないように切ってあります。
電光掲示板 9種 — 5x7のドット文字・途切れない列スクロール・消えた点の残光・行を押し上げる切り替え・明るさの段階・点の間隔と半径・幅を測って止めるか流すか・列の位置ごとの色の帯・行の走査駆動
九つの場面がこの順に並ぶ理由と描画で測った値
01はどの場面も頼る文字の表なので先頭に置き、02から05までは時間とともに点の灯り方が変わる仕組み、06から08までは点の形・盤の幅・色を決める盤単位の判断です。09は本物の電光掲示板がなぜ一枚に見えるのかを解くので最後に置きます。
| 場面 | 累積面積 | 動いたフレーム | 強さの平均 |
|---|---|---|---|
| 01 5x7の文字 | 14.1% | 22/23 | 73.8 |
| 02 列スクロール | 37.4% | 23/23 | 105.8 |
| 03 残光 | 24.0% | 23/23 | 80.8 |
| 04 行の押し上げ | 20.4% | 7/23 | 104.6 |
| 05 明るさの段階 | 9.7% | 12/23 | 43.0 |
| 06 点の間隔 | 28.3% | 23/23 | 110.8 |
| 07 幅の測定 | 12.0% | 23/23 | 118.7 |
| 08 色の帯 | 28.1% | 23/23 | 114.5 |
| 09 行の走査 | 19.9% | 23/23 | 58.2 |
描画で測った面積は02の37.4%がもっとも広く、05の9.7%がもっとも狭く、強さの平均は07の118.7が最高で05の43.0が最低です。04は一区切り2.16秒のうち1.6秒が停止なので動いたフレームが7/23にとどまり、05は明るくなる区間と消える区間の間に止まった区間が挟まって12/23です。
015x7のドット文字を格子に打つ
フォントファイルなしで短い文句を点で描く、いちばん小さな方法です。OPEN 24H を一文字1.0秒ずつ打ち、一文字の中では前の84%を使って表35マスを列の順に読み、左の盤は四角い点、右の盤は丸い点で同じ表を描きます。lvOf は文字の中の列位置が GLYPH_W 以上なら文字の間の空の列とみなして0を返します。

<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で1コマ、Rで最初から">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="OPEN 24H という文句を一文字ずつ5x7のドット文字で打つ。左の盤は四角い点、右の盤は丸い点で同じ文字を描き、下には今打っている文字の横5マス縦7マスの0と1の表と、列ごとに計算した整数五つがマスを読むたびに埋まる。表で1のマスだけ点が灯り、読んでいるマスにはピンクの枠が付く。"></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">1コマ</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;
// 글자와 글자 사이 빈 列 한 줄
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[' ']; }
// 문구를 列 목록으로 편다 — 列 하나는 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;
}
// 순환 목록 — 문구 列 뒤에 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();
}
// 1コマ — 멈춘 채 장면 시계를 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 는 一時停止, N 은 1コマ, 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 });
// 마우스·터치 — 무대를 누르면 一時停止 토글
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 });
// ── 01 5x7 점 글꼴을 격자에 찍기 — 文字の表(0·1)에서 1 인 칸만 켜고, 같은 표를 사각 점과 둥근 점으로 비교 ────
// 장면은 표를 列 순서로 한 칸씩 읽는다. 읽힌 칸만 두 판과 표에서 같이 켜지고, 列 하나가 끝나면 그 열의 정수가 오른쪽에 찍힌다
const LAP_S = 9.6;
const START_S = 3.4;
const REST_S = 5.2;
const MSG = 'OPEN 24H';
const T0 = 0.3;
const TYPE_S = 1.0;
const SCAN_SHARE = 0.84;
const VIEW_CH = 5;
const TBL = 17;
const TBL_X = 26;
const TBL_Y = 160;
function build() { return; }
function update(dt) { if (dt < 0) {
return;
} }
// 지금 찍는 글자 번호 idx 와 그 글자 표에서 읽힌 칸 수 n
function cur() {
const u = Math.max(0, sceneT - T0);
const idx = Math.min(MSG.length - 1, Math.floor(u / TYPE_S));
const within = Math.min(1, (u - idx * TYPE_S) / (TYPE_S * SCAN_SHARE));
return { idx: idx, n: sceneT < T0 ? 0 : Math.ceil(within * GLYPH_W * GLYPH_H) };
}
function onesOf(ch) {
let n = 0;
for (const row of glyphRows(ch)) {
for (let c = 0; c < GLYPH_W; c += 1) {
if (row.charAt(c) === '1') {
n += 1;
}
}
}
return n;
}
function lvOf(c, r, first, idx, n) {
const s = Math.floor(c / ADV);
const cc = c % ADV;
if (cc >= GLYPH_W) {
return 0;
}
const k = first + s;
if (k > idx) {
return 0;
}
if (glyphRows(MSG.charAt(k))[r].charAt(cc) !== '1') {
return 0;
}
if (k < idx) {
return 1;
}
const cell = cc * GLYPH_H + r;
return cell < n ? (cell === n - 1 ? 1 : 0.9) : 0;
}
function draw() {
skyFill();
const st = cur();
const first = Math.max(0, st.idx - (VIEW_CH - 1));
const bw = boardW(VIEW_CH * ADV, PITCH);
tag('四角い点', 10 + bw / 2, 58, MINT, INK, 10, 'center');
tag('丸い点', 246 + bw / 2, 58, ROSE, INK, 10, 'center');
board(10, 68, VIEW_CH * ADV, GLYPH_H, PITCH, 0.46, false, 0, function (c, r) { return lvOf(c, r, first, st.idx, st.n); }, null);
board(246, 68, VIEW_CH * ADV, GLYPH_H, PITCH, 0.42, true, 0, function (c, r) { return lvOf(c, r, first, st.idx, st.n); }, null);
// 文字の表 — 같은 글자의 0·1 표. 읽는 칸에 분홍 테두리
const ch = MSG.charAt(st.idx);
const rows = glyphRows(ch);
tag('文字の表', TBL_X + (GLYPH_W * TBL) / 2, 150, BUTTER, INK, 10, 'center');
for (let r = 0; r < GLYPH_H; r += 1) {
for (let cc = 0; cc < GLYPH_W; cc += 1) {
const x = TBL_X + cc * TBL;
const y = TBL_Y + r * TBL;
const cell = cc * GLYPH_H + r;
const seen = cell < st.n;
const one = rows[r].charAt(cc) === '1';
if (cell === st.n - 1) {
box(x - 2, y - 2, TBL + 2, TBL + 2, 5, ROSE, 1);
}
box(x, y, TBL - 2, TBL - 2, 4, one ? BUTTER : PAPER, seen ? 1 : 0.35);
text(one ? '1' : '0', x + (TBL - 2) / 2, y + (TBL - 2) / 2 + 0.5, INK, seen ? 1 : 0.4, 11, 700, 'center');
}
}
// 列 정수 — 표의 列 하나를 위 비트가 0 번인 7 비트 정수로
const cols = colsOf(ch);
for (let cc = 0; cc < GLYPH_W; cc += 1) {
const done = (cc + 1) * GLYPH_H <= st.n;
const x = 138 + cc * 66;
text('cols[' + cc + ']', x + 30, 172, INK, 0.9, 9, 700, 'center');
box(x, 182, 60, 32, 8, done ? PAPER : WHITE, done ? 1 : 0.35);
text(done ? String(cols[cc]) : '--', x + 30, 198.5, INK, done ? 1 : 0.4, 16, 700, 'center');
}
text('横{w}マス x 縦{h}マス = {n}マス'.replace('{w}', String(GLYPH_W)).replace('{h}', String(GLYPH_H)).replace('{n}', String(GLYPH_W * GLYPH_H)), 138, 240, INK, 1, 12, 700, 'left');
text('1のマス{a}個だけ点が灯る'.replace('{a}', String(onesOf(ch))), 138, 262, INK, 1, 12, 700, 'left');
}
// 화면 낭독기로 보내는 문장 — 표시 문구와 판 이름만 담아 장면이 흘러도 달라지지 않는다
function speakText() {
return '表示文句 {s} - 5x7ドット文字の盤'.replace('{s}', MSG);
}
function statusText() {
const st = cur();
return '文字 {c} · 1のマス {a}個 · 表 {n} / 35 マス読んだ'.replace('{c}', MSG.charAt(st.idx)).replace('{a}', String(onesOf(MSG.charAt(st.idx)))).replace('{n}', String(st.n));
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// BitmapFont5x7DotGrid - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./BitmapFont5x7DotGrid.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function BitmapFont5x7DotGrid() {
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 });
// ── 01 5x7 점 글꼴을 격자에 찍기 — 글자 표(0·1)에서 1 인 칸만 켜고, 같은 표를 사각 점과 둥근 점으로 비교 ────
// 장면은 표를 열 순서로 한 칸씩 읽는다. 읽힌 칸만 두 판과 표에서 같이 켜지고, 열 하나가 끝나면 그 열의 정수가 오른쪽에 찍힌다
const LAP_S = 9.6;
const START_S = 3.4;
const REST_S = 5.2;
const MSG = 'OPEN 24H';
const T0 = 0.3;
const TYPE_S = 1.0;
const SCAN_SHARE = 0.84;
const VIEW_CH = 5;
const TBL = 17;
const TBL_X = 26;
const TBL_Y = 160;
function build(): void { return; }
function update(dt: number): void { if (dt < 0) { return; } }
// 지금 찍는 글자 번호 idx 와 그 글자 표에서 읽힌 칸 수 n
function cur(): { idx: number; n: number } {
const u = Math.max(0, sceneT - T0);
const idx = Math.min(MSG.length - 1, Math.floor(u / TYPE_S));
const within = Math.min(1, (u - idx * TYPE_S) / (TYPE_S * SCAN_SHARE));
return { idx: idx, n: sceneT < T0 ? 0 : Math.ceil(within * GLYPH_W * GLYPH_H) };
}
function onesOf(ch: string): number {
let n = 0;
for (const row of glyphRows(ch)) {
for (let c = 0; c < GLYPH_W; c += 1) { if (row.charAt(c) === '1') { n += 1; } }
}
return n;
}
function lvOf(c: number, r: number, first: number, idx: number, n: number): number {
const s = Math.floor(c / ADV);
const cc = c % ADV;
if (cc >= GLYPH_W) { return 0; }
const k = first + s;
if (k > idx) { return 0; }
if (glyphRows(MSG.charAt(k))[r].charAt(cc) !== '1') { return 0; }
if (k < idx) { return 1; }
const cell = cc * GLYPH_H + r;
return cell < n ? (cell === n - 1 ? 1 : 0.9) : 0;
}
function draw(): void {
skyFill();
const st = cur();
const first = Math.max(0, st.idx - (VIEW_CH - 1));
const bw = boardW(VIEW_CH * ADV, PITCH);
tag('사각 점', 10 + bw / 2, 58, MINT, INK, 10, 'center');
tag('둥근 점', 246 + bw / 2, 58, ROSE, INK, 10, 'center');
board(10, 68, VIEW_CH * ADV, GLYPH_H, PITCH, 0.46, false, 0, function (c: number, r: number): number { return lvOf(c, r, first, st.idx, st.n); }, null);
board(246, 68, VIEW_CH * ADV, GLYPH_H, PITCH, 0.42, true, 0, function (c: number, r: number): number { return lvOf(c, r, first, st.idx, st.n); }, null);
// 글자 표 — 같은 글자의 0·1 표. 읽는 칸에 분홍 테두리
const ch = MSG.charAt(st.idx);
const rows = glyphRows(ch);
tag('글자 표', TBL_X + (GLYPH_W * TBL) / 2, 150, BUTTER, INK, 10, 'center');
for (let r = 0; r < GLYPH_H; r += 1) {
for (let cc = 0; cc < GLYPH_W; cc += 1) {
const x = TBL_X + cc * TBL;
const y = TBL_Y + r * TBL;
const cell = cc * GLYPH_H + r;
const seen = cell < st.n;
const one = rows[r].charAt(cc) === '1';
if (cell === st.n - 1) { box(x - 2, y - 2, TBL + 2, TBL + 2, 5, ROSE, 1); }
box(x, y, TBL - 2, TBL - 2, 4, one ? BUTTER : PAPER, seen ? 1 : 0.35);
text(one ? '1' : '0', x + (TBL - 2) / 2, y + (TBL - 2) / 2 + 0.5, INK, seen ? 1 : 0.4, 11, 700, 'center');
}
}
// 열 정수 — 표의 열 하나를 위 비트가 0 번인 7 비트 정수로
const cols = colsOf(ch);
for (let cc = 0; cc < GLYPH_W; cc += 1) {
const done = (cc + 1) * GLYPH_H <= st.n;
const x = 138 + cc * 66;
text('cols[' + cc + ']', x + 30, 172, INK, 0.9, 9, 700, 'center');
box(x, 182, 60, 32, 8, done ? PAPER : WHITE, done ? 1 : 0.35);
text(done ? String(cols[cc]) : '--', x + 30, 198.5, INK, done ? 1 : 0.4, 16, 700, 'center');
}
text('가로 {w}칸 x 세로 {h}칸 = {n}칸'.replace('{w}', String(GLYPH_W)).replace('{h}', String(GLYPH_H)).replace('{n}', String(GLYPH_W * GLYPH_H)), 138, 240, INK, 1, 12, 700, 'left');
text('1 인 칸 {a} 개만 점이 켜진다'.replace('{a}', String(onesOf(ch))), 138, 262, INK, 1, 12, 700, 'left');
}
// 화면 낭독기로 보내는 문장 — 표시 문구와 판 이름만 담아 장면이 흘러도 달라지지 않는다
function speakText(): string {
return '표시 문구 {s} · 5x7 점 글꼴 판'.replace('{s}', MSG);
}
function statusText(): string {
const st = cur();
return '글자 {c} · 1 인 칸 {a} 개 · 표 {n} / 35 칸 읽음'.replace('{c}', MSG.charAt(st.idx)).replace('{a}', String(onesOf(MSG.charAt(st.idx)))).replace('{n}', String(st.n));
}
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 라는 문구를 한 글자씩 5x7 점 글꼴로 찍는다. 왼쪽 판은 사각 점, 오른쪽 판은 둥근 점으로 같은 글자를 그리고, 아래에는 지금 찍는 글자의 가로 5칸 세로 7칸 0과 1 표와 열마다 계산된 정수 다섯 개가 칸이 읽힐 때마다 채워진다. 표에서 1 인 칸만 점이 켜지고 읽는 칸에는 분홍 테두리가 붙는다'}></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>
);
}01 · 5x7のドット文字を格子に打つ — 計測 累積面積 14.124% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function lvOf(c, r, first, idx, n) {
const s = Math.floor(c / ADV);
const cc = c % ADV;
if (cc >= GLYPH_W) {
return 0;
}
const k = first + s;
if (k > idx) {
return 0;
}
if (glyphRows(MSG.charAt(k))[r].charAt(cc) !== '1') {
return 0;
}
if (k < idx) {
return 1;
}
const cell = cc * GLYPH_H + r;
return cell < n ? (cell === n - 1 ? 1 : 0.9) : 0;
}
02列単位で途切れなく流れるスクロール
案内や告知の一行が止まらずに流れる、電光掲示板の基本の動きです。開始の列番号を毎秒 SCROLL_SPEED の24列ずつ進め、Math.floor で整数だけを使うので点がにじまず、SALE 30% OFF TODAY の107列のあとに GAP_COLS の13列の空きを足して一周を120列にします。終わりから始まりへ移る場所には空の列が連続13本入り、空きを0にすると連続0本になって、終わりの文字Yと最初の文字Sがくっつきます。

<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で1コマ、Rで最初から">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="SALE 30% OFF TODAY という文句が三つの盤で右から左へ流れる。上の盤は整数の列だけで動き文句の後ろに13列の空きを置くので継ぎ目がなく、中の盤は小数の列で動くので点がにじみ、下の盤は空きがないので終わりと始まりがくっつき継ぎ目の印が跳ねる。"></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">1コマ</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;
// 글자와 글자 사이 빈 列 한 줄
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[' ']; }
// 문구를 列 목록으로 편다 — 列 하나는 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;
}
// 순환 목록 — 문구 列 뒤에 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();
}
// 1コマ — 멈춘 채 장면 시계를 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 는 一時停止, N 은 1コマ, 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 });
// 마우스·터치 — 무대를 누르면 一時停止 토글
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 });
// ── 02 列 단위로 끊김 없이 흐르는 스크롤 — 정수 列 이동 + 빈틈 / 소수 列 이동(번짐) / 빈틈 없음(이음새) ────
// 위 판은 시작 列 번호를 정수로만 올리고 문구 뒤에 GAP_COLS 列 빈틈을 이어 한 바퀴를 만든다. 가운데는 같은 속도를 소수 열로 옮겨 점이 번지고, 아래는 빈틈이 없어 끝과 처음이 붙는다
const SCROLL_SPEED = 24;
const GAP_COLS = 13;
const LEAD = 4;
const LAP_S = 10;
const START_S = 2.0;
const REST_S = 0;
const MSG = 'SALE 30% OFF TODAY';
const cols = colsOf(MSG);
// 한 판 시간은 시계가 이어지는 총 시간으로 센다 — 판이 되풀이돼도 위상이 끊기지 않게
function clock() { return sceneT + lap * LAP_S; }
function build() { return; }
function update(dt) { if (dt < 0) {
return;
} }
// 한 바퀴의 列 수 — 문구 列 뒤에 gap 개의 빈 列
function loopLen(gap) { return cols.length + gap; }
// 시작 列 번호 i 에서 보이는 칸 c 의 켜짐 — 순환 목록에서 읽는다
function colAt(i, c, r, gap) { return loopBit(cols, gap, i + c - LEAD, r); }
function headCol() { return Math.floor(clock() * SCROLL_SPEED); }
// 문구 첫 열이 보이는 칸 번호 — 보이는 범위 밖이면 -1
function seamCol(gap) {
const n = loopLen(gap);
const c = (((LEAD - headCol()) % n) + n) % n;
return c < VIEW_COLS ? c : -1;
}
function seamMark(i, gap, c) {
const sc = seamCol(gap);
if (sc < 0) {
return;
}
box(VIEW_X + BOARD_PAD + sc * PITCH + 1, boardY(i) + BOARD_PAD * 2 + GLYPH_H * PITCH - 3, PITCH - 2, 2.5, 1.2, c, 1);
}
function draw() {
skyFill();
const h = headCol();
const p = clock() * SCROLL_SPEED;
const fl = Math.floor(p);
const f = p - fl;
blockTag(0, '整数の列移動 + 空き{g}列'.replace('{g}', String(GAP_COLS)), MINT, INK);
blockTag(1, '小数の列移動 - 点がにじむ', ROSE, INK);
blockTag(2, '空きなし - 終わりと始まりがくっつく', BUTTER, INK);
blockRight(0, 'start ' + (h % loopLen(GAP_COLS)));
blockRight(1, 'start ' + p.toFixed(2));
blockRight(2, 'start ' + (h % loopLen(0)));
board(VIEW_X, boardY(0), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c, r) { return colAt(h, c, r, GAP_COLS); }, null);
board(VIEW_X, boardY(1), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c, r) {
return (1 - f) * colAt(fl, c, r, GAP_COLS) + f * colAt(fl + 1, c, r, GAP_COLS);
}, null);
board(VIEW_X, boardY(2), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c, r) { return colAt(h, c, r, 0); }, null);
seamMark(0, GAP_COLS, MINT);
seamMark(2, 0, ROSE);
}
// 화면 낭독기로 보내는 문장 — 표시 문구와 판 이름만 담아 장면이 흘러도 달라지지 않는다
function speakText() {
return '表示文句 {s} - 継ぎ目のない列スクロールの盤'.replace('{s}', MSG);
}
function statusText() {
return '一周 {l}列 · 毎秒 {v}列 · 開始列 {c}'.replace('{l}', String(loopLen(GAP_COLS))).replace('{v}', String(SCROLL_SPEED)).replace('{c}', String(headCol() % loopLen(GAP_COLS)));
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// ColumnScrollSeamlessLoop - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./ColumnScrollSeamlessLoop.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function ColumnScrollSeamlessLoop() {
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 });
// ── 02 열 단위로 끊김 없이 흐르는 스크롤 — 정수 열 이동 + 빈틈 / 소수 열 이동(번짐) / 빈틈 없음(이음새) ────
// 위 판은 시작 열 번호를 정수로만 올리고 문구 뒤에 GAP_COLS 열 빈틈을 이어 한 바퀴를 만든다. 가운데는 같은 속도를 소수 열로 옮겨 점이 번지고, 아래는 빈틈이 없어 끝과 처음이 붙는다
const SCROLL_SPEED = 24;
const GAP_COLS = 13;
const LEAD = 4;
const LAP_S = 10;
const START_S = 2.0;
const REST_S = 0;
const MSG = 'SALE 30% OFF TODAY';
const cols = colsOf(MSG);
// 한 판 시간은 시계가 이어지는 총 시간으로 센다 — 판이 되풀이돼도 위상이 끊기지 않게
function clock(): number { return sceneT + lap * LAP_S; }
function build(): void { return; }
function update(dt: number): void { if (dt < 0) { return; } }
// 한 바퀴의 열 수 — 문구 열 뒤에 gap 개의 빈 열
function loopLen(gap: number): number { return cols.length + gap; }
// 시작 열 번호 i 에서 보이는 칸 c 의 켜짐 — 순환 목록에서 읽는다
function colAt(i: number, c: number, r: number, gap: number): number { return loopBit(cols, gap, i + c - LEAD, r); }
function headCol(): number { return Math.floor(clock() * SCROLL_SPEED); }
// 문구 첫 열이 보이는 칸 번호 — 보이는 범위 밖이면 -1
function seamCol(gap: number): number {
const n = loopLen(gap);
const c = (((LEAD - headCol()) % n) + n) % n;
return c < VIEW_COLS ? c : -1;
}
function seamMark(i: number, gap: number, c: RGB): void {
const sc = seamCol(gap);
if (sc < 0) { return; }
box(VIEW_X + BOARD_PAD + sc * PITCH + 1, boardY(i) + BOARD_PAD * 2 + GLYPH_H * PITCH - 3, PITCH - 2, 2.5, 1.2, c, 1);
}
function draw(): void {
skyFill();
const h = headCol();
const p = clock() * SCROLL_SPEED;
const fl = Math.floor(p);
const f = p - fl;
blockTag(0, '정수 열 이동 + 빈틈 {g}열'.replace('{g}', String(GAP_COLS)), MINT, INK);
blockTag(1, '소수 열 이동 — 점이 번진다', ROSE, INK);
blockTag(2, '빈틈 없음 — 끝과 처음이 붙는다', BUTTER, INK);
blockRight(0, 'start ' + (h % loopLen(GAP_COLS)));
blockRight(1, 'start ' + p.toFixed(2));
blockRight(2, 'start ' + (h % loopLen(0)));
board(VIEW_X, boardY(0), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c: number, r: number): number { return colAt(h, c, r, GAP_COLS); }, null);
board(VIEW_X, boardY(1), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c: number, r: number): number {
return (1 - f) * colAt(fl, c, r, GAP_COLS) + f * colAt(fl + 1, c, r, GAP_COLS);
}, null);
board(VIEW_X, boardY(2), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c: number, r: number): number { return colAt(h, c, r, 0); }, null);
seamMark(0, GAP_COLS, MINT);
seamMark(2, 0, ROSE);
}
// 화면 낭독기로 보내는 문장 — 표시 문구와 판 이름만 담아 장면이 흘러도 달라지지 않는다
function speakText(): string {
return '표시 문구 {s} · 이음새 없는 열 흐름 판'.replace('{s}', MSG);
}
function statusText(): string {
return '한 바퀴 {l}열 · 초당 {v}열 · 시작 열 {c}'.replace('{l}', String(loopLen(GAP_COLS))).replace('{v}', String(SCROLL_SPEED)).replace('{c}', String(headCol() % loopLen(GAP_COLS)));
}
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={'SALE 30% OFF TODAY 문구가 세 판에서 오른쪽에서 왼쪽으로 흐른다. 위 판은 정수 열로만 움직이고 문구 뒤에 13열 빈틈을 두어 이음새가 없으며, 가운데 판은 소수 열로 옮겨 점이 번지고, 아래 판은 빈틈이 없어 끝과 처음이 붙어 이음새 표시가 튄다'}></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>
);
}02 · 列単位で途切れなく流れるスクロール — 計測 累積面積 37.431% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 一周の列数 — 文句の列のあとにgap個の空の列
function loopLen(gap) { return cols.length + gap; }
// 開始の列番号iで見えるマスcの灯り — 循環する並びから読む
function colAt(i, c, r, gap) { return loopBit(cols, gap, i + c - LEAD, r); }
function headCol() { return Math.floor(clock() * SCROLL_SPEED); }
// 文句の最初の列が見えるマス番号 — 見える範囲の外なら-1
function seamCol(gap) {
const n = loopLen(gap);
const c = (((LEAD - headCol()) % n) + n) % n;
return c < VIEW_COLS ? c : -1;
}
03消えた点に残る残光
目で見たとき滑らかにつながって見える、残像の処理です。点ごとに明るさを別に持ち、点くべきならすぐ1に上げ、消えたら前の明るさに減衰を掛けて減らします。上の盤は TICK_S の0.05秒で DECAY の0.55倍になるよう経過時間に合わせて掛け、中の盤は144 Hz画面のフレームごとに0.55を掛けるので、一列過ぎたあとに残る明るさは時間基準で0.608、フレーム基準で0.028になり、明るさ0.1を超える尾が4列対0列に分かれます。

<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で1コマ、Rで最初から">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="SALE 30% OFF TODAY という文句が流れる二つの盤の後ろに、消えた点の残光の尾が残る。上の盤は経過時間に合わせて減衰を掛けるので尾が一定で、中の盤は144 Hz画面のフレームごとに減衰を掛けるので尾が短くなる。下には先頭から後ろへ列ごとに残る明るさを二本の棒で比べる。"></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">1コマ</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;
// 글자와 글자 사이 빈 列 한 줄
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[' ']; }
// 문구를 列 목록으로 편다 — 列 하나는 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;
}
// 순환 목록 — 문구 列 뒤에 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();
}
// 1コマ — 멈춘 채 장면 시계를 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 는 一時停止, N 은 1コマ, 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 });
// 마우스·터치 — 무대를 누르면 一時停止 토글
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 });
// ── 03 꺼진 점에 남는 잔광 — 점마다 밝기를 남기고 경과 시간으로 곱해 줄인다 / 프레임마다 곱하면 화면 주사율에 따라 꼬리가 달라진다 ────
// 켜져야 할 점은 밝기 1 로 올리고, 꺼져야 할 점은 이전 밝기에 감쇠를 곱한다. 감쇠는 TICK_S 초 동안 DECAY 배로 정의해 경과 시간 h 에는 DECAY 의 h / TICK_S 제곱을 곱한다
const SCROLL_SPEED = 24;
const DECAY = 0.55;
const TICK_S = 0.05;
const FRAME_HZ = 144;
const GAP_COLS = 13;
const LEAD = 4;
const LAP_S = 10;
const START_S = 2.4;
const REST_S = 3.1;
const MSG = 'SALE 30% OFF TODAY';
const cols = colsOf(MSG);
const N = VIEW_COLS * GLYPH_H;
const levelT = [];
const levelF = [];
for (let i = 0; i < N; i += 1) {
levelT.push(0);
levelF.push(0);
}
function clock() { return sceneT + lap * LAP_S; }
function build() { return; }
// 켜져야 하면 1, 아니면 이전 밝기에 k 를 곱한 값 — 둘 중 큰 쪽
function fadeStep(level, target, k) {
const v = level * k;
return target > v ? target : v;
}
function targetAt(t, c, r) {
return loopBit(cols, GAP_COLS, Math.floor(t * SCROLL_SPEED) + c - LEAD, r);
}
function update(dt) {
const t1 = clock();
// 경과 時間基準 — 60 분의 1 초 조각마다 h 에 맞는 감쇠를 곱한다
const nT = Math.max(1, Math.ceil(dt * 60));
const hT = dt / nT;
const kT = Math.pow(DECAY, hT / TICK_S);
for (let s = 0; s < nT; s += 1) {
const t = t1 - dt + (s + 1) * hT;
for (let c = 0; c < VIEW_COLS; c += 1) {
for (let r = 0; r < GLYPH_H; r += 1) {
const i = c * GLYPH_H + r;
levelT[i] = fadeStep(levelT[i], targetAt(t, c, r), kT);
}
}
}
// フレーム基準 — 144 Hz 화면의 프레임마다 DECAY 를 한 번씩 곱한다
const nF = Math.max(1, Math.ceil(dt * FRAME_HZ));
const hF = dt / nF;
for (let s = 0; s < nF; s += 1) {
const t = t1 - dt + (s + 1) * hF;
for (let c = 0; c < VIEW_COLS; c += 1) {
for (let r = 0; r < GLYPH_H; r += 1) {
const i = c * GLYPH_H + r;
levelF[i] = fadeStep(levelF[i], targetAt(t, c, r), DECAY);
}
}
}
}
// 머리에서 뒤로 k 列 떨어진 점의 남은 밝기 — 시간 기준과 フレーム基準
function tailT(k) { return Math.pow(DECAY, k / SCROLL_SPEED / TICK_S); }
function tailF(k) { return Math.pow(DECAY, k / SCROLL_SPEED * FRAME_HZ); }
function tailLen(f) {
let n = 0;
while (n < 60 && f(n + 1) > 0.1) {
n += 1;
}
return n;
}
function draw() {
skyFill();
blockTag(0, '経過時間に合わせて掛ける', MINT, INK);
blockTag(1, 'フレームごとに掛ける ({f} Hz画面)'.replace('{f}', String(FRAME_HZ)), ROSE, INK);
blockTag(2, '先頭から後ろへ列ごとに残る明るさ', SKY, INK);
blockRight(0, 'DECAY ' + DECAY);
blockRight(1, 'DECAY ' + DECAY);
board(VIEW_X, boardY(0), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c, r) { return levelT[c * GLYPH_H + r]; }, null);
board(VIEW_X, boardY(1), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c, r) { return levelF[c * GLYPH_H + r]; }, null);
// 꼬리 곡선 — 列 간격 11px 막대 두 줄(時間基準 5px·フレーム基準 4px 폭)
const base = 276;
box(VIEW_X, BLOCK_Y[2] + 16, boardW(VIEW_COLS, PITCH), 62, 8, WHITE, 0.55);
for (let k = 0; k < 40; k += 1) {
const a = tailT(k);
const b = tailF(k);
box(VIEW_X + 12 + k * 11, base - 46 * a, 5, 46 * a, 2, mix(AMBER, INK, 0.2), 1);
box(VIEW_X + 17 + k * 11, base - 46 * b, 4, 46 * b, 2, mix(ROSE, INK, 0.25), 1);
}
text('時間基準', VIEW_X + 318, BLOCK_Y[2] + 29, INK, 1, 10, 700, 'left');
text('フレーム基準', VIEW_X + 384, BLOCK_Y[2] + 29, INK, 1, 10, 700, 'left');
box(VIEW_X + 310, BLOCK_Y[2] + 24, 6, 6, 2, mix(AMBER, INK, 0.2), 1);
box(VIEW_X + 376, BLOCK_Y[2] + 24, 6, 6, 2, mix(ROSE, INK, 0.25), 1);
}
// 화면 낭독기로 보내는 문장 — 표시 문구와 판 이름만 담아 장면이 흘러도 달라지지 않는다
function speakText() {
return '表示文句 {s} - 残光の尾を比べる盤'.replace('{s}', MSG);
}
function statusText() {
return 'DECAY {d} · 時間基準の尾 {a}列 · フレーム基準の尾 {b}列'.replace('{d}', String(DECAY)).replace('{a}', String(tailLen(tailT))).replace('{b}', String(tailLen(tailF)));
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// GlowDecayAfterimageTrail - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./GlowDecayAfterimageTrail.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function GlowDecayAfterimageTrail() {
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 });
// ── 03 꺼진 점에 남는 잔광 — 점마다 밝기를 남기고 경과 시간으로 곱해 줄인다 / 프레임마다 곱하면 화면 주사율에 따라 꼬리가 달라진다 ────
// 켜져야 할 점은 밝기 1 로 올리고, 꺼져야 할 점은 이전 밝기에 감쇠를 곱한다. 감쇠는 TICK_S 초 동안 DECAY 배로 정의해 경과 시간 h 에는 DECAY 의 h / TICK_S 제곱을 곱한다
const SCROLL_SPEED = 24;
const DECAY = 0.55;
const TICK_S = 0.05;
const FRAME_HZ = 144;
const GAP_COLS = 13;
const LEAD = 4;
const LAP_S = 10;
const START_S = 2.4;
const REST_S = 3.1;
const MSG = 'SALE 30% OFF TODAY';
const cols = colsOf(MSG);
const N = VIEW_COLS * GLYPH_H;
const levelT: number[] = [];
const levelF: number[] = [];
for (let i = 0; i < N; i += 1) { levelT.push(0); levelF.push(0); }
function clock(): number { return sceneT + lap * LAP_S; }
function build(): void { return; }
// 켜져야 하면 1, 아니면 이전 밝기에 k 를 곱한 값 — 둘 중 큰 쪽
function fadeStep(level: number, target: number, k: number): number {
const v = level * k;
return target > v ? target : v;
}
function targetAt(t: number, c: number, r: number): number {
return loopBit(cols, GAP_COLS, Math.floor(t * SCROLL_SPEED) + c - LEAD, r);
}
function update(dt: number): void {
const t1 = clock();
// 경과 시간 기준 — 60 분의 1 초 조각마다 h 에 맞는 감쇠를 곱한다
const nT = Math.max(1, Math.ceil(dt * 60));
const hT = dt / nT;
const kT = Math.pow(DECAY, hT / TICK_S);
for (let s = 0; s < nT; s += 1) {
const t = t1 - dt + (s + 1) * hT;
for (let c = 0; c < VIEW_COLS; c += 1) {
for (let r = 0; r < GLYPH_H; r += 1) {
const i = c * GLYPH_H + r;
levelT[i] = fadeStep(levelT[i], targetAt(t, c, r), kT);
}
}
}
// 프레임 기준 — 144 Hz 화면의 프레임마다 DECAY 를 한 번씩 곱한다
const nF = Math.max(1, Math.ceil(dt * FRAME_HZ));
const hF = dt / nF;
for (let s = 0; s < nF; s += 1) {
const t = t1 - dt + (s + 1) * hF;
for (let c = 0; c < VIEW_COLS; c += 1) {
for (let r = 0; r < GLYPH_H; r += 1) {
const i = c * GLYPH_H + r;
levelF[i] = fadeStep(levelF[i], targetAt(t, c, r), DECAY);
}
}
}
}
// 머리에서 뒤로 k 열 떨어진 점의 남은 밝기 — 시간 기준과 프레임 기준
function tailT(k: number): number { return Math.pow(DECAY, k / SCROLL_SPEED / TICK_S); }
function tailF(k: number): number { return Math.pow(DECAY, k / SCROLL_SPEED * FRAME_HZ); }
function tailLen(f: (k: number) => number): number {
let n = 0;
while (n < 60 && f(n + 1) > 0.1) { n += 1; }
return n;
}
function draw(): void {
skyFill();
blockTag(0, '경과 시간에 맞춰 곱하기', MINT, INK);
blockTag(1, '프레임마다 곱하기 ({f} Hz 화면)'.replace('{f}', String(FRAME_HZ)), ROSE, INK);
blockTag(2, '머리에서 뒤로 열마다 남는 밝기', SKY, INK);
blockRight(0, 'DECAY ' + DECAY);
blockRight(1, 'DECAY ' + DECAY);
board(VIEW_X, boardY(0), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c: number, r: number): number { return levelT[c * GLYPH_H + r]; }, null);
board(VIEW_X, boardY(1), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c: number, r: number): number { return levelF[c * GLYPH_H + r]; }, null);
// 꼬리 곡선 — 열 간격 11px 막대 두 줄(시간 기준 5px·프레임 기준 4px 폭)
const base = 276;
box(VIEW_X, BLOCK_Y[2] + 16, boardW(VIEW_COLS, PITCH), 62, 8, WHITE, 0.55);
for (let k = 0; k < 40; k += 1) {
const a = tailT(k);
const b = tailF(k);
box(VIEW_X + 12 + k * 11, base - 46 * a, 5, 46 * a, 2, mix(AMBER, INK, 0.2), 1);
box(VIEW_X + 17 + k * 11, base - 46 * b, 4, 46 * b, 2, mix(ROSE, INK, 0.25), 1);
}
text('시간 기준', VIEW_X + 318, BLOCK_Y[2] + 29, INK, 1, 10, 700, 'left');
text('프레임 기준', VIEW_X + 384, BLOCK_Y[2] + 29, INK, 1, 10, 700, 'left');
box(VIEW_X + 310, BLOCK_Y[2] + 24, 6, 6, 2, mix(AMBER, INK, 0.2), 1);
box(VIEW_X + 376, BLOCK_Y[2] + 24, 6, 6, 2, mix(ROSE, INK, 0.25), 1);
}
// 화면 낭독기로 보내는 문장 — 표시 문구와 판 이름만 담아 장면이 흘러도 달라지지 않는다
function speakText(): string {
return '표시 문구 {s} · 잔광 꼬리 비교 판'.replace('{s}', MSG);
}
function statusText(): string {
return 'DECAY {d} · 시간 기준 꼬리 {a}열 · 프레임 기준 꼬리 {b}열'.replace('{d}', String(DECAY)).replace('{a}', String(tailLen(tailT))).replace('{b}', String(tailLen(tailF)));
}
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={'SALE 30% OFF TODAY 문구가 흐르는 판 두 장 뒤로 꺼진 점의 잔광 꼬리가 남는다. 위 판은 경과 시간에 맞춰 감쇠를 곱해 꼬리가 일정하고, 가운데 판은 144 Hz 화면의 프레임마다 감쇠를 곱해 꼬리가 짧아진다. 아래에는 머리에서 뒤로 열마다 남는 밝기를 두 줄 막대로 비교한다'}></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>
);
}03 · 消えた点に残る残光 — 計測 累積面積 24.035% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 先頭から後ろへk列離れた点に残る明るさ — 時間基準とフレーム基準
function tailT(k) { return Math.pow(DECAY, k / SCROLL_SPEED / TICK_S); }
function tailF(k) { return Math.pow(DECAY, k / SCROLL_SPEED * FRAME_HZ); }
function tailLen(f) {
let n = 0;
while (n < 60 && f(n + 1) > 0.1) {
n += 1;
}
return n;
}
04文が替わるとき行を押し上げる切り替え
複数の文句を順に見せる案内板や待ち番号の表示で使う、入れ替えの方法です。古い文句の7行と空の一行、新しい文句の7行をつないで15行の山を立て、7行の窓を ROLL_STEP の0.07秒ごとに一行ずつ上げて8行ぶん押すので、整数の行だから点がにじまず、終わるとちょうど新しい文句の位置で止まります。中の盤は swapAt で一度にカットで替わり、下の盤は小数の行で押すので点がにじみます。

<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で1コマ、Rで最初から">
<canvas class="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 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">1コマ</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;
// 글자와 글자 사이 빈 列 한 줄
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[' ']; }
// 문구를 列 목록으로 편다 — 列 하나는 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;
}
// 순환 목록 — 문구 列 뒤에 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();
}
// 1コマ — 멈춘 채 장면 시계를 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 는 一時停止, N 은 1コマ, 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 });
// 마우스·터치 — 무대를 누르면 一時停止 토글
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 문장이 바뀔 때 행을 밀어 올리는 전환 — 정수 行 굴림 / 한 번에 컷 / 소수 行 이동 ────
// 옛 문구 일곱 行, 빈 行 하나, 새 문구 일곱 행을 위아래로 이어 세운 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) { 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 };
}
// 굴림이 시작된 뒤 몇 行 올라갔는지 — 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 번 列 — 0 에서 6 은 옛 문구, 7 은 빈 行, 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, '一行ずつ押し上げる - 整数の行', 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, 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 '表示文句 {s} - 行ロール切り替えの盤'.replace('{s}', PHRASES[o >= ROLL_ROWS ? (sl.pi + 1) % PHRASES.length : sl.pi]);
}
function statusText() {
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 () {
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 · 文が替わるとき行を押し上げる切り替え — 計測 累積面積 20.402% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 押し上げが始まってから何行上がったか — 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番の列 — 0から6は古い文句、7は空の行、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);
}
05明るさを数段階に分けて点け消しする
安い部品のような粗い明るさと滑らかな明るさのどちらを選ぶかを決める基準です。文句が左から RAMP_W の22列幅のにじみの帯で明るくなって同じ順に消え、点ごとに求めた連続の明るさを quantize が stepOf で一番近い段階に丸めます。同じフェードを2段階・LEVELS の4段階・16段階で並べると、段階が少ないほどにじみの帯が階段のように途切れて見えます。

<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で1コマ、Rで最初から">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="HELLO LED という文句が左から明るくなり、同じ順序で消える。同じにじみのフェードを上の盤は2段階、中の盤は4段階、下の盤は16段階に分けて描き、段階が少ないほど縞が見え、多いほどなめらかになる。"></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">1コマ</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;
// 글자와 글자 사이 빈 列 한 줄
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[' ']; }
// 문구를 列 목록으로 편다 — 列 하나는 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;
}
// 순환 목록 — 문구 列 뒤에 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();
}
// 1コマ — 멈춘 채 장면 시계를 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 는 一時停止, N 은 1コマ, 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 });
// 마우스·터치 — 무대를 누르면 一時停止 토글
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 });
// ── 05 밝기를 몇 단계로 나눠 켜고 끄기 — 같은 번짐 페이드를 2 단계 / LEVELS 단계 / 16 단계로 양자화 ────
// 문구는 왼쪽에서부터 밝아졌다가 같은 순서로 꺼진다. 점마다 연속 밝기를 구한 뒤 몇 단계 중 가장 가까운 단계로 반올림해 켠다. 단계가 적으면 줄무늬가 보이고 많으면 매끈하다
const LEVELS = 4;
const LEVELS_HARD = 2;
const LEVELS_FINE = 16;
const RAMP_W = 22;
const IN_T0 = 0.2;
const IN_T1 = 2.8;
const OUT_T0 = 3.8;
const OUT_T1 = 6.4;
const LAP_S = 7.0;
const START_S = 1.5;
const REST_S = 3.3;
const MSG = 'HELLO LED';
const cols = colsOf(MSG);
const START_COL = Math.floor((VIEW_COLS - cols.length) / 2);
function build() { return; }
function update(dt) { if (dt < 0) {
return;
} }
// 몇 번째 단계인지 — 0 에서 n - 1
function stepOf(v, n) { return Math.round(clamp(v, 0, 1) * (n - 1)); }
// 가장 가까운 단계의 밝기로 — n 단계면 0, 1/(n-1), ... 1
function quantize(v, n) { return n <= 1 ? 0 : stepOf(v, n) / (n - 1); }
// 列 c 의 연속 밝기 — 들어오는 번짐 띠에서 나가는 번짐 띠를 뺀 값
function fadeAt(c) {
const xin = lerp(0, VIEW_COLS + RAMP_W, clamp((sceneT - IN_T0) / (IN_T1 - IN_T0), 0, 1));
const xout = lerp(0, VIEW_COLS + RAMP_W, clamp((sceneT - OUT_T0) / (OUT_T1 - OUT_T0), 0, 1));
return clamp((xin - c) / RAMP_W, 0, 1) - clamp((xout - c) / RAMP_W, 0, 1);
}
function lv(c, r, n) {
return bitOf(cols, c - START_COL, r) === 1 ? quantize(fadeAt(c), n) : 0;
}
// 단계 사다리 — 이름표 줄 오른쪽에 n 칸, 오른쪽으로 갈수록 밝다
function ladder(i, n) {
const x1 = VIEW_X + boardW(VIEW_COLS, PITCH) - 2;
const wd = n > 8 ? 6 : 12;
const x0 = x1 - n * (wd + 1);
box(x0 - 3, BLOCK_Y[i] - 1, n * (wd + 1) + 5, 16, 5, BOARD, 1);
for (let k = 0; k < n; k += 1) {
box(x0 + k * (wd + 1), BLOCK_Y[i] + 2, wd, 10, 2, mix(DOT_OFF, AMBER, n <= 1 ? 0 : k / (n - 1)), 1);
}
text('LEVELS ' + n, x0 - 8, BLOCK_Y[i] + 7, INK, 0.9, 10, 700, 'right');
}
function draw() {
skyFill();
blockTag(0, '2段階 - 点灯と消灯だけ', BUTTER, INK);
blockTag(1, '{n}段階に分ける'.replace('{n}', String(LEVELS)), MINT, INK);
blockTag(2, '{n}段階 - ほぼなめらか'.replace('{n}', String(LEVELS_FINE)), SKY, INK);
ladder(0, LEVELS_HARD);
ladder(1, LEVELS);
ladder(2, LEVELS_FINE);
board(VIEW_X, boardY(0), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c, r) { return lv(c, r, LEVELS_HARD); }, null);
board(VIEW_X, boardY(1), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c, r) { return lv(c, r, LEVELS); }, null);
board(VIEW_X, boardY(2), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c, r) { return lv(c, r, LEVELS_FINE); }, null);
}
// 화면 낭독기로 보내는 문장 — 표시 문구와 판 이름만 담아 장면이 흘러도 달라지지 않는다
function speakText() {
return '表示文句 {s} - 明るさ段階フェードの盤'.replace('{s}', MSG);
}
function statusText() {
const c = START_COL + 12;
const v = fadeAt(c);
return '表示文句 {s} · 列 {c} 連続値 {v} → {n}段階の{k}番目'.replace('{s}', MSG).replace('{c}', String(c)).replace('{v}', v.toFixed(2)).replace('{n}', String(LEVELS)).replace('{k}', String(stepOf(v, LEVELS) + 1));
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// BrightnessLevelQuantizeFade - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./BrightnessLevelQuantizeFade.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function BrightnessLevelQuantizeFade() {
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 });
// ── 05 밝기를 몇 단계로 나눠 켜고 끄기 — 같은 번짐 페이드를 2 단계 / LEVELS 단계 / 16 단계로 양자화 ────
// 문구는 왼쪽에서부터 밝아졌다가 같은 순서로 꺼진다. 점마다 연속 밝기를 구한 뒤 몇 단계 중 가장 가까운 단계로 반올림해 켠다. 단계가 적으면 줄무늬가 보이고 많으면 매끈하다
const LEVELS = 4;
const LEVELS_HARD = 2;
const LEVELS_FINE = 16;
const RAMP_W = 22;
const IN_T0 = 0.2;
const IN_T1 = 2.8;
const OUT_T0 = 3.8;
const OUT_T1 = 6.4;
const LAP_S = 7.0;
const START_S = 1.5;
const REST_S = 3.3;
const MSG = 'HELLO LED';
const cols = colsOf(MSG);
const START_COL = Math.floor((VIEW_COLS - cols.length) / 2);
function build(): void { return; }
function update(dt: number): void { if (dt < 0) { return; } }
// 몇 번째 단계인지 — 0 에서 n - 1
function stepOf(v: number, n: number): number { return Math.round(clamp(v, 0, 1) * (n - 1)); }
// 가장 가까운 단계의 밝기로 — n 단계면 0, 1/(n-1), ... 1
function quantize(v: number, n: number): number { return n <= 1 ? 0 : stepOf(v, n) / (n - 1); }
// 열 c 의 연속 밝기 — 들어오는 번짐 띠에서 나가는 번짐 띠를 뺀 값
function fadeAt(c: number): number {
const xin = lerp(0, VIEW_COLS + RAMP_W, clamp((sceneT - IN_T0) / (IN_T1 - IN_T0), 0, 1));
const xout = lerp(0, VIEW_COLS + RAMP_W, clamp((sceneT - OUT_T0) / (OUT_T1 - OUT_T0), 0, 1));
return clamp((xin - c) / RAMP_W, 0, 1) - clamp((xout - c) / RAMP_W, 0, 1);
}
function lv(c: number, r: number, n: number): number {
return bitOf(cols, c - START_COL, r) === 1 ? quantize(fadeAt(c), n) : 0;
}
// 단계 사다리 — 이름표 줄 오른쪽에 n 칸, 오른쪽으로 갈수록 밝다
function ladder(i: number, n: number): void {
const x1 = VIEW_X + boardW(VIEW_COLS, PITCH) - 2;
const wd = n > 8 ? 6 : 12;
const x0 = x1 - n * (wd + 1);
box(x0 - 3, BLOCK_Y[i] - 1, n * (wd + 1) + 5, 16, 5, BOARD, 1);
for (let k = 0; k < n; k += 1) { box(x0 + k * (wd + 1), BLOCK_Y[i] + 2, wd, 10, 2, mix(DOT_OFF, AMBER, n <= 1 ? 0 : k / (n - 1)), 1); }
text('LEVELS ' + n, x0 - 8, BLOCK_Y[i] + 7, INK, 0.9, 10, 700, 'right');
}
function draw(): void {
skyFill();
blockTag(0, '2 단계 — 켜짐과 꺼짐뿐', BUTTER, INK);
blockTag(1, '{n} 단계로 나눈다'.replace('{n}', String(LEVELS)), MINT, INK);
blockTag(2, '{n} 단계 — 거의 매끈하다'.replace('{n}', String(LEVELS_FINE)), SKY, INK);
ladder(0, LEVELS_HARD);
ladder(1, LEVELS);
ladder(2, LEVELS_FINE);
board(VIEW_X, boardY(0), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c: number, r: number): number { return lv(c, r, LEVELS_HARD); }, null);
board(VIEW_X, boardY(1), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c: number, r: number): number { return lv(c, r, LEVELS); }, null);
board(VIEW_X, boardY(2), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c: number, r: number): number { return lv(c, r, LEVELS_FINE); }, null);
}
// 화면 낭독기로 보내는 문장 — 표시 문구와 판 이름만 담아 장면이 흘러도 달라지지 않는다
function speakText(): string {
return '표시 문구 {s} · 밝기 단계 페이드 판'.replace('{s}', MSG);
}
function statusText(): string {
const c = START_COL + 12;
const v = fadeAt(c);
return '표시 문구 {s} · 열 {c} 연속 {v} → {n} 단계 중 {k} 번째'.replace('{s}', MSG).replace('{c}', String(c)).replace('{v}', v.toFixed(2)).replace('{n}', String(LEVELS)).replace('{k}', String(stepOf(v, LEVELS) + 1));
}
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={'HELLO LED 문구가 왼쪽부터 밝아졌다가 같은 순서로 꺼진다. 같은 번짐 페이드를 위 판은 2단계, 가운데 판은 4단계, 아래 판은 16단계로 나눠 그려 단계가 적을수록 줄무늬가 보이고 많을수록 매끈해진다'}></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>
);
}05 · 明るさを数段階に分けて点け消しする — 計測 累積面積 9.708% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 何番目の段階か — 0からn - 1
function stepOf(v, n) { return Math.round(clamp(v, 0, 1) * (n - 1)); }
// 一番近い段階の明るさに — n段階なら0、1/(n-1)、2/(n-1)、そして最後が1
function quantize(v, n) { return n <= 1 ? 0 : stepOf(v, n) / (n - 1); }
// 列cの連続の明るさ — 入ってくるにじみの帯から出ていくにじみの帯を引いた値
function fadeAt(c) {
const xin = lerp(0, VIEW_COLS + RAMP_W, clamp((sceneT - IN_T0) / (IN_T1 - IN_T0), 0, 1));
const xout = lerp(0, VIEW_COLS + RAMP_W, clamp((sceneT - OUT_T0) / (OUT_T1 - OUT_T0), 0, 1));
return clamp((xin - c) / RAMP_W, 0, 1) - clamp((xout - c) / RAMP_W, 0, 1);
}
function lv(c, r, n) {
return bitOf(cols, c - START_COL, r) === 1 ? quantize(fadeAt(c), n) : 0;
}
06点の間隔と半径の比
画面の大きさが違っても点の感じを保つ、格子の設定です。点の中心の間隔 PITCH の7pxに対する半径の比 DOT_FILL は大きさのない値なので、0.4なら半径2.8pxで点の間の隙間が1.4px、細い点の0.2なら半径1.4pxで隙間4.2pxになります。下の盤は比が0.18から0.5まで上下して、0.5では隙間が0になって隣の点が触れ合い、softEdge は比が大きいほど縁の輪の倍率を下げます。

<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で1コマ、Rで最初から">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="LED 5X7 DOT という文句が三つの盤に流れる。上の盤は縁がそのままの細い点、中の盤は半径の外側にうすい輪を一重足して縁をやわらげ、下の盤は点の間隔に対する半径の比が0.18から0.5まで上下して点が散らばってはくっつく。"></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">1コマ</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;
// 글자와 글자 사이 빈 列 한 줄
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[' ']; }
// 문구를 列 목록으로 편다 — 列 하나는 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;
}
// 순환 목록 — 문구 列 뒤에 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();
}
// 1コマ — 멈춘 채 장면 시계를 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 는 一時停止, N 은 1コマ, 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 });
// 마우스·터치 — 무대를 누르면 一時停止 토글
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 });
// ── 06 점 간격과 반지름의 비율 — 가는 점 / 부드러운 가장자리 점 / 比が上下する点 ────
// 점 간격 PITCH 에 대한 반지름의 비율이 DOT_FILL 이다. 비율이 작으면 점이 흩어져 보이고, 0.5 에 다가가면 이웃 점과 붙어 한 덩어리가 된다. softEdge 는 바깥에 옅은 둘레를 한 겹 더해 가장자리를 풀어 준다
const DOT_FILL = 0.4;
const DOT_THIN = 0.2;
const FILL_LO = 0.18;
const FILL_HI = 0.5;
const FILL_PERIOD = 6;
const SCROLL_SPEED = 13;
const GAP_COLS = 13;
const LEAD = 4;
const LAP_S = 6;
const START_S = 1.4;
const REST_S = 2.0;
const MSG = 'LED 5X7 DOT';
const cols = colsOf(MSG);
function clock() { return sceneT + lap * LAP_S; }
function build() { return; }
function update(dt) { if (dt < 0) {
return;
} }
// 가장자리 풀기 — 반지름 비율이 클수록 이웃과 겹치니 둘레 배율을 낮춘다. 0 이면 딱 떨어지는 점
function softEdge(fill) { return clamp(0.5 / fill * 0.92, 1.05, 1.9); }
// 오르내리는 판의 지금 비율 — 한 주기에 FILL_LO 에서 FILL_HI 로 갔다 온다
function fillNow() { return lerp(FILL_LO, FILL_HI, 0.5 - 0.5 * Math.cos(TAU * clock() / FILL_PERIOD)); }
function lvAt(c, r) {
return loopBit(cols, GAP_COLS, Math.floor(clock() * SCROLL_SPEED) + c - LEAD, r);
}
function gapPx(fill) { return PITCH - 2 * PITCH * fill; }
function draw() {
skyFill();
const fn = fillNow();
blockTag(0, '細い点 - 縁はそのまま', BUTTER, INK);
blockTag(1, 'やわらかい縁を一重', MINT, INK);
blockTag(2, '比が上下する点', ROSE, INK);
blockRight(0, 'DOT_FILL ' + DOT_THIN.toFixed(2));
blockRight(1, 'DOT_FILL ' + DOT_FILL.toFixed(2));
blockRight(2, 'DOT_FILL ' + fn.toFixed(2));
board(VIEW_X, boardY(0), VIEW_COLS, GLYPH_H, PITCH, DOT_THIN, true, 0, lvAt, null);
board(VIEW_X, boardY(1), VIEW_COLS, GLYPH_H, PITCH, DOT_FILL, true, softEdge(DOT_FILL), lvAt, null);
board(VIEW_X, boardY(2), VIEW_COLS, GLYPH_H, PITCH, fn, true, softEdge(fn), lvAt, null);
}
// 화면 낭독기로 보내는 문장 — 표시 문구와 판 이름만 담아 장면이 흘러도 달라지지 않는다
function speakText() {
return '表示文句 {s} - 点間隔と半径の盤'.replace('{s}', MSG);
}
function statusText() {
const fn = fillNow();
return '間隔 {p}px · 充填 {f} · 点の間の隙間 {g}px'.replace('{p}', String(PITCH)).replace('{f}', fn.toFixed(2)).replace('{g}', gapPx(fn).toFixed(1));
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// DotPitchRadiusSoftEdge - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./DotPitchRadiusSoftEdge.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function DotPitchRadiusSoftEdge() {
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 });
// ── 06 점 간격과 반지름의 비율 — 가는 점 / 부드러운 가장자리 점 / 비율이 오르내리는 점 ────
// 점 간격 PITCH 에 대한 반지름의 비율이 DOT_FILL 이다. 비율이 작으면 점이 흩어져 보이고, 0.5 에 다가가면 이웃 점과 붙어 한 덩어리가 된다. softEdge 는 바깥에 옅은 둘레를 한 겹 더해 가장자리를 풀어 준다
const DOT_FILL = 0.4;
const DOT_THIN = 0.2;
const FILL_LO = 0.18;
const FILL_HI = 0.5;
const FILL_PERIOD = 6;
const SCROLL_SPEED = 13;
const GAP_COLS = 13;
const LEAD = 4;
const LAP_S = 6;
const START_S = 1.4;
const REST_S = 2.0;
const MSG = 'LED 5X7 DOT';
const cols = colsOf(MSG);
function clock(): number { return sceneT + lap * LAP_S; }
function build(): void { return; }
function update(dt: number): void { if (dt < 0) { return; } }
// 가장자리 풀기 — 반지름 비율이 클수록 이웃과 겹치니 둘레 배율을 낮춘다. 0 이면 딱 떨어지는 점
function softEdge(fill: number): number { return clamp(0.5 / fill * 0.92, 1.05, 1.9); }
// 오르내리는 판의 지금 비율 — 한 주기에 FILL_LO 에서 FILL_HI 로 갔다 온다
function fillNow(): number { return lerp(FILL_LO, FILL_HI, 0.5 - 0.5 * Math.cos(TAU * clock() / FILL_PERIOD)); }
function lvAt(c: number, r: number): number {
return loopBit(cols, GAP_COLS, Math.floor(clock() * SCROLL_SPEED) + c - LEAD, r);
}
function gapPx(fill: number): number { return PITCH - 2 * PITCH * fill; }
function draw(): void {
skyFill();
const fn = fillNow();
blockTag(0, '가는 점 — 가장자리 그대로', BUTTER, INK);
blockTag(1, '부드러운 가장자리 한 겹', MINT, INK);
blockTag(2, '비율이 오르내리는 점', ROSE, INK);
blockRight(0, 'DOT_FILL ' + DOT_THIN.toFixed(2));
blockRight(1, 'DOT_FILL ' + DOT_FILL.toFixed(2));
blockRight(2, 'DOT_FILL ' + fn.toFixed(2));
board(VIEW_X, boardY(0), VIEW_COLS, GLYPH_H, PITCH, DOT_THIN, true, 0, lvAt, null);
board(VIEW_X, boardY(1), VIEW_COLS, GLYPH_H, PITCH, DOT_FILL, true, softEdge(DOT_FILL), lvAt, null);
board(VIEW_X, boardY(2), VIEW_COLS, GLYPH_H, PITCH, fn, true, softEdge(fn), lvAt, null);
}
// 화면 낭독기로 보내는 문장 — 표시 문구와 판 이름만 담아 장면이 흘러도 달라지지 않는다
function speakText(): string {
return '표시 문구 {s} · 점 간격 반지름 판'.replace('{s}', MSG);
}
function statusText(): string {
const fn = fillNow();
return '간격 {p}px · 채움 {f} · 점 사이 빈틈 {g}px'.replace('{p}', String(PITCH)).replace('{f}', fn.toFixed(2)).replace('{g}', gapPx(fn).toFixed(1));
}
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={'LED 5X7 DOT 문구가 세 판에 흐른다. 위 판은 가는 점에 가장자리가 그대로이고, 가운데 판은 반지름 바깥에 옅은 둘레를 한 겹 더해 가장자리를 풀며, 아래 판은 점 간격에 대한 반지름의 비율이 0.18 에서 0.5 까지 오르내려 점이 흩어졌다가 붙는다'}></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>
);
}06 · 点の間隔と半径の比 — 計測 累積面積 28.342% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 縁をほぐす — 半径の比が大きいほど隣と重なるので輪の倍率を下げる。0ならきっちりした点
function softEdge(fill) { return clamp(0.5 / fill * 0.92, 1.05, 1.9); }
// 上下する盤の今の比 — 一周期でFILL_LOからFILL_HIへ行って戻る
function fillNow() { return lerp(FILL_LO, FILL_HI, 0.5 - 0.5 * Math.cos(TAU * clock() / FILL_PERIOD)); }
function lvAt(c, r) {
return loopBit(cols, GAP_COLS, Math.floor(clock() * SCROLL_SPEED) + c - LEAD, r);
}
function gapPx(fill) { return PITCH - 2 * PITCH * fill; }
07幅を測って止めるか流すかを決める
文句の長さが毎回違う掲示板で、読みやすい方を自動で選ぶ決まりです。colCount が数えた列数が VIEW_COLS の64より長ければ流れ、そうでなければ盤の中央に止まり、短い OPEN 24H は47列なので両脇に8列と9列が残ります。盤は6.5秒ごとに短い文句と209列の長い文句 WELCOME TO THE DOT MATRIX SIGN 5X7 を入れ替えてかけ、長い文句のほうが流れます。

<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で1コマ、Rで最初から">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="短い文句 OPEN 24H は盤の中央に止まり、長い文句 WELCOME TO THE DOT MATRIX SIGN 5X7 は流れる。文句を交互に掛け、下には列数colCountと盤の幅VIEW_COLSを比べる棒が置かれ、はみ出す列数がバラ色で示される。"></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">1コマ</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;
// 글자와 글자 사이 빈 列 한 줄
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[' ']; }
// 문구를 列 목록으로 편다 — 列 하나는 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;
}
// 순환 목록 — 문구 列 뒤에 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();
}
// 1コマ — 멈춘 채 장면 시계를 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 는 一時停止, N 은 1コマ, 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 });
// 마우스·터치 — 무대를 누르면 一時停止 토글
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 });
// ── 07 폭을 재서 멈출지 흘릴지 정하기 — 두 문구를 한 판에 번갈아 걸고 colCount 와 VIEW_COLS 를 비교한다 ────
// 문구 폭이 판 칸 수보다 짧으면 판 가운데에 놓고 멈추고(hold), 길면 SCROLL_SPEED 列 매 초로 流れる. 멈춘 문구는 양옆에 몇 칸이 비는지, 흐르는 문구는 몇 칸이 넘치는지 아래 막대에 찍힌다
const SCROLL_SPEED = 24;
const GAP_COLS = 13;
const LEAD = 4;
const SHORT = 'OPEN 24H';
const LONG = 'WELCOME TO THE DOT MATRIX SIGN 5X7 ';
const LOOP_S = 6.5;
const LAP_S = LOOP_S * 2;
const START_S = 1.6;
const REST_S = 0.6;
const SHORT_COLS = colsOf(SHORT);
const LONG_COLS = colsOf(LONG);
const SHORT_LEN = SHORT_COLS.length + GAP_COLS;
const LONG_LEN = LONG_COLS.length + GAP_COLS;
function build() { return; }
function update(dt) { if (dt < 0) {
return;
} }
// 흐르기 시작한 뒤 경과 시간
function flowAt() { return sceneT % LOOP_S; }
// 지금 판에 걸린 문구 — 짧은 문구와 긴 문구가 한 판에 번갈아 들어온다
// 판은 긴 문구가 흐르는 장면으로 시작한다 — 0 이 짧은 문구, 1 이 긴 문구
function curIdx() { return (Math.floor(sceneT / LOOP_S) + 1) % 2; }
// 시작 列 번호 — 긴 문구만 시간에 따라 오른다(LOOP_S 마다 문구가 바뀌며 처음부터 다시 流れる). 짧은 문구는 0 으로 멈춘다
function headCol() {
if (curIdx() === 1) {
return Math.floor(flowAt() * SCROLL_SPEED);
}
return 0;
}
// 列 수를 세는 함수 하나가 두 갈래를 모두 정한다 — 판 칸 수보다 길면 흐르고 아니면 멈춘다
function colCount(cols) { return cols.length; }
function holdOrScroll(cols) { return colCount(cols) > VIEW_COLS ? 'scroll' : 'hold'; }
// 짧은 문구는 순환 없이 판 가운데에 한 번만 놓고, 긴 문구는 순환 목록에서 읽는다
function lvAt(c, r) {
if (curIdx() === 0) {
return bitOf(SHORT_COLS, c - Math.floor((VIEW_COLS - SHORT_COLS.length) / 2), r);
}
return loopBit(LONG_COLS, GAP_COLS, headCol() + c - LEAD, r);
}
function draw() {
skyFill();
const idx = curIdx();
const cur = idx === 0 ? SHORT_COLS : LONG_COLS;
const flows = holdOrScroll(cur) === 'scroll';
blockTag(0, '短い文句 - 幅{c}列、盤{v}列'.replace('{c}', String(SHORT_COLS.length)).replace('{v}', String(VIEW_COLS)), MINT, INK);
blockTag(1, '長い文句 - 幅{c}列'.replace('{c}', String(LONG_COLS.length)), ROSE, INK);
blockTag(2, 'colCountと盤の幅の比較', SKY, INK);
blockRight(0, 'holdOrScroll');
blockRight(1, 'flow ' + (flows ? 'o' : 'x'));
blockRight(2, '');
board(VIEW_X, boardY(0), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c, r) { return idx === 0 ? lvAt(c, r) : 0; }, null);
board(VIEW_X, boardY(1), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c, r) { return idx === 1 ? lvAt(c, r) : 0; }, null);
// 비교대 — 위 검은 막대가 판 폭(칸 하나 1px), 아래 막대가 문구 폭(칸 하나 1px)이고 같은 눈금 안에서 비교한다
const by = BLOCK_Y[2] + 16;
const bw = boardW(VIEW_COLS, PITCH);
box(VIEW_X, by, bw, 46, 8, WHITE, 0.55);
const y1 = by + 10;
box(VIEW_X + 12, y1, VIEW_COLS, 10, 3, BOARD, 1);
for (let c = 0; c <= VIEW_COLS; c += 8) {
box(VIEW_X + 12 + c, y1 + 10, 1, 3, 0, DOT_OFF, 0.8);
}
const tw = colCount(cur);
// 문구 폭 막대는 칸 하나 1px — 판 폭을 넘으면 막대는 판 폭에서 잘리고 그 끝에 장미색 표시가 붙는다. 넘친 칸 수가 흘러야 하는 양
const barW = Math.min(tw, VIEW_COLS);
box(VIEW_X + 12, y1 + 18, barW, 10, 3, mix(AMBER, INK, 0.25), 1);
text('colCount ' + tw, VIEW_X + 16, y1 + 34, INK, 1, 10, 700, 'left');
if (tw > VIEW_COLS) {
box(VIEW_X + 12 + VIEW_COLS - 14, y1 + 16, 14, 14, 3, ROSE, 1);
text('はみ出し ' + (tw - VIEW_COLS) + '列 流れる', VIEW_X + bw - 16, y1 + 34, INK, 1, 10, 700, 'right');
}
else {
text('余白 ' + (VIEW_COLS - tw) + '列 停止', VIEW_X + bw - 16, y1 + 34, INK, 1, 10, 700, 'right');
}
}
// 화면 낭독기로 보내는 문장 — 표시 문구와 흐름·정지 방식만 담아 문구가 바뀔 때만 달라진다
function speakText() {
const idx = curIdx();
const cur = idx === 0 ? SHORT_COLS : LONG_COLS;
return '表示文句 {s} - {m}'.replace('{s}', idx === 0 ? SHORT : LONG).replace('{m}', holdOrScroll(cur) === 'scroll' ? '流れる' : '止まる');
}
function statusText() {
const idx = curIdx();
const cur = idx === 0 ? SHORT_COLS : LONG_COLS;
return '表示文句 {s} · colCount {c}列 · 盤{v}列 · {m}'.replace('{s}', idx === 0 ? SHORT : LONG).replace('{c}', String(colCount(cur))).replace('{v}', String(VIEW_COLS)).replace('{m}', holdOrScroll(cur) === 'scroll' ? '流れる' : '止まる');
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// WidthMeasureScrollOrHold - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./WidthMeasureScrollOrHold.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function WidthMeasureScrollOrHold() {
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 });
// ── 07 폭을 재서 멈출지 흘릴지 정하기 — 두 문구를 한 판에 번갈아 걸고 colCount 와 VIEW_COLS 를 비교한다 ────
// 문구 폭이 판 칸 수보다 짧으면 판 가운데에 놓고 멈추고(hold), 길면 SCROLL_SPEED 열 매 초로 흐른다. 멈춘 문구는 양옆에 몇 칸이 비는지, 흐르는 문구는 몇 칸이 넘치는지 아래 막대에 찍힌다
const SCROLL_SPEED = 24;
const GAP_COLS = 13;
const LEAD = 4;
const SHORT = 'OPEN 24H';
const LONG = 'WELCOME TO THE DOT MATRIX SIGN 5X7 ';
const LOOP_S = 6.5;
const LAP_S = LOOP_S * 2;
const START_S = 1.6;
const REST_S = 0.6;
const SHORT_COLS = colsOf(SHORT);
const LONG_COLS = colsOf(LONG);
const SHORT_LEN = SHORT_COLS.length + GAP_COLS;
const LONG_LEN = LONG_COLS.length + GAP_COLS;
function build(): void { return; }
function update(dt: number): void { if (dt < 0) { return; } }
// 흐르기 시작한 뒤 경과 시간
function flowAt(): number { return sceneT % LOOP_S; }
// 지금 판에 걸린 문구 — 짧은 문구와 긴 문구가 한 판에 번갈아 들어온다
// 판은 긴 문구가 흐르는 장면으로 시작한다 — 0 이 짧은 문구, 1 이 긴 문구
function curIdx(): number { return (Math.floor(sceneT / LOOP_S) + 1) % 2; }
// 시작 열 번호 — 긴 문구만 시간에 따라 오른다(LOOP_S 마다 문구가 바뀌며 처음부터 다시 흐른다). 짧은 문구는 0 으로 멈춘다
function headCol(): number {
if (curIdx() === 1) { return Math.floor(flowAt() * SCROLL_SPEED); }
return 0;
}
// 열 수를 세는 함수 하나가 두 갈래를 모두 정한다 — 판 칸 수보다 길면 흐르고 아니면 멈춘다
function colCount(cols: number[]): number { return cols.length; }
function holdOrScroll(cols: number[]): string { return colCount(cols) > VIEW_COLS ? 'scroll' : 'hold'; }
// 짧은 문구는 순환 없이 판 가운데에 한 번만 놓고, 긴 문구는 순환 목록에서 읽는다
function lvAt(c: number, r: number): number {
if (curIdx() === 0) { return bitOf(SHORT_COLS, c - Math.floor((VIEW_COLS - SHORT_COLS.length) / 2), r); }
return loopBit(LONG_COLS, GAP_COLS, headCol() + c - LEAD, r);
}
function draw(): void {
skyFill();
const idx = curIdx();
const cur = idx === 0 ? SHORT_COLS : LONG_COLS;
const flows = holdOrScroll(cur) === 'scroll';
blockTag(0, '짧은 문구 — 폭 {c}칸, 판 {v}칸'.replace('{c}', String(SHORT_COLS.length)).replace('{v}', String(VIEW_COLS)), MINT, INK);
blockTag(1, '긴 문구 — 폭 {c}칸'.replace('{c}', String(LONG_COLS.length)), ROSE, INK);
blockTag(2, 'colCount 와 판 폭의 비교', SKY, INK);
blockRight(0, 'holdOrScroll');
blockRight(1, 'flow ' + (flows ? 'o' : 'x'));
blockRight(2, '');
board(VIEW_X, boardY(0), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c: number, r: number): number { return idx === 0 ? lvAt(c, r) : 0; }, null);
board(VIEW_X, boardY(1), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c: number, r: number): number { return idx === 1 ? lvAt(c, r) : 0; }, null);
// 비교대 — 위 검은 막대가 판 폭(칸 하나 1px), 아래 막대가 문구 폭(칸 하나 1px)이고 같은 눈금 안에서 비교한다
const by = BLOCK_Y[2] + 16;
const bw = boardW(VIEW_COLS, PITCH);
box(VIEW_X, by, bw, 46, 8, WHITE, 0.55);
const y1 = by + 10;
box(VIEW_X + 12, y1, VIEW_COLS, 10, 3, BOARD, 1);
for (let c = 0; c <= VIEW_COLS; c += 8) {
box(VIEW_X + 12 + c, y1 + 10, 1, 3, 0, DOT_OFF, 0.8);
}
const tw = colCount(cur);
// 문구 폭 막대는 칸 하나 1px — 판 폭을 넘으면 막대는 판 폭에서 잘리고 그 끝에 장미색 표시가 붙는다. 넘친 칸 수가 흘러야 하는 양
const barW = Math.min(tw, VIEW_COLS);
box(VIEW_X + 12, y1 + 18, barW, 10, 3, mix(AMBER, INK, 0.25), 1);
text('colCount ' + tw, VIEW_X + 16, y1 + 34, INK, 1, 10, 700, 'left');
if (tw > VIEW_COLS) {
box(VIEW_X + 12 + VIEW_COLS - 14, y1 + 16, 14, 14, 3, ROSE, 1);
text('넘침 ' + (tw - VIEW_COLS) + '칸 흐름', VIEW_X + bw - 16, y1 + 34, INK, 1, 10, 700, 'right');
} else {
text('여백 ' + (VIEW_COLS - tw) + '칸 정지', VIEW_X + bw - 16, y1 + 34, INK, 1, 10, 700, 'right');
}
}
// 화면 낭독기로 보내는 문장 — 표시 문구와 흐름·정지 방식만 담아 문구가 바뀔 때만 달라진다
function speakText(): string {
const idx = curIdx();
const cur = idx === 0 ? SHORT_COLS : LONG_COLS;
return '표시 문구 {s} · {m}'.replace('{s}', idx === 0 ? SHORT : LONG).replace('{m}', holdOrScroll(cur) === 'scroll' ? '흐른다' : '정지한다');
}
function statusText(): string {
const idx = curIdx();
const cur = idx === 0 ? SHORT_COLS : LONG_COLS;
return '표시 문구 {s} · colCount {c}칸 · 판 {v}칸 · {m}'.replace('{s}', idx === 0 ? SHORT : LONG).replace('{c}', String(colCount(cur))).replace('{v}', String(VIEW_COLS)).replace('{m}', holdOrScroll(cur) === 'scroll' ? '흐른다' : '정지한다');
}
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 는 판 가운데에 멈추고 긴 문구 WELCOME TO THE DOT MATRIX SIGN 5X7 은 흐른다. 문구를 번갈아 걸며 열 수 colCount 를 판 폭 VIEW_COLS 와 비교하는 막대가 아래에 놓이고 넘치는 칸 수가 장미색으로 표시된다'}></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>
);
}07 · 幅を測って止めるか流すかを決める — 計測 累積面積 12.04% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 列数を数える関数一つが二つの分岐を決める — 盤のマス数より長ければ流れ、そうでなければ止まる
function colCount(cols) { return cols.length; }
function holdOrScroll(cols) { return colCount(cols) > VIEW_COLS ? 'scroll' : 'hold'; }
// 短い文句は循環させず盤の中央に一度だけ置き、長い文句は循環する並びから読む
function lvAt(c, r) {
if (curIdx() === 0) {
return bitOf(SHORT_COLS, c - Math.floor((VIEW_COLS - SHORT_COLS.length) / 2), r);
}
return loopBit(LONG_COLS, GAP_COLS, headCol() + c - LEAD, r);
}
08列の位置で色が変わる点
速さや状態を色で知らせる案内板や待ち表示板で使う方式です。色は文字ではなく盤の列番号に付くので、文句が流れて通り過ぎると同じ文字が赤から琥珀、黄緑へと変わって読め、colorAt が三つの色の段階 RAMP_STOPS の間を lerpRgb で線形補間するので途切れません。中の盤の zoneAt は補間せずに三つの区域で切り、下の帯は sampleCol で選んだ一列の rgb を5秒周期でなぞって読みます。

<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で1コマ、Rで最初から">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="ARRIVING NOW 3 MIN という文句が流れ、盤の列の位置によって左は赤、中央は琥珀、右は黄緑に読める。上の盤は段階の間を線形補間でつなぎ、中の盤は三つの区画に切り、下の色の帯では印の付いた列のrgb値が文字で読める。"></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">1コマ</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;
// 글자와 글자 사이 빈 列 한 줄
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[' ']; }
// 문구를 列 목록으로 편다 — 列 하나는 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;
}
// 순환 목록 — 문구 列 뒤에 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();
}
// 1コマ — 멈춘 채 장면 시계를 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 는 一時停止, N 은 1コマ, 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 });
// 마우스·터치 — 무대를 누르면 一時停止 토글
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 });
// ── 08 列 위치에 따라 색이 변하는 점 — 색 단계 사이를 선형 보간 / 단계 사이를 잇지 않는 세 구역 / 한 열을 골라 rgb 값 읽기 ────
// 문구는 오른쪽으로 갈수록 붉은색 → 호박색 → 연두색으로 읽힌다. 색은 글자가 아니라 판의 列 번호에 붙어 있어서 글자가 흘러가면 같은 글자의 색이 바뀐다
const SCROLL_SPEED = 12;
const GAP_COLS = 13;
const LEAD = 4;
const LAP_S = 10;
const START_S = 3.2;
const REST_S = 2.4;
const SWEEP_S = 5;
const MSG = 'ARRIVING NOW 3 MIN';
const LED_RED = [255, 84, 84];
const LED_AMBER = [255, 184, 72];
const LED_LIME = [156, 236, 84];
const RAMP_STOPS = [{ at: 0, c: LED_RED }, { at: 0.5, c: LED_AMBER }, { at: 1, c: LED_LIME }];
const cols = colsOf(MSG);
function clock() { return sceneT + lap * LAP_S; }
function build() { return; }
function update(dt) { if (dt < 0) {
return;
} }
// 두 색 사이를 k 비율로 섞는다 — 색 세 채널을 따로 선형으로
function lerpRgb(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 판 안 위치 u(0 에서 1) 의 색 — 두 단계 사이에서는 보간한다
function colorAt(u) {
const x = clamp(u, 0, 1);
for (let i = 1; i < RAMP_STOPS.length; i += 1) {
if (x <= RAMP_STOPS[i].at) {
const a = RAMP_STOPS[i - 1];
const b = RAMP_STOPS[i];
return lerpRgb(a.c, b.c, (x - a.at) / (b.at - a.at));
}
}
return RAMP_STOPS[RAMP_STOPS.length - 1].c;
}
// 보간 없이 세 구역으로 끊는다 — 단계 색 그대로
function zoneAt(u) {
const i = Math.min(RAMP_STOPS.length - 1, Math.floor(clamp(u, 0, 0.999) * RAMP_STOPS.length));
return RAMP_STOPS[i].c;
}
function uOf(c) { return c / (VIEW_COLS - 1); }
function lvAt(c, r) { return loopBit(cols, GAP_COLS, Math.floor(clock() * SCROLL_SPEED) + c - LEAD, r); }
function sampleCol() { return Math.min(VIEW_COLS - 1, Math.floor(((clock() % SWEEP_S) / SWEEP_S) * VIEW_COLS)); }
function draw() {
skyFill();
blockTag(0, '段階の間をつなぐ - 線形補間', MINT, INK);
blockTag(1, 'つながずに三つの区画に切る', BUTTER, INK);
blockTag(2, '一つの列を選んで色を読む', ROSE, INK);
blockRight(0, 'RAMP_STOPS ' + RAMP_STOPS.length);
blockRight(1, 'zoneAt');
board(VIEW_X, boardY(0), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, lvAt, function (c) { return colorAt(uOf(c)); });
board(VIEW_X, boardY(1), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, lvAt, function (c) { return zoneAt(uOf(c)); });
// 색 띠 — 열마다 colorAt 값을 칠하고 고른 列 아래에 표시를 단다
const sx = VIEW_X + 4;
const sy = boardY(2) + 6;
const cw = (boardW(VIEW_COLS, PITCH) - 8) / VIEW_COLS;
for (let c = 0; c < VIEW_COLS; c += 1) {
box(sx + c * cw, sy, cw + 0.4, 16, 0, colorAt(uOf(c)), 1);
}
for (const s of RAMP_STOPS) {
box(sx + s.at * (VIEW_COLS - 1) * cw + cw / 2 - 1.5, sy - 3, 3, 22, 1.5, INK, 0.9);
}
const k = sampleCol();
const rgb = colorAt(uOf(k));
box(sx + k * cw - 1.5, sy - 5, cw + 3, 26, 3, WHITE, 0.95);
box(sx + k * cw, sy - 3, cw, 22, 2, rgb, 1);
text('列 ' + k + ' → rgb(' + Math.round(rgb[0]) + ', ' + Math.round(rgb[1]) + ', ' + Math.round(rgb[2]) + ')', VIEW_X + 4, sy + 34, INK, 1, 12, 700, 'left');
text('段階3つ · 間はlerpRgb一行', VIEW_X + boardW(VIEW_COLS, PITCH) - 4, sy + 34, INK, 0.9, 10, 700, 'right');
}
// 화면 낭독기로 보내는 문장 — 표시 문구와 판 이름만 담아 장면이 흘러도 달라지지 않는다
function speakText() {
return '表示文句 {s} - 列位置の色の帯の盤'.replace('{s}', MSG);
}
function statusText() {
const k = sampleCol();
const rgb = colorAt(uOf(k));
return '列 {c} / {n} · rgb {r} {g} {b} · 色の段階 {s}個'.replace('{c}', String(k)).replace('{n}', String(VIEW_COLS)).replace('{r}', String(Math.round(rgb[0]))).replace('{g}', String(Math.round(rgb[1]))).replace('{b}', String(Math.round(rgb[2]))).replace('{s}', String(RAMP_STOPS.length));
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// ColumnPositionColorRamp - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./ColumnPositionColorRamp.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function ColumnPositionColorRamp() {
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 });
// ── 08 열 위치에 따라 색이 변하는 점 — 색 단계 사이를 선형 보간 / 단계 사이를 잇지 않는 세 구역 / 한 열을 골라 rgb 값 읽기 ────
// 문구는 오른쪽으로 갈수록 붉은색 → 호박색 → 연두색으로 읽힌다. 색은 글자가 아니라 판의 열 번호에 붙어 있어서 글자가 흘러가면 같은 글자의 색이 바뀐다
const SCROLL_SPEED = 12;
const GAP_COLS = 13;
const LEAD = 4;
const LAP_S = 10;
const START_S = 3.2;
const REST_S = 2.4;
const SWEEP_S = 5;
const MSG = 'ARRIVING NOW 3 MIN';
const LED_RED: RGB = [255, 84, 84];
const LED_AMBER: RGB = [255, 184, 72];
const LED_LIME: RGB = [156, 236, 84];
const RAMP_STOPS: Array<{ at: number; c: RGB }> = [{ at: 0, c: LED_RED }, { at: 0.5, c: LED_AMBER }, { at: 1, c: LED_LIME }];
const cols = colsOf(MSG);
function clock(): number { return sceneT + lap * LAP_S; }
function build(): void { return; }
function update(dt: number): void { if (dt < 0) { return; } }
// 두 색 사이를 k 비율로 섞는다 — 색 세 채널을 따로 선형으로
function lerpRgb(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)]; }
// 판 안 위치 u(0 에서 1) 의 색 — 두 단계 사이에서는 보간한다
function colorAt(u: number): RGB {
const x = clamp(u, 0, 1);
for (let i = 1; i < RAMP_STOPS.length; i += 1) {
if (x <= RAMP_STOPS[i].at) {
const a = RAMP_STOPS[i - 1];
const b = RAMP_STOPS[i];
return lerpRgb(a.c, b.c, (x - a.at) / (b.at - a.at));
}
}
return RAMP_STOPS[RAMP_STOPS.length - 1].c;
}
// 보간 없이 세 구역으로 끊는다 — 단계 색 그대로
function zoneAt(u: number): RGB {
const i = Math.min(RAMP_STOPS.length - 1, Math.floor(clamp(u, 0, 0.999) * RAMP_STOPS.length));
return RAMP_STOPS[i].c;
}
function uOf(c: number): number { return c / (VIEW_COLS - 1); }
function lvAt(c: number, r: number): number { return loopBit(cols, GAP_COLS, Math.floor(clock() * SCROLL_SPEED) + c - LEAD, r); }
function sampleCol(): number { return Math.min(VIEW_COLS - 1, Math.floor(((clock() % SWEEP_S) / SWEEP_S) * VIEW_COLS)); }
function draw(): void {
skyFill();
blockTag(0, '단계 사이를 잇는다 — 선형 보간', MINT, INK);
blockTag(1, '잇지 않고 세 구역으로 끊는다', BUTTER, INK);
blockTag(2, '한 열을 골라 색 읽기', ROSE, INK);
blockRight(0, 'RAMP_STOPS ' + RAMP_STOPS.length);
blockRight(1, 'zoneAt');
board(VIEW_X, boardY(0), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, lvAt, function (c: number): RGB { return colorAt(uOf(c)); });
board(VIEW_X, boardY(1), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, lvAt, function (c: number): RGB { return zoneAt(uOf(c)); });
// 색 띠 — 열마다 colorAt 값을 칠하고 고른 열 아래에 표시를 단다
const sx = VIEW_X + 4;
const sy = boardY(2) + 6;
const cw = (boardW(VIEW_COLS, PITCH) - 8) / VIEW_COLS;
for (let c = 0; c < VIEW_COLS; c += 1) { box(sx + c * cw, sy, cw + 0.4, 16, 0, colorAt(uOf(c)), 1); }
for (const s of RAMP_STOPS) { box(sx + s.at * (VIEW_COLS - 1) * cw + cw / 2 - 1.5, sy - 3, 3, 22, 1.5, INK, 0.9); }
const k = sampleCol();
const rgb = colorAt(uOf(k));
box(sx + k * cw - 1.5, sy - 5, cw + 3, 26, 3, WHITE, 0.95);
box(sx + k * cw, sy - 3, cw, 22, 2, rgb, 1);
text('열 ' + k + ' → rgb(' + Math.round(rgb[0]) + ', ' + Math.round(rgb[1]) + ', ' + Math.round(rgb[2]) + ')', VIEW_X + 4, sy + 34, INK, 1, 12, 700, 'left');
text('단계 3개 · 사이는 lerpRgb 한 줄', VIEW_X + boardW(VIEW_COLS, PITCH) - 4, sy + 34, INK, 0.9, 10, 700, 'right');
}
// 화면 낭독기로 보내는 문장 — 표시 문구와 판 이름만 담아 장면이 흘러도 달라지지 않는다
function speakText(): string {
return '표시 문구 {s} · 열 위치 색 띠 판'.replace('{s}', MSG);
}
function statusText(): string {
const k = sampleCol();
const rgb = colorAt(uOf(k));
return '열 {c} / {n} · rgb {r} {g} {b} · 색 단계 {s}개'.replace('{c}', String(k)).replace('{n}', String(VIEW_COLS)).replace('{r}', String(Math.round(rgb[0]))).replace('{g}', String(Math.round(rgb[1]))).replace('{b}', String(Math.round(rgb[2]))).replace('{s}', String(RAMP_STOPS.length));
}
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={'ARRIVING NOW 3 MIN 문구가 흐르면서 판의 열 위치에 따라 왼쪽은 붉은색 가운데는 호박색 오른쪽은 연두색으로 읽힌다. 위 판은 단계 사이를 선형 보간으로 잇고 가운데 판은 세 구역으로 끊으며, 아래 색 띠에서는 표시된 열의 rgb 값이 글자로 읽힌다'}></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>
);
}08 · 列の位置で色が変わる点 — 計測 累積面積 28.085% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 二つの色をkの割合で混ぜる — 色の三つのチャンネルを別々に線形で
function lerpRgb(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 盤の中の位置u(0から1)の色 — 二つの段階の間では補間する
function colorAt(u) {
const x = clamp(u, 0, 1);
for (let i = 1; i < RAMP_STOPS.length; i += 1) {
if (x <= RAMP_STOPS[i].at) {
const a = RAMP_STOPS[i - 1];
const b = RAMP_STOPS[i];
return lerpRgb(a.c, b.c, (x - a.at) / (b.at - a.at));
}
}
return RAMP_STOPS[RAMP_STOPS.length - 1].c;
}
// 補間せずに三つの区域で切る — 段階の色そのまま
function zoneAt(u) {
const i = Math.min(RAMP_STOPS.length - 1, Math.floor(clamp(u, 0, 0.999) * RAMP_STOPS.length));
return RAMP_STOPS[i].c;
}
09行を順に点ける走査駆動
安い駆動方式がなぜ一枚に見えるのかを、目で確かめる場面です。盤は一度に一行だけを7倍の強さで点け、SCAN_HZ は七行が毎秒一周する回数で、目の残像は EYE_S の0.09秒の指数平均で真似ています。60周なら一行の揺れが16%なので一枚に合わさって見え、SCAN_LOW の10は94%で行ごとにちらつき、下の盤は SLOW_HZ の1.5で点いた行が上から下へなぞる様子を見せます。

<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で1コマ、Rで最初から">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="HELLO ROW SCAN という文句が流れる二つの盤を、一行ずつだけ点ける方式で描く。上の盤は毎秒60周なので一枚に合わさって見え、中の小さな盤は毎秒10周なので行ごとに明るさが揺れ、右の棒がちらつきの幅を比べる。下の盤は毎秒1.5周とゆっくり見せるので、点いた一行が上から下へなぞる。"></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">1コマ</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;
// 글자와 글자 사이 빈 列 한 줄
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[' ']; }
// 문구를 列 목록으로 편다 — 列 하나는 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;
}
// 순환 목록 — 문구 列 뒤에 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();
}
// 1コマ — 멈춘 채 장면 시계를 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 는 一時停止, N 은 1コマ, 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 });
// 마우스·터치 — 무대를 누르면 一時停止 토글
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 });
// ── 09 행을 차례로 켜는 스캔 구동 — SCAN_HZ 가 높으면 한 장 / 낮으면 깜빡임(작은 판) / 느린 화면으로 한 행씩 보기 ────
// 판은 한 번에 한 행만 켠다. 눈의 잔상을 EYE_S 초짜리 지수 평균으로 흉내 내고, SCAN_HZ 는 일곱 행을 한 바퀴 도는 횟수다. 낮으면 행마다 밝기가 크게 출렁인다
const SCAN_HZ = 60;
const SCAN_LOW = 10;
const SLOW_HZ = 1.5;
const EYE_S = 0.09;
const SCROLL_SPEED = 9;
const GAP_COLS = 13;
const LEAD = 4;
const LOW_COLS = 22;
const LAP_S = 10;
const START_S = 3.0;
const REST_S = 2.6;
const MSG = 'HELLO ROW SCAN';
const MSG2 = 'ROW SCAN';
const cols = colsOf(MSG);
const cols2 = colsOf(MSG2);
const N = VIEW_COLS * GLYPH_H;
const lvA = [];
const lvB = [];
for (let i = 0; i < N; i += 1) {
lvA.push(0);
lvB.push(0);
}
let tickA = 0;
let tickB = 0;
function clock() { return sceneT + lap * LAP_S; }
function build() { return; }
function targetOf(t, c, r) {
return loopBit(cols, GAP_COLS, Math.floor(t * SCROLL_SPEED) + c - LEAD, r);
}
// 한 틱 — 行 하나만 7 배 세기로 켜고 눈 평균에 섞는다. 평균 밝기는 한 장으로 켠 것과 같다
function stepEye(lv, ncols, row, t, h) {
const e = Math.exp(-h / EYE_S);
for (let c = 0; c < ncols; c += 1) {
for (let r = 0; r < GLYPH_H; r += 1) {
const i = c * GLYPH_H + r;
const on = r === row ? 7 * targetOf(t, c, r) : 0;
lv[i] = lv[i] * e + on * (1 - e);
}
}
}
function update(dt) {
if (dt < 0) {
return;
}
const t1 = clock();
const rA = GLYPH_H * SCAN_HZ;
const rB = GLYPH_H * SCAN_LOW;
const goalA = Math.floor(t1 * rA);
const goalB = Math.floor(t1 * rB);
if (goalA - tickA > 600 || goalA < tickA) {
tickA = goalA - 600;
}
if (goalB - tickB > 600 || goalB < tickB) {
tickB = goalB - 600;
}
while (tickA < goalA) {
tickA += 1;
stepEye(lvA, VIEW_COLS, tickA % GLYPH_H, tickA / rA, 1 / rA);
}
while (tickB < goalB) {
tickB += 1;
stepEye(lvB, LOW_COLS, tickB % GLYPH_H, tickB / rB, 1 / rB);
}
}
// 켠 行 하나가 눈 평균에서 만드는 밝기 출렁임의 폭(평균 1 기준, 퍼센트) — 한 바퀴 hz 에서 해석식으로
function flickerOf(hz) {
const h = 1 / (GLYPH_H * hz);
const e = Math.exp(-h / EYE_S);
const d = Math.pow(e, GLYPH_H - 1);
const lo = 7 * (1 - e) * d / (1 - e * d);
const hi = lo * e + 7 * (1 - e);
return Math.round((hi - lo) * 100);
}
function rowIndex(t, hz) { return Math.floor(t * GLYPH_H * hz) % GLYPH_H; }
function draw() {
skyFill();
blockTag(0, 'SCAN_HZ {h} - 一枚に合わさって見える'.replace('{h}', String(SCAN_HZ)), MINT, INK);
blockTag(1, 'SCAN_LOW {h} - 行ごとにちらつく'.replace('{h}', String(SCAN_LOW)), ROSE, INK);
blockTag(2, 'スロー表示 {h} - 一度に一行'.replace('{h}', String(SLOW_HZ)), BUTTER, INK);
blockRight(0, 'persist ' + flickerOf(SCAN_HZ) + '%');
board(VIEW_X, boardY(0), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c, r) { return Math.min(1, lvA[c * GLYPH_H + r]); }, null);
board(VIEW_X, boardY(1), LOW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c, r) { return Math.min(1, lvB[c * GLYPH_H + r]); }, null);
// ちらつきの幅 막대 — 정지한 그림이라 번쩍이지 않는다
const bx = VIEW_X + boardW(LOW_COLS, PITCH) + 14;
const bw = 220;
text('ちらつきの幅', bx, boardY(1) + 6, INK, 1, 10, 700, 'left');
box(bx, boardY(1) + 16, bw, 11, 5, WHITE, 0.6);
box(bx, boardY(1) + 16, bw * Math.min(1, flickerOf(SCAN_HZ) / 100), 11, 5, MINT, 1);
text(SCAN_HZ + ' 回 → ' + flickerOf(SCAN_HZ) + '%', bx + bw + 4, boardY(1) + 22, INK, 1, 10, 700, 'left');
box(bx, boardY(1) + 34, bw, 11, 5, WHITE, 0.6);
box(bx, boardY(1) + 34, bw * Math.min(1, flickerOf(SCAN_LOW) / 100), 11, 5, ROSE, 1);
text(SCAN_LOW + ' 回 → ' + flickerOf(SCAN_LOW) + '%', bx + bw + 4, boardY(1) + 40, INK, 1, 10, 700, 'left');
// 느린 화면 — 지금 켜진 行 하나만 밝고 직전 행은 옅게 남는다
const t = clock();
const ri = rowIndex(t, SLOW_HZ);
const st = Math.floor((VIEW_COLS - cols2.length) / 2);
board(VIEW_X, boardY(2), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c, r) {
const k = bitOf(cols2, c - st, r);
if (!k) {
return 0;
}
return r === ri ? 1 : (r === (ri + GLYPH_H - 1) % GLYPH_H ? 0.3 : 0);
}, null);
blockRight(2, '行 ' + (ri + 1) + ' / ' + GLYPH_H);
}
// 화면 낭독기로 보내는 문장 — 표시 문구와 판 이름만 담아 장면이 흘러도 달라지지 않는다
function speakText() {
return '表示文句 {s} - 行スキャンの盤'.replace('{s}', MSG);
}
function statusText() {
return 'SCAN_HZ {a} ちらつき {x}% · SCAN_LOW {b} ちらつき {y}% · 点灯中の行 {r} / 7'.replace('{a}', String(SCAN_HZ)).replace('{x}', String(flickerOf(SCAN_HZ))).replace('{b}', String(SCAN_LOW)).replace('{y}', String(flickerOf(SCAN_LOW))).replace('{r}', String(rowIndex(clock(), SLOW_HZ) + 1));
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// RowScanMultiplexPersistence - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./RowScanMultiplexPersistence.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function RowScanMultiplexPersistence() {
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 });
// ── 09 행을 차례로 켜는 스캔 구동 — SCAN_HZ 가 높으면 한 장 / 낮으면 깜빡임(작은 판) / 느린 화면으로 한 행씩 보기 ────
// 판은 한 번에 한 행만 켠다. 눈의 잔상을 EYE_S 초짜리 지수 평균으로 흉내 내고, SCAN_HZ 는 일곱 행을 한 바퀴 도는 횟수다. 낮으면 행마다 밝기가 크게 출렁인다
const SCAN_HZ = 60;
const SCAN_LOW = 10;
const SLOW_HZ = 1.5;
const EYE_S = 0.09;
const SCROLL_SPEED = 9;
const GAP_COLS = 13;
const LEAD = 4;
const LOW_COLS = 22;
const LAP_S = 10;
const START_S = 3.0;
const REST_S = 2.6;
const MSG = 'HELLO ROW SCAN';
const MSG2 = 'ROW SCAN';
const cols = colsOf(MSG);
const cols2 = colsOf(MSG2);
const N = VIEW_COLS * GLYPH_H;
const lvA: number[] = [];
const lvB: number[] = [];
for (let i = 0; i < N; i += 1) { lvA.push(0); lvB.push(0); }
let tickA = 0;
let tickB = 0;
function clock(): number { return sceneT + lap * LAP_S; }
function build(): void { return; }
function targetOf(t: number, c: number, r: number): number {
return loopBit(cols, GAP_COLS, Math.floor(t * SCROLL_SPEED) + c - LEAD, r);
}
// 한 틱 — 행 하나만 7 배 세기로 켜고 눈 평균에 섞는다. 평균 밝기는 한 장으로 켠 것과 같다
function stepEye(lv: number[], ncols: number, row: number, t: number, h: number): void {
const e = Math.exp(-h / EYE_S);
for (let c = 0; c < ncols; c += 1) {
for (let r = 0; r < GLYPH_H; r += 1) {
const i = c * GLYPH_H + r;
const on = r === row ? 7 * targetOf(t, c, r) : 0;
lv[i] = lv[i] * e + on * (1 - e);
}
}
}
function update(dt: number): void {
if (dt < 0) { return; }
const t1 = clock();
const rA = GLYPH_H * SCAN_HZ;
const rB = GLYPH_H * SCAN_LOW;
const goalA = Math.floor(t1 * rA);
const goalB = Math.floor(t1 * rB);
if (goalA - tickA > 600 || goalA < tickA) { tickA = goalA - 600; }
if (goalB - tickB > 600 || goalB < tickB) { tickB = goalB - 600; }
while (tickA < goalA) { tickA += 1; stepEye(lvA, VIEW_COLS, tickA % GLYPH_H, tickA / rA, 1 / rA); }
while (tickB < goalB) { tickB += 1; stepEye(lvB, LOW_COLS, tickB % GLYPH_H, tickB / rB, 1 / rB); }
}
// 켠 행 하나가 눈 평균에서 만드는 밝기 출렁임의 폭(평균 1 기준, 퍼센트) — 한 바퀴 hz 에서 해석식으로
function flickerOf(hz: number): number {
const h = 1 / (GLYPH_H * hz);
const e = Math.exp(-h / EYE_S);
const d = Math.pow(e, GLYPH_H - 1);
const lo = 7 * (1 - e) * d / (1 - e * d);
const hi = lo * e + 7 * (1 - e);
return Math.round((hi - lo) * 100);
}
function rowIndex(t: number, hz: number): number { return Math.floor(t * GLYPH_H * hz) % GLYPH_H; }
function draw(): void {
skyFill();
blockTag(0, 'SCAN_HZ {h} — 한 장으로 합쳐 보인다'.replace('{h}', String(SCAN_HZ)), MINT, INK);
blockTag(1, 'SCAN_LOW {h} — 행마다 깜빡인다'.replace('{h}', String(SCAN_LOW)), ROSE, INK);
blockTag(2, '느린 화면 {h} — 한 번에 한 행'.replace('{h}', String(SLOW_HZ)), BUTTER, INK);
blockRight(0, 'persist ' + flickerOf(SCAN_HZ) + '%');
board(VIEW_X, boardY(0), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c: number, r: number): number { return Math.min(1, lvA[c * GLYPH_H + r]); }, null);
board(VIEW_X, boardY(1), LOW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c: number, r: number): number { return Math.min(1, lvB[c * GLYPH_H + r]); }, null);
// 깜빡임 폭 막대 — 정지한 그림이라 번쩍이지 않는다
const bx = VIEW_X + boardW(LOW_COLS, PITCH) + 14;
const bw = 220;
text('깜빡임 폭', bx, boardY(1) + 6, INK, 1, 10, 700, 'left');
box(bx, boardY(1) + 16, bw, 11, 5, WHITE, 0.6);
box(bx, boardY(1) + 16, bw * Math.min(1, flickerOf(SCAN_HZ) / 100), 11, 5, MINT, 1);
text(SCAN_HZ + ' 회 → ' + flickerOf(SCAN_HZ) + '%', bx + bw + 4, boardY(1) + 22, INK, 1, 10, 700, 'left');
box(bx, boardY(1) + 34, bw, 11, 5, WHITE, 0.6);
box(bx, boardY(1) + 34, bw * Math.min(1, flickerOf(SCAN_LOW) / 100), 11, 5, ROSE, 1);
text(SCAN_LOW + ' 회 → ' + flickerOf(SCAN_LOW) + '%', bx + bw + 4, boardY(1) + 40, INK, 1, 10, 700, 'left');
// 느린 화면 — 지금 켜진 행 하나만 밝고 직전 행은 옅게 남는다
const t = clock();
const ri = rowIndex(t, SLOW_HZ);
const st = Math.floor((VIEW_COLS - cols2.length) / 2);
board(VIEW_X, boardY(2), VIEW_COLS, GLYPH_H, PITCH, 0.42, true, 1.3, function (c: number, r: number): number {
const k = bitOf(cols2, c - st, r);
if (!k) { return 0; }
return r === ri ? 1 : (r === (ri + GLYPH_H - 1) % GLYPH_H ? 0.3 : 0);
}, null);
blockRight(2, '행 ' + (ri + 1) + ' / ' + GLYPH_H);
}
// 화면 낭독기로 보내는 문장 — 표시 문구와 판 이름만 담아 장면이 흘러도 달라지지 않는다
function speakText(): string {
return '표시 문구 {s} · 행 스캔 판'.replace('{s}', MSG);
}
function statusText(): string {
return 'SCAN_HZ {a} 깜빡임 {x}% · SCAN_LOW {b} 깜빡임 {y}% · 지금 켜진 행 {r} / 7'.replace('{a}', String(SCAN_HZ)).replace('{x}', String(flickerOf(SCAN_HZ))).replace('{b}', String(SCAN_LOW)).replace('{y}', String(flickerOf(SCAN_LOW))).replace('{r}', String(rowIndex(clock(), SLOW_HZ) + 1));
}
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={'HELLO ROW SCAN 문구가 흐르는 판 두 장을 행 하나씩만 켜는 방식으로 그린다. 위 판은 초당 60 바퀴라 한 장으로 합쳐 보이고, 가운데 작은 판은 초당 10 바퀴라 행마다 밝기가 출렁이며 오른쪽 막대가 깜빡임 폭을 비교한다. 아래 판은 초당 1.5 바퀴로 느리게 보여 한 번에 켜진 행이 위에서 아래로 훑는다'}></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>
);
}09 · 行を順に点ける走査駆動 — 計測 累積面積 19.942% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 一拍 — 一行だけを7倍の強さで点けて目の平均に混ぜる。平均の明るさは一枚で点けたものと同じ
function stepEye(lv, ncols, row, t, h) {
const e = Math.exp(-h / EYE_S);
for (let c = 0; c < ncols; c += 1) {
for (let r = 0; r < GLYPH_H; r += 1) {
const i = c * GLYPH_H + r;
const on = r === row ? 7 * targetOf(t, c, r) : 0;
lv[i] = lv[i] * e + on * (1 - e);
}
}
}
電光掲示板をコードで描くと、文一つはどんな値いくつで点の動きになるのでしょうか?
文一つは列の並び一つと開始の列番号一つで決まり、場面ごとにその点の明るさを直す値が一つずつ足されます。だから九つの場面が colsOf を共有し、loopBit は02・03・06・07・08・09が、bitOf は04・05・07・09が使います。
点一つが持つ値とその役目
| 値 | 作られる場所 | 役目 |
|---|---|---|
cols |
colsOf |
文句を7ビットの整数の列の並びに広げたもの |
SCROLL_SPEED |
02・03・07は24、06は13、08は12、09は9 | 開始の列番号が毎秒上がる列数 |
GAP_COLS |
13 | 一周を作るために文句の後ろへつなぐ空の列 |
lvAt(c, r) |
場面ごとに違う | board に渡す0から1の明るさ |
DECAY・LEVELS・SCAN_HZ |
03・05・09 | 残光の掛け率・明るさの段階数・行の走査の速さ |
読む向きが逆になる回
数字を一つずつ回す計算は数字ローリングの回が扱っていて、この回は点が集まって文句になる計算にとどまります。そちらは数字の桁を動かし、こちらは文字の表の1のマスから点を灯す点が違います。
文を文字表の列の並びに広げ、開始の列番号を時間に合わせて進め、点ごとに明るさを下げながら塗る — 電光掲示板の一コマが描かれる順番
一コマが通り過ぎる五つの歩み
colsOfが文句を列の整数の並びに広げます。文字一つが列5本で、文字の間に空の列が一本です。- 開始の列番号を時間から求めます。整数の列で進めるときは
Math.floor(clock * SCROLL_SPEED)です。 - 盤のマス
(c, r)ごとにloopBit(cols, gap, start + c - LEAD, r)で灯るべきかを読みます。LEADの4は、文句の最初の列を盤の四番目のマスから始めさせます。 - 03・05・09は点ごとに明るさを別に持ち、掛けるか、段階に切るか、目の平均に混ぜます。
boardが下地の点・にじみ・点・ハイライトの順に塗ります。
電光掲示板 文字 流れる動きを作りたい人は、colsOf の一関数から読むと、残りがこの並びの上に乗っていることが見えてきます。
開始の列番号が一周する循環の並び
文句の列のあとに空の列を gap 個つないで一周を作り、番号は一周の長さで余りを取るので、開始の列番号がどれだけ大きくなっても同じ文句が続けて戻ってきます。
// 循環する並び — 文句の列のあとに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;
}
電光掲示板のシミュレーターのように実際の画面に付けるとき — ライブラリなしで作る・文句を配列で書く方法・文句が替わる時点
ライブラリなしで一つのキャンバスに付ける
index.html 一つの中にキャンバス・一時停止ボタン・状態の行があり、点は roundRect・arc・fillRect で直接塗ります。電光掲示板 サイトのように店の案内や告知の帯を載せたい人が、場面の時計と board の二つの部分だけを切り出して持っていけるコードです。
文句を配列で書き、幅を測って方式を選ぶ
04は PHRASES の配列に OPEN 24H・SALE 30%・NEW MENU・THANK YOU の四つの文句を書いておき、一区切りごとに一つずつ回します。文句が増えても減っても LAP_S が PERIOD と文句の数の積で決まるので、くり返しの長さがついてきます。長さがばらばらの文句は07の holdOrScroll 一つで、止めるか流すかを分けます。
文句が替わる時点を一か所に集める
04の slot は文句の番号とその区切りの中の経過時間を一つの関数で求め、状態の文は押し上げた行が ROLL_ROWS に届く瞬間に次の文句の名前へ替わります。待ち番号の表示のように番号が替わる場所は、この構造に番号だけを差し替えればよく、文句が替わる時刻を場面の時計一か所だけで決めておくと、盤と読み上げの文がずれません。
電光掲示板が壊れる瞬間 — 小数の列で動かして点がにじむ落とし穴・文の終わりと始まりがくっついて読めない継ぎ目・フレームの間隔に頼って残光が遅くなること
小数の列で動かすと点がにじむ
02の中の盤は同じ速さを小数の列で動かします。開始の列30.5では点いた点203個のうち159個が中間の明るさになる一方で、整数の列で進めると点いた点120個が完全に点いて中間の明るさは0個です。
終わりと始まりがくっつくと読めない
空きが13列なら一周のつなぎ目に空の列が連続13本あり、0なら0本です。文句の中の単語の間の空の列は7本なので、空きを単語の間くらいまで縮めても、文の終わりと始まりが一つの単語のように読めてしまうことがあります。
フレームの間隔に頼った残光は画面によって変わる
03の真ん中の盤はフレームごとに0.55を掛けるので、144 Hz画面では一列後に残る明るさが0.028まで落ちて尾が0列になり、経過時間で掛けた側は同じ画面でも4列が残ります。減衰をフレームに結びつけると、リフレッシュレートの違う機器で尾の長さがばらばらになります。
幅の合わない文句を一方の方式に固定すると
長い文句の209列を止めておくと盤の64列しか見えず31%で、点いた点421個のうち139個(33%)しか見えずに145列が隠れます。逆に短い文句の47列を流すと7秒のあいだ盤の列の41%が空いていて、止めれば27%(両脇の8列と9列)です。
文字の表にない文字は空白になる
glyphRows は表にない文字を空白の表に置き換えます。表は英大文字・数字・記号7種と空白の45文字なので、小文字とハングルは空の列で出るため、文句を大文字で書くか、表に文字を足す必要があります。
九つの場面の完成ソースは下のzipにまとめてあり、展開するときに 7hnz4qjz を入力すると開きます。
スクロールの速さと残光の減衰と走査の速さを変えると、感じはどれほど変わるのでしょうか — SCROLL_SPEED・DECAY・SCAN_HZ を変えた違い
| 名前 | 変えた値 | 感じ |
|---|---|---|
| スクロールを速くする | 02 SCROLL_SPEED 24 → 60 |
一周が5.00秒から2.00秒に縮み、文字一つ(6列)が通り過ぎる時間が250msから100msになって読む前に過ぎます。下げると文字はくっきりしますが、長い文句は一周が長く退屈です |
| 残光を長くする | 03 DECAY 0.55 → 0.9 |
時間基準の尾が4列から26列に伸びて文字四つ分ほど引きずり、文字の形がにじんで読みにくくなります。下げるとくっきりしますがちらつきが増えます |
| 走査を半分にする | 09 SCAN_HZ 60 → 30 |
一行の揺れ幅が16%から32%へ倍になりますが、盤全体の明るさの揺れは2.1%から4.2%にとどまり、一枚に読めます |
値を変える前に見ておくこと
02の速さを上げると、文句が盤を通り過ぎる時間が2.67秒から1.07秒に縮み、長い案内文は一度通る間に読めません。03の DECAY はフレーム基準で見ると、同じ0.9でも尾が0列から3列に伸びるだけなので、値一つが二つの方式で違う大きさに読まれます。
走査の速さには下げられる限界がある
SCAN_HZ を12に下げると一行の揺れが79%で盤全体も10.3%揺れ、SCAN_LOW の10では94%と12.3%です。盤全体が明滅して見え、光の点滅に敏感な人には発作を誘発するおそれがあるため、変形としてお勧めする一番低い値は30です。10前後まで下げるのは避けてください。
動きを減らす設定では止まった一行の電光掲示板 — 場面名と今見えている文句を文で読み上げるアクセシビリティ
動きを減らす設定なら一枚だけ描く
prefers-reduced-motion が有効だと requestAnimationFrame のループを始めず、場面の時計を REST_S にして一枚だけ描きます。止まった場面の時刻は01が5.2秒、02が0秒、03が3.1秒、04が0.8秒、05が3.3秒、06が2.0秒、07が0.6秒、08が2.4秒、09が2.6秒の地点で、ページを開いたまま設定を変えても change リスナーが読み直します。
キーボードで止めて最初から見直す
舞台は role="application" で tabindex が0なので、フォーカスを受けるとEnter・Space・Pが一時停止、Nが場面の時計を0.4秒ぶん一歩進め、Rが最初からやり直しです。ボタンは一時停止(aria-pressed)と一歩の二つで、舞台を押しても一時停止が切り替わります。
今見えている文句を文で知らせる
キャンバスは role="img" で aria-label があり、九つの項目とも説明に表示の文句をそのまま書きます。画面の状態の行は statusText が開始の列・読んだマス数のような値を入れて変わり続けますが、読み上げ機に届く文は別に置いた speakText で、表示の文句と盤の名前(04・07は今の文句と押し上げ・流れの方式)だけを含みます。状態の行自体は aria-live が off のままで、pushSpeak が文の変わったときだけ role="status" の段落に書くので、デモ中は一つの盤が終わる0.6秒前に一度、人が触ったあとは文句かモードが変わったときだけ読まれ、値が流れ続けても読み上げが溢れません。
隠れたタブでは止まる
タブが隠れると stopLoop でループを止め、戻ると lastTs を空にして最初のフレームの間隔を捨てるので、隠れていた数秒が一フレームに押し寄せません。
電光掲示板のよくある質問 — ライブラリなしで作る・ドット文字はどこで手に入れるか・ウェブに載せるときに必要なこと
電光掲示板のプログラムはライブラリなしで作れますか
作れます。キャンバス2Dの roundRect・arc・fillRect と requestAnimationFrame だけで九つの場面が動き、外部のスクリプトはありません。
電光掲示板 フォントはどこで手に入りますか
この回はフォントファイルを受け取らず、GL の表に文字一つを '00000' のような7行で直接書きます。文字を足すには同じ形で一マスを書けば glyphRows が探して使い、書いていない文字は空白になります。
ウェブに載せるときに必要なことはありますか
スクリプトが index.html の中に入っていて fetch や外部アドレスの呼び出しがないので、別に用意するサーバー側の作業はありません。キャンバスの倍率は devicePixelRatio を2で切って使うため、倍率3の機器でも画素が2.25倍に増えません。
キャンバスの描画はMDNのCanvas 2D、アニメーションのループはrequestAnimationFrame、タブが隠れる場合はPage Visibility API、動きを減らす設定はprefers-reduced-motionの文書が基準です。流れる相場表示はリアルタイム相場表示の回で続けて見られます。









