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

워드 클라우드 만들기 9종 — 나선 배치·모양 채우기·3D 구·실시간 투표

워드 클라우드 만들기는 글에서 낱말을 세어 자주 나온 말은 크게, 드문 말은 작게 그려 서로 겹치지 않게 한 판에 앉히는 일이다. 투표가 하나 들어올 때마다 단어 자리가 통째로 뒤바뀌면 아무도 못 읽는다.

설문 답변 요약부터 실시간 투표판까지 — 단어가 제자리를 찾는 아홉 화면

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

손대지 않으면 아홉 판이 저마다 정해진 시드로 혼자 돈다. 01~05 는 낱말이 하나씩 내려앉는 과정을 보여 준 뒤 처음부터 되풀이하고, 06 은 첫 바퀴에만 거품이 하나씩 부푼 뒤 6초마다 바깥으로 한 번 튕겼다 다시 모이며, 07 은 스스로 도는 구가 크림색 고리로 표시된 가상 포인터 쪽으로 기운다. 08 은 쉬지 않고 바람에 흔들리며, 09 는 미리 뽑아 둔 56표를 125ms 마다 하나씩 넣는다. 캔버스 위에 포인터를 처음 올리는 순간 시연 표시가 떨어지고, 01~05·08 은 포인터 아래 낱말에 고리와 말풍선을, 06·09 는 말풍선만, 07 은 앞쪽 낱말에만 말풍선을 띄운다(05 는 「낱말 · 주제」, 09 는 「낱말 · N표」, 나머지는 「낱말 · N회」).

배지 목록에 크기만 달리 준 기술 스택 태그 클라우드 와 다른 점은 자리다. 거기서는 배지가 줄을 따라 차례로 흐르지만, 여기 아홉 판은 낱말 하나하나가 이미 놓인 글자와 겹치지 않을 자리를 계산으로 찾는다.

설문 주관식 답변을 한 장으로 줄이는 판

설문 주관식 답변을 받아 두고 무엇이 많이 나왔는지 한눈에 보여 줘야 한다면 01 이 출발점이다. 답변 문장을 그대로 넣으면 낱말로 쪼개 세고 크기를 매기는 데까지 한 번에 하니, 지금 예시 답변 30개가 있는 자리에 실제 답변을 넣으면 된다. 회고 회의에서 나온 의견을 갈래별로 묶어 보여 줄 때는 주제마다 색과 기준점이 다른 05 가 낫다.

강연장 화면에 띄우는 투표판

강연이나 수업 중에 청중 표를 받아 띄우는 실시간 워드 클라우드 만들기에는 09 가 맞다. 버튼은 일정표에 미리 뽑아 둔 다음 표를 castVote 로 넣는데, 이 함수가 표 수를 올리고 가야 할 크기를 다시 매긴 뒤 1위가 바뀌었는지 확인하므로, 실제 응답이 들어올 때 선택지 번호로 이 함수를 부르는 쪽만 바꾸면 될 것으로 보인다. 다만 선택지 낱말 14개는 처음부터 정해져 있다(시작 표 3·3·2·2·2 에 1표짜리 아홉 개). 자유 입력을 받아 새 낱말이 생기는 판이라면 01 처럼 세는 단계를 앞에 붙여야 한다.

워드클라우드 생성은 왜 큰 단어부터 자리를 정하는가?

01~05·08 은 많이 나온 낱말부터 차례로 자리를 잡는다. 07 은 구 위 자리 번호를 시드로 섞어 크기와 무관하게 흩고, 06 은 거품을 하나씩 부풀린 뒤 힘으로 밀어 정리하며, 09 는 선택지 자리를 지키는 쪽이라 이 순서를 따르지 않는다.

빈 판에서는 첫 걸음에 자리가 난다

처음 놓는 낱말은 판이 비어 있어 나선 첫 걸음에서 바로 자리를 얻는다. 02 실측(이 글의 배치 수치는 ko 낱말 세트 기준)에서 첫 낱말은 1걸음, 그다음 네 낱말은 139·154·112·162걸음이 들었다. 큰 낱말을 뒤로 미루면 이미 흩어진 작은 글자들 사이에 50px 짜리 글자가 들어갈 틈이 남지 않아, 나선을 더 오래 돌거나 0.85배씩 줄어들어야 할 것으로 보인다. 크기로 횟수를 보여 주는 그림에서 1위 낱말이 줄어들면 그 그림은 순위를 틀리게 말하게 된다.

작은 낱말은 남은 틈을 메운다

03 은 이 순서를 끝까지 밀고 간다. 앞 낱말 48개를 놓은 다음 같은 낱말을 11·10·9·8px 로 다시 써서 큰 글자 사이 틈을 채우고, 판이 차서 153개에서 멈춘다. 04 도 깊은 칸부터 앉혀 첫 낱말이 가장 깊은 106.7px 자리에 앉고, 하트 가장자리로 갈수록 작은 낱말이 남는다.

크기는 넓이로, 자리는 나선으로 — 횟수를 제곱근으로 줄이고 비트 칸 겹침으로 빈자리를 찾는 계산

횟수가 두 배면 글자 넓이도 두 배

01~05·07·08 의 글자 크기는 sizeFor 한 줄이 정한다. 크기 = 50 × (횟수 ÷ 1위 횟수)^0.5 이고, 10px 아래로는 내려가지 않는다. 글자가 차지하는 넓이는 크기의 제곱에 비례하므로 지수가 0.5 면 넓이가 횟수에 비례한다. 01 의 16개 낱말은 12회 50px·6회 35.4px·3회 25px·1회 14.4px 로 크기가 제각각이지만, 크기² ÷ 횟수는 16개 모두 208.3 으로 같았다. 지수를 1 로 두면 글자 높이가 횟수에 비례하고 넓이는 횟수의 제곱으로 커지니, 1위가 실제 표 차이보다 훨씬 앞서 보이는 셈이다.

32화소를 정수 하나에 담는 비트 칸

겹침은 글자 상자가 아니라 글자 모양으로 잰다. makeSprite 가 작은 캔버스에 낱말을 그리고 getImageData 로 알파가 40 을 넘는 화소를 1 로 적되, 둘레 3×3 까지 칠해 글자 사이에 1px 틈을 남긴다. 한 줄의 화소 32개가 32비트 정수 하나에 들어가니 480px 판은 한 줄에 정수 17개(480 ÷ 32 = 15 에 여유 2), 300줄이다. 낱말을 x 자리에 대 볼 때는 정수를 ox & 31(32 로 나눈 나머지)만큼 오른쪽으로 밀고 앞 정수에서 넘친 비트를 이어 붙여 판 정수와 AND 한다. 결과가 0 이 아니면 어딘가 한 화소라도 겹친 것이다.

        bitTests += 1;
        const s = ox & 31;
        const base = ox >> 5;
        const inv = 32 - s;
        for (let r = 0; r < sp.h; r += 1) {
            const row = (oy + r) * BOARD_INTS + base;
            const sr = r * sp.mw;
            let carry = 0;
            for (let k = 0; k < sp.mw; k += 1) {
                const v = sp.bits[sr + k];
                const part = s === 0 ? v : ((v >>> s) | carry);
                if ((board[row + k] & part) !== 0) {
                    return true;
                }
                carry = s === 0 ? 0 : (v << inv);
            }
            if (carry !== 0 && (board[row + sp.mw] & carry) !== 0) {
                return true;
            }
        }

정수 하나를 AND 하면 화소 32개를 한 번에 보므로, 글자 상자 안 화소를 하나씩 비교할 때보다 비교 횟수가 32분의 1 로 줄어드는 셈이다(계산으로 낸 값이고, 줄마다 넘친 비트를 잇는 AND 가 한 번 더 붙을 수 있다). 02 에서 나선 9,007걸음이 모두 이 검사를 한 번씩 거쳤다.

나선 한 바퀴에 반지름 21.99px

자리 후보는 가운데서 바깥으로 도는 나선 위의 점들이다. 걸음 i 의 각은 i × 0.1 라디안, 반지름은 i × 0.35px 이고 가로를 1.6배 늘려 넓은 판에 맞춘다. 한 바퀴가 62.8걸음이니 반지름은 바퀴마다 21.99px 씩 넓어지고, 판 대각선의 절반까지 닿는 809걸음을 다 돌아도 빈자리가 없으면 그 크기로는 놓을 곳이 없다는 뜻이다.

워드 클라우드 만들기 9종 — 빈도 크기·나선 배치·칸 쪼개기 충돌·하트 채우기·주제 무리·단어 거품·3D 구·바람 흔들림·실시간 투표

아홉 판은 캔버스 2D 와 자바스크립트로만 그린다. 라이브러리·WebGL·CDN·이미지 파일이 없고, 난수가 필요한 06·07·08·09 는 시드를 받는 makeRng 에서 값을 꺼내 몇 번을 열어도 같은 장면이 나온다. 프레임 콜백(requestAnimationFrame)이 올 때마다 지난 시간을 모아 16ms 단위로 장면을 진행시키고, 탭을 오래 비웠다 돌아와도 한 프레임에 6단위 넘게 몰아서 계산하지 않는다. CSS 키프레임이나 타이머는 하나도 쓰지 않는다.

2초 동안 스물세 번 견준 움직임

판마다 2초를 24장으로 찍어 이웃한 두 장의 차이를 스물세 번 구했다. 누적면적은 그 사이 한 번이라도 바뀐 화소가 판에서 차지한 비율이다.

판 누적면적 움직인 프레임 강도 평균
01 글을 세는 단어판 15.3% 18/23 84.3
02 나선 배치 18.5% 23/23 73.2
03 칸 쪼개기 충돌 63.0% 23/23 46.2
04 하트 채우기 20.3% 22/23 61.1
05 주제 무리 18.2% 23/23 71.3
06 단어 거품 44.1% 23/23 92.2
07 3D 단어 구 31.1% 23/23 69.5
08 바람 흔들림 29.7% 23/23 33.4
09 실시간 투표 10.1% 22/23 76.7

03 이 63.0% 로 가장 넓은 것은 낱말 수가 바뀔 때마다 칸 경계를 다시 그리고 막 놓인 낱말이 걸친 칸을 하늘색으로 칠하기 때문으로 보이고, 09 가 10.1% 로 가장 좁은 것은 표를 받은 낱말과 비켜서는 이웃만 움직이기 때문으로 보인다. 01 의 18/23 과 04·09 의 22/23 은 낱말이 다 놓인 뒤나 표와 표 사이에 화면이 잠깐 멈춘 순간이 잡힌 것으로 보인다.

01글을 세어 넓이로 크기를 정하는 단어판

예시 답변 30개를 빈칸·쉼표·마침표·물음표 같은 구분 문자로 잘라 낱말 75개를 세면 16종이 남고, 많이 나온 말부터 105ms 간격으로 튀어나오며 머리 위에 「N회」 꼬리표를 띄운다. 설문 주관식 답변 요약처럼 원문 문장을 그대로 받아야 할 때 쓰는 판으로, 대문자는 소문자로 맞춰 같은 말로 센다. 아래는 countWords 의 몸통이고, 횟수가 같으면 먼저 나온 낱말이 앞에 선다.

글을 세어 넓이로 크기를 정하는 단어판
새 창에서 열기

01 · 글을 세어 넓이로 크기를 정하는 단어판 — 측정 누적면적 15.29% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SIZE_EXPONENTSPLIT_PATTERNAPPEAR_GAP_MS
        const counts = new Map();
        const order = [];
        let tokens = 0;
        for (let i = 0; i < lines.length; i += 1) {
            const parts = lines[i].split(SPLIT_PATTERN);
            for (let k = 0; k < parts.length; k += 1) {
                const w = parts[k].trim().toLowerCase();
                if (!w) {
                    continue;
                }
                tokens += 1;
                const c = counts.get(w);
                if (c === undefined) {
                    order.push(w);
                    counts.set(w, 1);
                }
                else {
                    counts.set(w, c + 1);
                }
            }
        }
        const entries = order.map(function (w, i) { return { text: w, count: counts.get(w) || 0, theme: i }; });
        entries.sort(function (a, b) { return b.count - a.count || a.theme - b.theme; });
        return { entries: entries, tokens: tokens };

02나선을 따라 빈칸을 찾는 배치

앞 낱말 36개가 가운데(240, 146)에서 나선을 한 걸음씩 밟아 가며, 걸음마다 글자 비트 칸을 판에 대 보고 처음 비는 자리에 내려앉는다. 지나온 자취를 옅게 남기고 지금 재는 글자 상자를 분홍 점선으로 보여 주니, 블로그 태그 모음 화면에서 배치가 어떻게 정해지는지 설명할 때 그대로 쓸 수 있다. 36개에 나선 9,007걸음(낱말당 평균 250.2, 가장 긴 것 325 — en 낱말이면 10,308걸음, ja 낱말이면 8,890걸음)이 들었고 비트 검사도 9,007번이라 판 밖으로 걸러진 걸음은 없었다.

나선을 따라 빈칸을 찾는 배치
새 창에서 열기

02 · 나선을 따라 빈칸을 찾는 배치 — 측정 누적면적 18.528% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SPIRAL_STEPSPIRAL_TURNBOARD_INTS
    function spiralAt(i) {
        const a = i * SPIRAL_TURN;
        const r = i * SPIRAL_STEP;
        return [Math.cos(a) * r * SPIRAL_ASPECT, Math.sin(a) * r];
    }
    // 나선 탐색 — 기준점 (cx, cy) 에서 나선을 한 걸음씩 옮기며, 글자 상자 가운데를 그 점에 두고 비트 칸이 안 겹치는 첫 자리를 돌려준다.
    // trail 을 주면 지나온 나선 점을 모두 적는다(02 가 그 자취를 그린다)
    function searchSpiral(board, sp, cx, cy, trail) {
        for (let i = 0; i < SPIRAL_LIMIT; i += 1) {
            spiralSteps += 1;
            const p = spiralAt(i);
            const ox = Math.round(cx + p[0] - sp.w / 2);
            const oy = Math.round(cy + p[1] - sp.h / 2);
            if (trail) {
                trail.push([cx + p[0], cy + p[1]]);
            }
            if (!collides(board, sp, ox, oy)) {
                return [ox, oy];
            }
        }
        return null;
    }

03칸을 넷씩 쪼개 빨리 찾는 충돌 검사

판을 네 칸으로 나누고 칸 안 낱말이 넷을 넘으면 그 칸을 다시 넷으로(깊이 6 미만일 때) 쪼개 두어, 새 낱말 상자는 자기가 걸친 칸 안의 상자하고만 견준다. 댓글 키워드 대시보드처럼 작은 낱말이 수백 개로 불어나는 판을 위한 방식이고, 아래 띠가 칸 쪽 검사 횟수와 하나씩 다 견줄 때의 횟수를 나란히 센다. 153개를 놓는 동안 검사는 140,332번, 하나씩 다 견줬다면 2,301,880번으로 16.4배 차이였고 잎 칸은 187개, 가장 깊은 칸은 5단이었다.

칸을 넷씩 쪼개 빨리 찾는 충돌 검사
새 창에서 열기

03 · 칸을 넷씩 쪼개 빨리 찾는 충돌 검사 — 측정 누적면적 63.024% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

QUAD_CAPACITYQUAD_MAX_DEPTHFILL_SIZES
    function quadHits(n, b, all) {
        if (!boxesTouch(n, b)) {
            return false;
        }
        if (n.kids) {
            for (let k = 0; k < 4; k += 1) {
                if (quadHits(n.kids[k], b, all)) {
                    return true;
                }
            }
            return false;
        }
        for (let i = 0; i < n.ids.length; i += 1) {
            const id = n.ids[i];
            if (marks[id] === markStamp) {
                continue;
            }
            marks[id] = markStamp;
            treeChecks += 1;
            if (boxesTouch(all[id], b)) {
                return true;
            }
        }
        return false;
    }

04하트 모양 안을 깊이 순으로 채우기

4px 칸 9,000개 가운데 하트 안쪽 3,516칸마다 가장자리까지의 거리를 두 번 훑어 미리 재 두고(이웃 +3·대각 +4 를 견준 뒤 4/3 을 곱해 px 로), 깊은 칸부터 큰 낱말을 앉힌다. 하트 바깥 화소를 판에 먼저 칠해 두니 겹침 검사 하나로 모양 안에만 놓이고, 기념일·감사 메시지 포스터처럼 워드 클라우드 모양 만들기가 필요한 자리라면 insideHeart 식만 다른 모양으로 바꾸면 될 것으로 보인다. 가장 깊은 곳은 106.7px 이고 앞 낱말 48개에 채우기 18개를 더한 66개에서 모양이 찼으며, 깊이가 모자란 뒤쪽 큰 낱말은 9.2px 까지 줄어들었다.

하트 모양 안을 깊이 순으로 채우기
새 창에서 열기

04 · 하트 모양 안을 깊이 순으로 채우기 — 측정 누적면적 20.323% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

HEART_SCALEDEPTH_CELLSHRINK_RATIO
        // 판 밖은 바깥(0)으로 친다
        const at = function (i, j) { return i < 0 || j < 0 || i >= GW || j >= GH ? 0 : d[j * GW + i]; };
        for (let j = 0; j < GH; j += 1) {
            for (let i = 0; i < GW; i += 1) {
                const c = j * GW + i;
                if (d[c] === 0) {
                    continue;
                }
                d[c] = Math.min(d[c], at(i - 1, j) + 3, at(i, j - 1) + 3, at(i - 1, j - 1) + 4, at(i + 1, j - 1) + 4);
            }
        }
        for (let j = GH - 1; j >= 0; j -= 1) {
            for (let i = GW - 1; i >= 0; i -= 1) {
                const c = j * GW + i;
                if (d[c] === 0) {
                    continue;
                }
                d[c] = Math.min(d[c], at(i + 1, j) + 3, at(i, j + 1) + 3, at(i + 1, j + 1) + 4, at(i - 1, j + 1) + 4);
            }
        }
        for (let c = 0; c < d.length; c += 1) {
            d[c] = d[c] * DEPTH_CELL / 3;
        }

05주제별로 무리 짓는 색 구름

낱말마다 붙은 주제 넷(일의 속도·함께하기·일하는 방식·배우고 자라기)에 기준점을 하나씩 정해 두고, 판 하나를 같이 쓰면서 각 낱말이 자기 주제 기준점에서 나선을 시작한다. 회의 회고 의견 분류처럼 무엇이 많았는지와 어느 갈래였는지를 함께 보여 줄 때 쓰며, 귀퉁이 넷의 이름표 자리를 먼저 막아 두고 막 놓인 낱말에서 기준점까지 실을 450ms 보여 준다. 48개가 주제별 12·12·13·11개로 줄이기 없이 모두 놓였고, 주제마다 첫 낱말은 기준점에서 0~1px 안에 앉았다.

주제별로 무리 짓는 색 구름
새 창에서 열기

05 · 주제별로 무리 짓는 색 구름 — 측정 누적면적 18.234% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

THEME_ANCHORSTHEME_COLORSLINK_MS
        for (let i = 0; i < THEME_LABELS.length; i += 1) {
            const b = labelBox(i);
            labels.push(b);
            blockRect(board, b[0] - 2, b[1] - 2, b[2] + 4, b[3] + 4);
        }
        placed = [];
        for (let i = 0; i < VOCAB.length; i += 1) {
            const e = VOCAB[i];
            const a = THEME_ANCHORS[e.theme];
            const p = placeEntry(board, e, sizeFor(e.count, top) * SIZE_SCALE, a[0], a[1], THEME_COLORS[e.theme], null);
            if (p) {
                placed.push(p);
            }
        }

06단어 거품이 서로 밀어 자리 잡기

거품 22개의 반지름을 횟수의 제곱근에 비례하게(50px 에서 가장 작은 20.4px 까지) 정하고, 틱마다 가운데로 끌어당긴 뒤 반지름 합에 3px 을 더한 거리보다 가까운 두 거품을 모자란 거리의 0.45배만큼 세 번 밀어 떼어 놓는다. 상대 넓이 비율로 나눠 밀어 작은 거품이 더 많이 비켜서니, 트렌드 키워드 순위 보드라면 순위가 높은 큰 거품이 덜 밀려 가운데 남는 것으로 보인다. 겹친 거리 합은 첫 바퀴에 하나씩 부푸는 동안 1,504ms 에 143.6px 로 가장 컸고, 다 부푼 뒤에는 약 25px 로 가라앉았다.

단어 거품이 서로 밀어 자리 잡기
새 창에서 열기

06 · 단어 거품이 서로 밀어 자리 잡기 — 측정 누적면적 44.077% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PULLPUSHRELAX_PASSES
        for (let i = 0; i < bubbles.length; i += 1) {
            const b = bubbles[i];
            b.vx = (b.vx + (W / 2 - b.x) * PULL) * DAMPING;
            b.vy = (b.vy + (H / 2 - b.y) * PULL * PULL_Y) * DAMPING;
            b.x += b.vx;
            b.y += b.vy;
            b.press = 0;
        }
                    const dx = b.x - a.x;
                    const dy = b.y - a.y;
                    const d = Math.hypot(dx, dy) || 0.01;
                    const gap = ra + rb + BUBBLE_GAP - d;
                    if (gap <= 0) {
                        continue;
                    }
                    if (pass === 0) {
                        overlapSum += gap;
                        a.press += gap;
                        b.press += gap;
                    }
                    const nx = dx / d;
                    const ny = dy / d;
                    // 작은 거품이 더 많이 비킨다 — 내 몫 = 상대 넓이 / 둘의 넓이 합
                    const share = (rb * rb) / (ra * ra + rb * rb);
                    a.x -= nx * gap * PUSH * share;
                    a.y -= ny * gap * PUSH * share;
                    b.x += nx * gap * PUSH * (1 - share);
                    b.y += ny * gap * PUSH * (1 - share);

07공 모양으로 도는 3D 단어 구

낱말 42개를 높이는 고르게 나누고 경도는 한 번에 황금각 137.51°씩 돌려 구 위에 흩으면, 가장 가까운 이웃 거리가 반지름 1 기준 0.477~0.525 로 고르게 퍼진다. 초당 31.5° 로 돌리면서 배율 3.2 ÷ (3.2 + z) 로 앞쪽 글자는 1.455배, 뒤쪽은 0.762배로 그리고, 먼 것부터 칠해 앞 글자가 뒤 글자를 덮게 한다. 포트폴리오 기술 스택 소개처럼 낱말이 수십 개로 정해진 자리에 맞고, 포인터를 올리면 가상 포인터 대신 실제 포인터 쪽으로 최대 34.4° 까지 기운다.

공 모양으로 도는 3D 단어 구
새 창에서 열기

07 · 공 모양으로 도는 3D 단어 구 — 측정 누적면적 31.059% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

GOLDEN_ANGLECAMERA_DISTPOINTER_SCHEDULE
    function spherePoint(i, n) {
        const y = 1 - (i + 0.5) * 2 / n;
        const r = Math.sqrt(Math.max(0, 1 - y * y));
        const phi = i * GOLDEN_ANGLE;
        return [Math.cos(phi) * r, y, Math.sin(phi) * r];
    }
    function project(ux, uy, uz) {
        const a = yaw + tiltY;
        const x1 = ux * Math.cos(a) - uz * Math.sin(a);
        const z1 = ux * Math.sin(a) + uz * Math.cos(a);
        const y2 = uy * Math.cos(tiltX) - z1 * Math.sin(tiltX);
        const z2 = uy * Math.sin(tiltX) + z1 * Math.cos(tiltX);
        const k = CAMERA_DIST / (CAMERA_DIST + z2);
        return [W / 2 + x1 * SPHERE_R * k, H / 2 - y2 * SPHERE_R * k, z2, k];
    }

08바람에 살랑이는 단어 배경

나선으로 놓은 낱말 40개가 자리는 그대로 둔 채, 낱말 가운데 좌표에서 2D 값 노이즈를 읽어 기울기와 떠오름을 정하고 노이즈 판을 시간에 따라 옆으로 밀어 바람결처럼 이어지게 하고, 이 바람 시각은 24,000ms 마다 0 으로 돌아가 노이즈 판이 처음 자리부터 다시 흐른다. 돌풍 띠가 초속 190px 로 왼쪽에서 오른쪽으로 한 번 지나는 데 3,789ms 가 걸리고, 띠가 지나는 낱말은 더 크게 흔들리며 크림 쪽으로 밝아진다. 랜딩 페이지 히어로 배경에 깔기 좋은 판으로, 4초 동안 40개를 잰 최대 기울기는 8.6°, 최대 떠오름은 5.7px 였다.

바람에 살랑이는 단어 배경
새 창에서 열기

08 · 바람에 살랑이는 단어 배경 — 측정 누적면적 29.658% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

NOISE_SCALESWAY_MAXGUST_SPEED
    function noise2(x, y) {
        const ix = Math.floor(x);
        const iy = Math.floor(y);
        const fx = x - ix;
        const fy = y - iy;
        const u = fx * fx * (3 - 2 * fx);
        const v = fy * fy * (3 - 2 * fy);
        return lerp(lerp(lattice(ix, iy), lattice(ix + 1, iy), u), lerp(lattice(ix, iy + 1), lattice(ix + 1, iy + 1), u), v);
    }
            const gust = gustAt(cx, ts);
            const g = gust * calm;
            const n1 = noise2(cx * NOISE_SCALE - ts * WIND_SPEED, cy * NOISE_SCALE);
            const n2 = noise2(cx * NOISE_SCALE + 17.3, cy * NOISE_SCALE - ts * WIND_SPEED * 0.8);
            // 돌풍이 지나는 낱말은 크림 쪽으로 살짝 밝아진다
            const glow = calm * (gust - GUST_BASE) / (1 - GUST_BASE);
            drawPlaced(p, 1, 1, n1 * SWAY_MAX * g, (n2 * 0.5 + 0.5) * LIFT_MAX * g, mixRGB(p.color, CREAM, 0.35 * clamp(glow, 0, 1)));

09투표가 들어올 때마다 자라는 실시간 단어판

선택지 낱말 14개가 표를 받을 때마다 가야 할 크기를 다시 매기고, 지금 크기는 틱마다 남은 차의 12% 씩 다가가며 커진다. 커진 상자가 이웃과 겹치면 표가 적은 쪽(같으면 뒤 번호)만 나선 700걸음 안에서 처음 만나는 빈자리로 목표를 옮기고 스프링으로 따라가게 해, 강연·수업 실시간 투표 화면에서 나머지 낱말은 제자리를 지킨다. 일정표 한 바퀴 동안 1위가 304ms 소통·2,304ms 마감·2,560ms 협업·3,312ms 마감으로 네 번 바뀌는 사이 비켜선 일은 54번이었고, 판 전체를 다시 배치한 적은 없다.

투표가 들어올 때마다 자라는 실시간 단어판
새 창에서 열기

09 · 투표가 들어올 때마다 자라는 실시간 단어판 — 측정 누적면적 10.128% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

REFLOW_STIFFNESSSIZE_LERPVOTE_GAP_MS
    function relocate(i) {
        const o = options[i];
        for (let s = 1; s < SEARCH_LIMIT; s += 1) {
            const p = spiralAt(s);
            const r = rectOf(o, o.tx + p[0], o.ty + p[1], o.goal);
            if (fits(i, r)) {
                o.tx += p[0];
                o.ty += p[1];
                moves += 1;
                return true;
            }
        }
        return false;
    }
        for (let i = 0; i < options.length; i += 1) {
            for (let j = i + 1; j < options.length; j += 1) {
                const a = options[i];
                const b = options[j];
                if (!touch(rectOf(a, a.tx, a.ty, a.goal), rectOf(b, b.tx, b.ty, b.goal))) {
                    continue;
                }
                relocate(b.votes > a.votes ? i : j);
            }
        }
        for (let i = 0; i < options.length; i += 1) {
            const o = options[i];
            o.vx = (o.vx + (o.tx - o.x) * REFLOW_STIFFNESS) * REFLOW_DAMPING;
            o.vy = (o.vy + (o.ty - o.y) * REFLOW_STIFFNESS) * REFLOW_DAMPING;
            o.x += o.vx;
            o.y += o.vy;
        }

워드 클라우드 배치가 깨지는 세 장면 — 판 끝에 걸린 긴 단어·늦게 불러온 글꼴·수백 개로 늘어난 단어

판 끝에 걸린 긴 단어

나선은 가로로 늘어난 채 판 대각선 절반까지 돌기 때문에 후보 자리 상당수가 판 가장자리에 걸치거나 밖으로 나간다. collides 가 상자가 판 밖으로 한 화소라도 나가면 겹친 것으로 치는 이유가 여기 있다. 그 검사가 없으면 긴 낱말이 판 끝에서 잘린 채 놓인다. 자리를 끝내 못 찾았을 때는 placeEntry 가 0.85배로 줄여 처음부터 다시 찾고, 8px 아래로 내려가면 버린다.

    function placeEntry(board, e, size, cx, cy, color, trail) {
        let s = size;
        let shrinks = 0;
        while (s >= SIZE_FLOOR) {
            const sp = makeSprite(e.text, s);
            if (trail) {
                trail.length = 0;
            }
            const at = searchSpiral(board, sp, cx, cy, trail);
            if (at) {
                stamp(board, sp, at[0], at[1]);
                return { text: e.text, count: e.count, theme: e.theme, size: s, x: at[0], y: at[1], w: sp.w, h: sp.h, dx: sp.dx, dy: sp.dy, color: color, shrinks: shrinks };
            }
            s *= SHRINK_RATIO;
            shrinks += 1;
            shrinkCount += 1;
        }
        dropCount += 1;
        return null;
    }

빈 판 가운데에서 「커뮤니케이션 피드백 회의」를 90px 로 놓아 보면 다섯 번 줄어 39.9px·폭 461px 로 겨우 들어가고, 「collaboration」은 한 번 줄어 76.5px·폭 478px 에 놓였다. 50px 로 놓은 「collaboration」은 줄지 않고 폭 314px 그대로였다. 데모의 기본 배치에서는 01·02·05·08 이 한 번도 줄이지 않았고, 04 만 하트 깊이가 모자라 뒤쪽 큰 낱말이 9.2px 까지 줄었다.

늦게 불러온 글꼴

비트 칸은 낱말을 한 번 그려 만든 그림이라, 칸을 만든 뒤 글꼴이 바뀌면 화면 글자와 칸이 어긋난다. 같은 낱말을 50px 로 데모 글꼴과 대체 serif 글꼴로 재 보면 「협업」은 95px 대 94px, 「피드백」은 147px 대 148px 로 1px 차이지만, 「collaboration」은 314px 대 324px, 「フィードバック」은 332px 대 343px 로 10~11px 이 벌어졌다. 그래서 아홉 판 모두 document.fonts.ready 가 끝난 뒤에 배치를 시작하고, 그 뒤에 새 글꼴이 들어와 loadingdone 이 오면 글자 폭을 다시 재서 배치를 새로 만든다. 데모 글꼴은 기기에 깔린 글꼴 목록이라 내려받는 글꼴이 없지만, 웹 글꼴을 붙여 쓴다면 이 순서가 더 중요해질 것으로 보인다.

수백 개로 늘어난 단어

하나씩 다 견주는 방식은 놓인 낱말이 늘수록 새 낱말 하나에 드는 비교도 같이 는다. 03 의 153개에서 16.4배였던 격차는 크기를 줄여 238개까지 채우자 473,909번 대 17,821,535번으로 37.6배가 됐다. 낱말이 1.6배 늘 때 격차는 두 배 넘게 벌어진 셈이라, 수백 개를 다루는 판이면 03 의 칸 나누기나 02 의 비트 칸처럼 견줄 대상을 줄이는 장치가 먼저다.

이 세 함정을 막는 검사가 들어간 아홉 판의 소스와 같은 소스로 만든 리액트 판은 한 압축 파일에 묶었고, 파일을 열 때 p49vy6me 을 넣으면 된다.

나선을 촘촘히, 크기 차를 크게, 스프링을 부드럽게 — 값 하나씩 바꾼 세 판

이름 바꾼 값 느낌
나선을 더 촘촘히 02 SPIRAL_STEP 0.35 → 0.15 한 바퀴에 넓어지는 반지름이 21.99px 에서 9.42px 로 줄어 걸음이 9,007 에서 19,547 로 늘고, 같은 글자가 383×221px 대신 360×211px 안에 모여 테두리 안 글자 비율이 38.9% 에서 43.3% 로 오른다
크기 차를 더 크게 01 SIZE_EXPONENT 0.5 → 0.8 1위 상자 몫이 13.5% 에서 19.0% 로 커지고 1회 낱말은 14.4px 에서 바닥값 10px 로 내려가며, 칠해진 화소는 20,224 에서 14,534 로 준다. 16개가 다 들어가는 것은 같다
스프링을 더 부드럽게 09 REFLOW_STIFFNESS 0.18 → 0.08 목표를 40px 옮겼을 때 ±0.5px 안에 처음 드는 시각이 224ms 에서 384ms 로 늦어지는 대신, 지나침이 9.52px 에서 3.15px 로 줄고 한 번 든 뒤 다시 나가지 않아 끝까지 머무는 시각은 432ms 에서 384ms 로 앞당겨진다

세 판이 잃는 것은 저마다 다르다. 02 는 촘촘해진 만큼 시연 한 바퀴가 12.0초에서 17.8초로 길어지고, 01 의 0.8 은 넓이 비례가 깨져 크기² ÷ 횟수가 1위 208.3 에서 끝 71.1 로 벌어지니 1위가 실제 횟수 차이보다 커 보인다. 09 는 처음 반응이 늦는 대신 덜 출렁이므로, 표가 몰려 들어오는 투표판이라면 0.08 쪽이 읽기 편할 것으로 보인다.

접근성 — 화면 낭독기가 따라가는 빈도 순 목록과 움직임 줄이기에서 멈춘 배치

캔버스 옆에 숨긴 빈도 순 목록

캔버스에 그린 글자는 화면 낭독기가 읽지 못하므로, 아홉 판 모두 옆에 눈에 보이지 않는 <ol> 목록을 두고 「낱말 — 12회」 꼴로 순위대로 적는다. 1px 상자에 clip-path: inset(50%) 를 건 숨김이라 display: none 과 달리 낭독기에는 남고, 순위나 횟수가 바뀐 때만 <li> 를 다시 쓴다. 목록 길이는 01 16개·02 36개·03~05 48개·06 22개·07 42개·08 40개·09 14개다. 캔버스 자체는 role="img" 에 장면 설명과 「가장 많이 나온 말」 1~3위를 붙인 aria-label 을 달고 순위가 바뀌면 함께 고친다(09 는 「지금 표가 많은 순」).

움직임 줄이기에서 그리는 마지막 한 장면

움직임 줄이기 설정(prefers-reduced-motion)이 켜져 있으면 아홉 판 모두 프레임 루프를 시작하지 않고, 배치를 끝까지 계산한 마지막 한 장면만 그린다. 01~05 는 모든 낱말이 자리 잡은 화면, 06 은 화면에 그리지 않고 600틱을 돌려 가라앉힌 거품 덩어리, 07 은 0.7라디안에서 기울지 않은 구, 08 은 바람이 멎은 판, 09 는 56표를 다 넣은 최종 집계(마감 16·협업 12·소통 12·피드백 8표)다. 페이지를 연 채 설정을 바꿔도 change 리스너가 바로 반영하고, 포인터 말풍선과 09 의 버튼은 이 상태에서도 그대로 쓸 수 있다.

1위가 바뀔 때만 말하는 09 의 알림 줄

09 의 알림 줄은 자동 시연 동안 aria-live="off" 로 두어 125ms 마다 들어오는 표를 읽어 대지 않게 하고, 사람이 처음 포인터를 올리거나 버튼을 누르면 polite 로 바꾼다. 그 뒤에도 표마다가 아니라 1위가 바뀐 때만 「1위가 바뀌었어요: 마감」 꼴로 문장을 고친다. 버튼은 진짜 <button> 이라 Tab 으로 닿아 Enter 로 한 표씩 넣을 수 있고, 포커스 윤곽은 2px 다.

회의 투표판이나 보고서에 워드 클라우드를 올릴 때 자주 묻는 질문

워드클라우드 이미지로 저장해 보고서에 붙일 수 있나?

데모에는 저장 버튼이 없다. 움직임 줄이기 때처럼 finalScene() 으로 배치가 끝난 장면을 그린 다음 캔버스를 이미지로 내보내면 한 장을 얻을 수 있고, 캔버스의 실제 화소는 표시 크기에 기기 배율(최대 2)을 곱한 만큼이다. 수치가 중요한 보고서라면 숨긴 빈도 순 목록을 표로 옮겨 그림 옆에 같이 두는 편이 읽는 사람에게 정확하다.

횟수가 같은데 어떤 낱말이 더 커 보이는 이유는?

크기가 정하는 것은 글자 크기라서, 글자 수가 많은 말은 같은 크기여도 상자가 더 넓다. 50px 에서 「협업」은 폭 95px, 「피드백」은 147px 이고, 01 의 같은 답변을 영어 낱말로 바꾸면 횟수는 그대로인데 칠해진 화소가 20,224 에서 35,109 로 늘었다. 넓이를 횟수에 정확히 맞추려면 글자 상자 넓이에서 크기를 거꾸로 구해야 하는데, 이 데모는 글자 크기를 기준으로 삼는다.

단어가 몇 개까지면 나선과 비트 칸만으로 충분한가?

02 처럼 낱말 수십 개를 놓는 판은 36개에 나선 9,007걸음이면 끝난다. 03 실측처럼 낱말이 153개에서 238개로 늘면 하나씩 견주는 비용이 230만 번에서 1,782만 번으로 뛰니, 백 개를 넘기는 판은 칸 나누기를 붙이는 쪽이 낫다. 점과 선으로 이어진 그래프를 끌어당기고 밀어내 자리 잡게 하는 쪽은 네트워크 그래프 9종 에서, 같은 대시보드 줄기의 막대 그래프는 막대 그래프 만들기 9종 에서 이어 볼 수 있다.

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

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