GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

LED 전광판 9종 — 5x7 점 글꼴부터 잔광과 행 스캔까지

LED 전광판은 글자를 어떻게 점 몇 개로 바꿀까 — 01번은 5x7 표에서 1 인 칸만 밝히고, 03번은 꺼진 점에 잔광을 남기며, 09번은 행을 하나씩 켜는 스캔으로 한 장처럼 보이게 한다. 아홉 장면은 점 격자 버퍼를 계산해서 그리는 코드를 데모와 함께 푼다.

LED 전광판은 화면에 글자를 쓰는 일이 아니라 글자 하나를 가로 5칸 세로 7칸의 점 표로 바꿔 한 칸씩 밝히는 계산이다 — LED 전광판 만들기는 글꼴 표와 흐르는 열 번호를 어떻게 잡을지 정하는 데서 시작한다

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아홉 장면의 틀은 하나다. 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초를 넘지 못하게 잘라 둔다.

LED 전광판 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 을 돌려준다.

5x7 점 글꼴을 격자에 찍기
새 창에서 열기

01 · 5x7 점 글꼴을 격자에 찍기 — 측정 누적면적 14.124% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

GLYPH_WGLYPH_HglyphRows
    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 가 붙는다.

열 단위로 끊김 없이 흐르는 스크롤
새 창에서 열기

02 · 열 단위로 끊김 없이 흐르는 스크롤 — 측정 누적면적 37.431% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SCROLL_SPEEDGAP_COLScolAt
    // 한 바퀴의 열 수 — 문구 열 뒤에 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열로 갈린다.

꺼진 점에 남는 잔광
새 창에서 열기

03 · 꺼진 점에 남는 잔광 — 측정 누적면적 24.035% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

DECAYlevelfadeStep
    // 머리에서 뒤로 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 에서 한 번에 컷으로 바뀌고 아래 판은 소수 행으로 밀어 점이 번진다.

문장이 바뀔 때 행을 밀어 올리는 전환
새 창에서 열기

04 · 문장이 바뀔 때 행을 밀어 올리는 전환 — 측정 누적면적 20.402% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ROLL_STEProwOffsetswapAt
    // 굴림이 시작된 뒤 몇 행 올라갔는지 — 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단계로 나란히 그리면 단계가 적을수록 번짐 띠가 계단으로 끊겨 보인다.

밝기를 몇 단계로 나눠 켜고 끄기
새 창에서 열기

05 · 밝기를 몇 단계로 나눠 켜고 끄기 — 측정 누적면적 9.708% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LEVELSquantizestepOf
    // 몇 번째 단계인지 — 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;
    }

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 는 비율이 클수록 가장자리 둘레 배율을 낮춘다.

점 간격과 반지름의 비율
새 창에서 열기

06 · 점 간격과 반지름의 비율 — 측정 누적면적 28.342% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PITCHDOT_FILLsoftEdge
    // 가장자리 풀기 — 반지름 비율이 클수록 이웃과 겹치니 둘레 배율을 낮춘다. 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 을 번갈아 걸고 긴 문구가 흐른다.

폭을 재서 멈출지 흘릴지 정하기
새 창에서 열기

07 · 폭을 재서 멈출지 흘릴지 정하기 — 측정 누적면적 12.04% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

VIEW_COLScolCountholdOrScroll
    // 열 수를 세는 함수 하나가 두 갈래를 모두 정한다 — 판 칸 수보다 길면 흐르고 아니면 멈춘다
    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초 주기로 훑으며 읽는다.

열 위치에 따라 색이 변하는 점
새 창에서 열기

08 · 열 위치에 따라 색이 변하는 점 — 측정 누적면적 28.085% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

RAMP_STOPScolorAtlerpRgb
    // 두 색 사이를 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 로 켜진 행이 위에서 아래로 훑는 모습을 보인다.

행을 차례로 켜는 스캔 구동
새 창에서 열기

09 · 행을 차례로 켜는 스캔 구동 — 측정 누적면적 19.942% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SCAN_HZrowIndexpersist
    // 한 틱 — 행 하나만 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);
            }
        }
    }

LED 전광판을 코드로 그리면 문장 하나는 어떤 값 몇 개로 점의 움직임이 될까?

문장 하나는 열 목록 하나와 시작 열 번호 하나로 정해지고, 장면마다 그 점의 밝기를 고치는 값이 하나씩 더 얹힌다. 그래서 아홉 장면이 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 인 칸에서 점을 켜므로 읽는 방향이 반대다. 숫자 하나를 굴리는 계산은 숫자 롤링 편이 다루고, 이 편은 점이 모여 문구가 되는 계산에 머문다.

문장을 글꼴 표의 열 목록으로 펴고, 시작 열 번호를 시간에 맞춰 올리고, 점마다 밝기를 줄여 가며 칠하는 — LED 전광판 한 프레임이 그려지는 순서

한 프레임이 지나가는 다섯 걸음

  1. colsOf 가 문구를 열 정수 목록으로 편다. 글자 하나가 열 5개이고 글자 사이 빈 열이 하나다.
  2. 시작 열 번호를 시간으로 구한다. 정수 열 이동은 Math.floor(clock * SCROLL_SPEED) 이다.
  3. 판의 칸 (c, r) 마다 loopBit(cols, gap, start + c - LEAD, r) 로 켜져야 하는지 읽는다. LEAD 4 는 문구 첫 열이 판의 네 번째 칸에서 시작하게 한다.
  4. 03·05·09 는 점마다 밝기를 따로 들고 곱하거나 단계로 자르거나 눈 평균에 섞는다.
  5. board 가 바탕 점·번짐·점·하이라이트 순으로 칠한다.

led 전광판 만들기가 처음이라면 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;
    }

LED 전광판 사이트처럼 실제 화면에 붙일 때 — 라이브러리 없이 만들기·문구를 배열로 적는 법·문구가 바뀌는 시점

라이브러리 없이 한 캔버스에 붙이기

index.html 하나 안에 캔버스·일시정지 버튼·상태 줄이 있고 점은 roundRect·arc·fillRect 로 직접 칠한다. LED 전광판 모듈을 사려는 사람보다 웹 화면에 점 격자 판을 띄우려는 사람을 위한 코드라서, 장면 시계와 board 두 부분만 떼어 가면 가게 안내판이나 공지 띠가 된다.

문구를 배열로 적고 폭을 재서 모드를 고른다

04 는 PHRASES 배열에 OPEN 24H·SALE 30%·NEW MENU·THANK YOU 네 문구를 적어 두고 마디마다 하나씩 돌린다. 문구가 늘거나 줄어도 LAP_S 가 PERIOD 와 문구 수의 곱으로 정해져 되풀이 길이가 따라온다. 길이가 제각각인 문구는 07 의 holdOrScroll 하나로 멈춤과 흐름을 나눈다.

문구가 바뀌는 시점을 한 곳에 모은다

04 의 slot 은 문구 번호와 그 마디 안 경과 시간을 한 함수에서 구하고, 상태 문장은 굴린 행이 ROLL_ROWS 에 닿는 순간 다음 문구 이름으로 바뀐다. 대기 번호판처럼 번호가 바뀌는 곳은 이 구조에 번호만 갈아 끼우면 되고, 문구가 바뀌는 시각을 장면 시계 한 곳에서만 정하면 판과 읽어 주는 문장이 어긋나지 않는다.

LED 전광판이 깨지는 순간 — 소수 열로 이동해 점이 번지는 함정·문장 끝과 처음이 붙어 읽히지 않는 이음새·프레임 간격에 기대어 잔광이 느려지는 일

소수 열로 옮기면 점이 번진다

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 안팎 아래로는 내리지 않는다.

움직임 줄이기에서는 멈춘 한 줄의 LED 전광판 — 장면 이름과 지금 보이는 문구를 문장으로 읽어 주는 접근성

움직임 줄이기 설정이면 한 장만 그린다

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 를 비워 첫 프레임 간격을 버리므로, 숨은 동안의 몇 초가 한 프레임에 몰리지 않는다.

LED 전광판 FAQ — 라이브러리 없이 만들기·점 글꼴은 어디서 얻는지·웹에 올릴 때 필요한 것

led 전광판 프로그램은 라이브러리 없이 만들 수 있나요

만들 수 있다. 캔버스 2D 의 roundRect·arc·fillRect 와 requestAnimationFrame 만으로 아홉 장면이 돌아가고 외부 스크립트는 없다.

5x7 점 글꼴은 어디서 얻나요

이 편은 글꼴 파일을 받지 않고 GL 표에 글자 하나를 '00000' 같은 7줄로 직접 적는다. 글자를 더하려면 같은 모양으로 한 칸을 적으면 glyphRows 가 찾아 쓰고, 적지 않은 글자는 공백이 된다.

웹에 올릴 때 필요한 것이 있나요

스크립트가 index.html 안에 들어 있고 fetch 나 외부 주소 호출이 없어 따로 준비할 서버 쪽 일은 없다. 캔버스 배율은 devicePixelRatio 를 2 에서 잘라 쓰므로 배율이 3 인 기기에서도 화소가 2.25배로 늘지 않는다.

캔버스 그리기는 MDN 의 Canvas 2D 문서, 애니메이션 루프는 requestAnimationFrame 문서, 탭이 숨는 때는 Page Visibility API 문서, 움직임 줄이기는 prefers-reduced-motion 문서가 기준이다. 시세가 흐르는 표시판은 실시간 시세판 편에서 이어서 볼 수 있다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.