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

정렬 알고리즘 시각화 9종 — 맞바꾸는 막대부터 색상환 원형 정렬까지

정렬은 코드로 읽으면 외워지지 않고 움직임으로 보면 이해된다. 04번은 기준 막대를 세워 양쪽으로 쓸어 나누고, 07번은 같은 배열로 네 알고리즘의 비교 횟수를 겨루며, 06번은 색 조각을 색상환 순서로 돌려 세운다.

정렬은 값이 아니라 움직임으로 이해한다 — 정렬 알고리즘 시각화는 막대 높이를 값으로 놓고 비교와 교환을 한 걸음씩 그리는 데서 시작한다

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

아홉 장면은 같은 규칙으로 만들었다. 정렬을 진짜로 한 번 돌려서 비교·교환·쓰기 같은 걸음을 목록으로 적어 두고, 화면은 그 목록을 시간표대로 한 칸씩 읽어 그린다. 막대 높이도 계수기의 숫자도 적용한 걸음에서만 나오니, 화면이 보여 주는 것은 연출이 아니라 계산된 정렬 과정이다.

막대를 그리는 쪽과 정렬하는 쪽을 나눈 이유

정렬하는 쪽은 bubbleSteps·mergeSteps·quickSteps 처럼 배열을 받아 걸음 목록을 돌려주는 순수 함수이고, 그리는 쪽은 그 목록을 받아 적용만 한다. 그래서 정렬 알고리즘 애니메이션을 바꿀 때 막대 그리기 코드는 그대로 두고 걸음 생성 함수만 갈아 끼우면 된다. 07 은 이 구조를 네 번 겹쳐 같은 배열에서 서로 다른 걸음 목록 네 줄을 동시에 재생한다.

소리 없이 막대 높이로 읽는다

정렬을 소리로 들려 주는 버전을 찾아 왔더라도 이 편은 오디오를 쓰지 않고 막대 높이와 계수기 문장으로 값을 전한다.

장면마다 얹은 계산 한 겹

01 은 스프링 교환과 잔상, 02 는 낮은 진동수 스프링의 관성, 03 은 층별 곡선 비행, 04 는 기준 높이 점선을 더했다. 05 는 트리와 막대를 동시에 보여 주고, 06 은 색상환 조각을 돌리며, 07 은 네 줄의 실측 계수를 세운다. 08 은 중력과 바닥 튕김, 09 는 진행률을 따라 밝아지는 하늘을 얹었다.

정렬 알고리즘 시각화 9종 — 이웃끼리 맞바꾸는 버블 막대·틈에 끼우는 삽입·반으로 갈라 엮는 병합·기준을 세워 쓸어 나누는 퀵·트리에서 내려 보내는 힙·색상환을 돌려 세우는 원형·네 방식을 겨루는 계량기·통에 붓는 기수·스스로 정렬되는 스카이라인

아홉 장면을 렌더해서 얻은 면적·프레임·강도

장면 누적면적 움직인 프레임 강도 평균
01 버블 막대 29.6% 23/23 66.6
02 삽입 25.5% 23/23 65.8
03 병합 10.0% 23/23 84.7
04 퀵 22.6% 22/23 52.8
05 힙 11.9% 22/23 60.9
06 색상환 14.8% 21/23 52.3
07 경주 계량기 12.7% 23/23 76.4
08 기수 28.3% 23/23 60.3
09 스카이라인 70.8% 23/23 61.2

하늘과 건물 전체가 바뀌는 09 가 70.8% 로 가장 넓고, 층으로 나뉜 줄 중 일부만 바뀌는 03 이 10.0% 로 가장 좁다. 강도 평균은 03 이 84.7 로 가장 높고 06 이 52.3 으로 가장 낮으며, 04·05 는 22/23, 06 은 21/23 이라 이 세 장면에서는 변화가 없는 프레임이 끼어 있다.

01이웃 막대가 자리를 맞바꾸는 버블 정렬 막대 추적

정렬 학습 사이트의 첫 화면에 두면 맞바꾸는 두 막대가 서로의 자리를 건너가는 장면이 바로 보인다. bubbleSteps 가 만든 걸음 중 교환(SWP) 걸음만 row 배열을 실제로 바꾸고, 화면의 sx 는 spr 스프링으로 새 자리를 뒤쫓는다. 오른쪽으로 건너가는 막대는 lift 로 살짝 떠올라 마주 오는 막대와 겹치지 않고, 지나간 자리에는 TRAIL_S 동안 옅은 잔상이 남는다.

이웃 막대가 자리를 맞바꾸는 버블 정렬 막대 추적
새 창에서 열기

01 · 이웃 막대가 자리를 맞바꾸는 버블 정렬 막대 추적 — 측정 누적면적 29.642% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BAR_COUNTSWAP_TRAVEL_MSbubbleSteps
    function update(dt) {
        trailClock += dt;
        for (let p = 0; p < BAR_COUNT; p += 1) {
            const id = row[p];
            spr(sx[id], slotX(p), dt, SPRING_W, SPRING_Z);
            // 오른쪽으로 건너가는 막대는 살짝 떠올라 건너오는 막대와 겹치지 않는다
            const away = slotX(p) - sx[id].x;
            spr(lift[id], away > 1 ? -Math.min(14, away * 0.35) : 0, dt, 22, 0.8);
            flash[id] = Math.max(0, flash[id] - dt * 2.4);
            const tr = trails[id];
            for (const t of tr) {
                t.age += dt;
            }
            while (tr.length > 0 && tr[0].age > TRAIL_S) {
                tr.shift();
            }
            if (Math.abs(slotX(p) - sx[id].x) > 2.5 && trailClock > 0.012) {
                tr.push({ x: sx[id].x, y: lift[id].x, age: 0 });
            }
        }
        if (trailClock > 0.012) {
            trailClock = 0;
        }
    }

02한 장을 들어 올려 알맞은 틈에 끼우는 삽입 정렬

카드 정리나 우선순위 목록 재배치 화면에서 쓰는 삽입 정렬이다. LIFT 걸음이 막대 하나를 LIFT_H 만큼 들어 올리고, 왼쪽의 큰 막대는 SHF 걸음마다 한 칸씩 밀려나며, DROP 이 비어 있는 틈에 내려 꽂는다. 밀리는 막대는 SHIFT_W 19 의 낮은 진동수 스프링이라 자리를 조금 늦게 뒤따르고, 들어 올린 막대는 34 로 틈 위를 더 빠르게 따라간다.

한 장을 들어 올려 알맞은 틈에 끼우는 삽입 정렬
새 창에서 열기

02 · 한 장을 들어 올려 알맞은 틈에 끼우는 삽입 정렬 — 측정 누적면적 25.461% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LIFT_HSHIFT_WinsertionSteps
    function update(dt) {
        for (let p = 0; p < BAR_COUNT; p += 1) {
            if (row[p] >= 0) {
                spr(sx[row[p]], slotX(p), dt, SHIFT_W, SHIFT_Z);
            }
        }
        for (let id = 0; id < BAR_COUNT; id += 1) {
            if (id === liftId) {
                spr(sx[id], slotX(holeAt), dt, 34, 0.7);
            }
            spr(ly[id], id === liftId ? -LIFT_H : 0, dt, 30, 0.6);
        }
    }

03반으로 갈라 두 줄을 엮어 합치는 병합 정렬

두 목록을 날짜순으로 합치는 기능을 설명할 때 어울리는 병합 정렬이다. SPLIT 걸음 4번이 5개 층(LEVELS)을 차례로 열고, 합칠 때는 CMP 로 두 줄의 맨 앞을 견준 뒤 작은 쪽을 SET 으로 위층에 쓴다. 쓰인 막대는 FLY_S 동안 두 점 사이를 곡선으로 날아가며, 곡선의 제어점은 두 칸보다 26px 위에 있다.

반으로 갈라 두 줄을 엮어 합치는 병합 정렬
새 창에서 열기

03 · 반으로 갈라 두 줄을 엮어 합치는 병합 정렬 — 측정 누적면적 10.017% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FLY_SLEVELSmergeSteps
        // 날아가는 막대 — 아래층 칸에서 위층 칸으로 위로 볼록한 곡선
        for (const f of flights) {
            const k = ease(f.t);
            const mx = (f.x0 + f.x1) / 2;
            const my = Math.min(f.y0, f.y1) - 26;
            const x = (1 - k) * (1 - k) * f.x0 + 2 * (1 - k) * k * mx + k * k * f.x1;
            const y = (1 - k) * (1 - k) * f.y0 + 2 * (1 - k) * k * my + k * k * f.y1;
            drawBar(x + 2, y, f.v, 1, true);
        }

04기준 막대를 세우고 양쪽으로 쓸어 나누는 퀵 정렬

검색 결과를 빠르게 나눠 정렬하는 과정을 설명하기에 맞는 퀵 정렬이다. 구간 맨 앞 막대가 PIV 걸음에서 PIVOT_LIFT 만큼 솟아 기준이 되고, 두 포인터가 서로를 향해 쓸면서 어긋난 쌍을 교환하다가 만나면 기준을 내려놓는다. 기준 막대의 윗면 높이에는 구간 폭만큼 점선이 그어져 포인터가 견주는 눈금이 된다.

기준 막대를 세우고 양쪽으로 쓸어 나누는 퀵 정렬
새 창에서 열기

04 · 기준 막대를 세우고 양쪽으로 쓸어 나누는 퀵 정렬 — 측정 누적면적 22.576% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PIVOT_LIFTSWAP_MSquickSteps
        // 기준 높이 점선 — 포인터가 이 선과 막대 윗면을 견준다
        if (pivId >= 0) {
            const py = BASE_Y - barH(vals[pivId]);
            ctx.setLineDash([5, 4]);
            ctx.lineWidth = 1.5;
            ctx.strokeStyle = rgba(INK, 0.5);
            ctx.beginPath();
            ctx.moveTo(slotX(rangeLo), py);
            ctx.lineTo(slotX(rangeHi + 1), py);
            ctx.stroke();
            ctx.setLineDash([]);
        }

05이진 트리 위에서 큰 값을 내려 보내는 힙 정렬

우선순위 큐로 작업을 뽑는 스케줄러를 설명할 때 쓰는 힙 정렬이다. 같은 값이 트리 노드와 막대 열 두 곳에 있고 선으로 이어져 있으며, 위치마다 tx·ty·bx 스프링 셋이 따로 뒤쫓아 노드가 트리에서 내려가는 동안 막대도 열 안에서 같이 움직인다. 트리 자리는 treeX 가 층마다 2^l 칸으로 폭을 똑같이 나눠 준다.

이진 트리 위에서 큰 값을 내려 보내는 힙 정렬
새 창에서 열기

05 · 이진 트리 위에서 큰 값을 내려 보내는 힙 정렬 — 측정 누적면적 11.944% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

COUNTtreeXheapSteps
    // 위치 p 의 트리 좌표 — 층 l 은 2^l 개 칸으로 폭을 똑같이 나눈다
    function treeX(p) {
        const l = Math.floor(Math.log2(p + 1));
        const k = p + 1 - Math.pow(2, l);
        return (W / Math.pow(2, l)) * (k + 0.5);
    }
    function treeY(p) { return 64 + Math.floor(Math.log2(p + 1)) * 34; }
    function barX(p) { return 21 + p * 29.5 + 12; }

06뒤섞인 색 조각을 색상환 순서로 돌려 세우는 원형 정렬

색상 팔레트 정렬 도구나 배경 장식에 쓰는 원형 정렬이다. 색조 값이 원 둘레의 조각이 되고 selectionSteps 가 남은 구간의 최솟값을 훑어 맞바꾸는데, 이 훑기만 비교가 276번이다. 교환하는 조각은 자리에서 떨어진 각도에 따라 popOf 가 최대 RING_POP 24px 까지 안쪽으로 들여보내 건너가는 동안에만 고리에서 어긋나 보인다.

뒤섞인 색 조각을 색상환 순서로 돌려 세우는 원형 정렬
새 창에서 열기

06 · 뒤섞인 색 조각을 색상환 순서로 돌려 세우는 원형 정렬 — 측정 누적면적 14.782% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

RING_POPpopOfselectionSteps
    // 조각이 자기 자리에서 떨어진 각도에 비례해 안쪽으로 들어간다 — 건너가는 동안만 튀어나온 것처럼 보인다
    function popOf(id, p) {
        return RING_POP * ease(Math.abs(wrapPi(slotAngle(p) - ang[id].x)) / 0.6);
    }
    function update(dt) {
        for (let p = 0; p < COUNT; p += 1) {
            const id = row[p];
            const target = ang[id].x + wrapPi(slotAngle(p) - ang[id].x);
            spr(ang[id], target, dt, SPRING_W, SPRING_Z);
        }
    }

07같은 배열로 네 알고리즘의 비교·이동 횟수를 겨루는 경주 계량기

알고리즘 선택 가이드의 성능 비교 표를 눈으로 보여 줄 때 쓰는 계량기다. 같은 14개 배열을 거품·삽입·병합·퀵 네 줄에 복사해 STEP_MS 마다 한 걸음씩 동시에 적용하고, 줄마다 cnt.c(비교)와 cnt.m(이동)을 올린다. 올리는 곳은 laneStep 한 군데이며, 비교·교환·밀기·쓰기를 적용한 걸음에서만 세고 표식 걸음은 시간 없이 지나간다.

같은 배열로 네 알고리즘의 비교·이동 횟수를 겨루는 경주 계량기
새 창에서 열기

07 · 같은 배열로 네 알고리즘의 비교·이동 횟수를 겨루는 경주 계량기 — 측정 누적면적 12.707% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STEP_MScountOfapplyLane
    function laneStep(l, s) {
        if (s.k === CMP) {
            cnt[l].c += 1;
            hot[l] = [s.a, s.b];
        }
        else if (s.k === SWP || s.k === SET || s.k === MOV || s.k === SHF) {
            cnt[l].m += 1;
            hot[l] = [s.a, s.b];
        }
        else if (s.k === FIX) {
            sortedFx[l][s.a] = true;
        }
        applyLane(lanes[l], s);
    }

08자리 숫자별 통에 붓고 다시 모으는 기수 정렬

우편번호나 사번처럼 자릿수가 있는 값을 분류하는 장면에 맞는 기수 정렬이다. radixSteps 가 자리마다 DRP 로 공을 통에 붓고 TAK 으로 0번 통부터 꺼내므로 값끼리 비교는 한 번도 없다. 떨어지는 공은 GRAVITY 900px/초² 로 가속하고 바닥에 닿을 때 속도가 70px/초를 넘으면 BOUNCE 0.38 배로 튕겨 오른다.

자리 숫자별 통에 붓고 다시 모으는 기수 정렬
새 창에서 열기

08 · 자리 숫자별 통에 붓고 다시 모으는 기수 정렬 — 측정 누적면적 28.308% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

GRAVITYBOUNCEradixSteps
            if (b.mode === 1) {
                b.vy += GRAVITY * dt;
                b.y += b.vy * dt;
                if (b.y >= ry) {
                    b.y = ry;
                    if (b.vy > 70) {
                        b.vy = -b.vy * BOUNCE;
                    }
                    else {
                        b.vy = 0;
                        b.mode = 2;
                    }
                }
            }

09무너진 도시 윤곽이 스스로 정렬돼 계단이 되는 히어로

개발 교육 서비스의 랜딩 첫 화면에 얹는 히어로 장면이다. hybridSteps 가 4칸 묶음은 삽입으로, 묶음끼리는 병합으로 건물을 옮기고(MOV), 하늘의 sky 는 at / 걸음 수 로 진행률을 한 값에 담아 밤색에서 새벽색·별·해가 그 값을 따라간다. 창문 불빛이 켜진 채 키 순서 계단 윤곽이 완성되면 한 판이 끝난다.

무너진 도시 윤곽이 스스로 정렬돼 계단이 되는 히어로
새 창에서 열기

09 · 무너진 도시 윤곽이 스스로 정렬돼 계단이 되는 히어로 — 측정 누적면적 70.819% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

RUNMOVE_MShybridSteps
        const t = ease(clamp(sky.x, 0, 1));
        skyFill(mix(NIGHT_TOP, DAWN_TOP, t), mix(NIGHT_BOT, DAWN_BOT, t));
        // 별 — 정렬이 진행될수록 사라진다
        for (const st of stars) {
            const a = clamp(1 - t * 1.5, 0, 1) * (0.5 + 0.5 * Math.sin(sceneT * 1.4 + st[3] * TAU));
            disc(st[0], st[1], st[2], PAPER, a * 0.85);
        }
        // 해 — 진행률 따라 아래에서 올라온다
        const sy = lerp(318, 112, t);
        disc(388, sy, 62, SUN, 0.18 * t);
        disc(388, sy, 40, SUN, 0.3 * t);
        disc(388, sy, 24, SUN, 0.95 * Math.min(1, t * 1.6));

정렬 알고리즘 시각화는 막대가 몇 개, 한 걸음이 몇 밀리초여야 읽힐까?

수업에서 칠판 대신 쓸 때

자료구조 수업에서는 같은 알고리즘을 몇 번 되돌려 보는 것이 중요해서 일시정지와 한 걸음 버튼이 쓸모 있다. 퀵 정렬 시각화는 04 처럼 기준 막대가 어디서 내려앉는지 한 걸음씩 확인할 수 있어야 해서 STEP_MS 75 와 막대 18개로 잡았다. 재생을 멈추고 한 걸음씩 넘기면 계수기도 그 걸음까지만 올라가 있어 학생이 직접 센 값과 맞춰 볼 수 있다.

면접 준비에서 손으로 따라 쓸 때

면접 준비로 정렬을 외우는 중이라면 07 을 틀어 두고 알고리즘마다 비교와 이동이 어떻게 갈리는지 눈으로 먼저 익힌 뒤 손으로 따라 쓰는 순서가 편하다. 07 의 같은 열 14개를 네 줄이 받았을 때와 각 장면의 첫 판 실측은 아래 표와 같다.

알고리즘 막대 수 비교 이동
07 거품 14 90 교환 52
07 삽입 14 62 밀기 52
07 병합 14 39 쓰기 54
07 퀵 14 38 교환 14
01 버블 16 119 교환 56
02 삽입 14 59 밀기 50
03 병합 16 44 쓰기 64
04 퀵 18 71 교환 16
05 힙 15 72 교환 47
06 선택 24 276 교환 20
08 기수 24 0 붓기 72 · 꺼내기 72
09 혼합 16 51 이동 23

같은 14개 열에서 비교는 거품 90에서 퀵 38까지, 이동은 퀵 14에서 병합 54까지 갈린다. 막대 수가 다른 행끼리는 직접 견줄 수 없고, 06 의 선택 정렬은 24개에 비교 276을 써서 훑는 방식이 비교를 얼마나 쓰는지 보여 준다. 08 은 값끼리 비교하지 않고 붓기와 꺼내기만 하니 비교 칸이 0이다.

막대 수와 걸음 간격의 선

01 의 비교 횟수는 막대 수 n 에 대해 n(n−1)/2 가 상한이라 16개에서는 120, 48개에서는 1,128 이고 실측은 119와 1,107이다. 막대 폭은 (480−32)/n 이어서 16개에서 28px, 48개에서 약 9px 가 되고, 값 글자는 폭이 16px 이상일 때만 그려서 막대를 늘리면 숫자가 사라진다. 걸음 간격은 걸음 종류마다 따로 둔다. 비교가 276번인 06 은 SCAN_MS 18 로 짧게 훑고 교환에는 420ms 를 줘서 훑기는 빠르게, 교환은 눈에 남게 했다.

막대 열을 만들고, 비교와 교환을 걸음 목록으로 기록하고, 한 걸음씩 재생한다 — 정렬 하나가 눈에 보이기까지의 순서

화면과 계수기를 한 곳에서 나오게 하는 이유가 하나다. 걸음을 만들 때 세지 않고 적용할 때 세면, 일시정지·한 걸음·판 중간에서 시작하는 첫 장면이 모두 같은 숫자를 낸다.

걸음을 적용할 때만 센다

runOne 이 걸음 하나를 꺼내 countStep 으로 비교와 이동을 세고 applyOne 으로 화면 상태를 바꾼 뒤 at 을 하나 올린다. 비교는 CMP, 이동은 SWP·SET·MOV·SHF 이고, 확정 표식(FIX)이나 층을 여는 표식은 세지 않는다.

    function countStep(s) {
        if (s.k === CMP) {
            cmpN += 1;
        }
        else if (s.k === SWP || s.k === SET || s.k === MOV || s.k === SHF) {
            movN += 1;
        }
    }
    function isDone() { return at >= steps.length; }
    // 지금 걸음의 진행(0~1) — 걸음이 끝나면 1
    function stepFrac() { return at < steps.length ? clamp(acc / stepDur(steps[at]), 0, 1) : 1; }
    function runOne() {
        const s = steps[at];
        countStep(s);
        applyOne(s);
        at += 1;
    }

시간표대로 적용하는 재생기

advance 는 지난 시간 acc 가 걸음의 길이 stepDur 를 넘을 때마다 걸음을 하나씩 적용한다. 판이 끝나면 HOLD_S 만큼 완성 모습을 보여 주고 다음 판으로 넘어가며, 첫 판은 START_FRAC 만큼 걸음을 미리 적용한 채 시작해 정렬이 진행 중인 장면이 첫 화면에 뜬다.

    function advance(dt) {
        sceneT += dt;
        if (at < steps.length) {
            acc += dt;
            while (at < steps.length && acc >= stepDur(steps[at])) {
                acc -= stepDur(steps[at]);
                runOne();
            }
        }
        else {
            holdT += dt;
            if (holdT >= HOLD_S) {
                lap += 1;
                begin();
            }
        }
        update(dt);
    }

화면 상태는 교환 걸음에서 바뀐다

01 의 applyOne 은 비교 걸음에서는 강조할 두 칸만 바꾸고, 교환 걸음에서만 row 의 두 자리를 실제로 맞바꾼 뒤 확정 걸음에서 초록으로 굳을 칸을 표시한다. 막대가 보여 주는 순서는 이 row 에서만 나오므로 화면과 정렬 결과가 어긋날 길이 없다.

    function applyOne(s) {
        if (s.k === CMP) {
            hiA = s.a;
            hiB = s.b;
        }
        else if (s.k === SWP) {
            swapAt(row, s.a, s.b);
            flash[row[s.a]] = 1;
            flash[row[s.b]] = 1;
            hiA = s.a;
            hiB = s.b;
        }
        else if (s.k === FIX) {
            fixedPos[s.a] = true;
        }
    }

정렬 시각화가 깨지는 순간 — 교환 중간 상태가 화면에 남아 같은 값이 두 개로 보이는 막대·걸음 기록과 재생이 어긋나 정렬이 끝났는데 막대는 섞여 있는 화면·막대가 수백 개로 늘어 걸음이 한꺼번에 몰리는 화면

첫 번째는 교환 중간에 같은 값이 둘로 보이는 막대다. 01 에서 row 는 교환 걸음에서 한 번에 바뀌지만 화면의 sx 는 스프링으로 따라가므로 두 막대가 겹쳐 지나가고, 잔상까지 같은 높이로 그려 값이 두 개처럼 읽힌다. 잔상은 투명도 0.34 에서 시작해 점점 옅어지고, 제자리에서 먼 막대를 나중에 그려 진짜 막대가 잔상 위에 오게 했다.

두 번째는 걸음 기록과 재생의 어긋남이다. 계수기를 걸음을 만들 때 올리면 첫 프레임부터 총합이 찍혀 있어 재생과 어긋나므로, 이 편은 runOne 안에서만 올린다. 한 판이 끝난 뒤 한 걸음 버튼을 누르면 다음 판을 처음부터 열고 doneSaid 를 다시 풀어, 새 판의 완료 알림이 다시 한 번 나오게 했다.

세 번째는 걸음이 몰리는 화면이다. 걸음 간격이 프레임 시간보다 짧아지면 한 프레임에 걸음 여럿이 들어오는데, 숨었던 탭이 돌아온 첫 프레임은 DT_MAX 0.05초까지만 계산하고 visibilitychange 에서 루프를 멈췄다 다시 시작해 숨어 있던 시간이 쏟아지지 않게 했다.

전체 소스와 라이브 편집용 파일은 아래 zip 에 들어 있고, 압축을 푸는 비밀번호는 7qbx64nu 이다.

막대 수와 걸음 간격을 바꾸면 알고리즘 차이는 얼마나 벌어질까 — 열 길이·재생 속도·교환 이동 시간을 바꿔 본 차이

이름 바꾼 값 느낌
막대를 많이 01 BAR_COUNT 16 → 48 같은 시드에서 비교는 119회에서 1,107회, 교환은 56회에서 477회로 각각 약 9배 늘고 전체 재생은 약 16초에서 약 139초가 된다
걸음 간격을 짧게 07 STEP_MS 90 → 30 가장 긴 줄(거품 142걸음)이 12.8초에서 4.3초로 줄고 비교·이동 횟수는 그대로다
교환 이동을 느리게 01 SWAP_TRAVEL_MS 160 → 420 스프링 고유 진동수가 37.5에서 약 14.3rad/s 로 낮아져 막대가 크게 출렁이며 전체 재생은 약 16초에서 약 31초가 된다

값을 바꾸기 전에 볼 것

막대를 48개로 늘리면 폭이 약 9px 로 얇아져 값 글자가 사라지고, 같은 시드라도 판이 길어져 화면 하나에서 끝까지 보려면 약 139초를 기다려야 한다. SWAP_TRAVEL_MS 만 올리면 잔상 보관 시간 TRAIL_S 는 그대로라 꼬리가 이동 경로보다 짧게 끊긴다. 07 의 STEP_MS 를 줄여도 횟수는 같으니, 알고리즘 차이를 보려면 간격이 아니라 막대 수를 바꿔야 한다.

움직임 줄이기에서는 정렬이 끝난 막대 한 장 — 현재 걸음을 문장으로 읽어 주고 한 걸음씩 넘기는 접근성

정렬 완료 알림은 한 번만

아홉 장면은 모두 시연이 시간표만으로 끝까지 가고, 무대에 포커스를 두고 Enter 나 Space 를 누르면 일시정지, 오른쪽 방향키는 한 걸음이다. 일시정지 버튼은 aria-pressed 로 눌림을 알리고 정렬이 끝나면 숨은 role="status" 줄에 완료 문장을 한 번만 쓴다.

    let doneSaid = false;
    function checkDone() {
        if (isDone() && !doneSaid) {
            doneSaid = true;
            doneP.textContent = statusText();
        }
    }

키보드와 보이지 않는 탭

    pad.addEventListener('keydown', function (e) {
        if (e.altKey || e.ctrlKey || e.metaKey) {
            return;
        }
        if (e.key === 'Enter' || e.key === ' ') {
            e.preventDefault();
            togglePause();
        }
        else if (e.key === 'ArrowRight') {
            e.preventDefault();
            stepOnce();
        }
    }, { signal: off.signal });

prefers-reduced-motion: reduce 이면 루프 없이 showRest 가 걸음을 끝까지 적용한 정렬 완료 한 장만 그리고 상태 줄에 「정렬 완료」 문장을 쓴다. 상태 줄은 시연 중에는 aria-live="off" 로 두어 한 걸음마다 읽히지 않게 하고, 사람이 처음 손대면 polite 로 바뀐다.

정렬 알고리즘 시각화 FAQ — 라이브러리 없이 만들기·정렬 확인 방법·사용자 입력 배열을 넣을 때의 한계

버블 정렬 시각화를 라이브러리 없이 만들 수 있나요

캔버스 2D 하나로 되고, 아홉 장면 모두 라이브러리·WebGL·CDN 을 쓰지 않는다. 난수는 시드 고정 생성기 seeded 로 만들어 같은 입력이 같은 걸음 목록을 만들고, 정렬 함수는 DOM 을 모르는 순수 함수라 따로 떼어 노드에서 돌려 볼 수 있다.

정렬이 제대로 됐는지는 어떻게 확인하나요

걸음 목록을 끝까지 적용한 배열을 isSorted 에 넘기면 되고, 이 함수는 소스에 들어 있다. 이 편을 만들 때 노드에서 걸음 생성기 8개를 돌려 모두 정렬된 결과가 나오는 것을 확인했고, 화면 쪽 루프는 이 함수를 호출하지 않는다.

사용자가 입력한 배열을 넣어도 되나요

08 을 뺀 장면이 다룬 입력은 1부터 n 까지를 한 번씩 담은 배열뿐이다. 08 은 seeded 로 100~999 세 자리 수를 겹치지 않게 뽑는 공 24개를 쓴다. barH 가 값을 막대 수로 나눠 높이를 만들기 때문에 같은 값이 둘 있거나 음수·큰 수가 들어오면 높이 계산과 색 계산부터 바꿔야 하고, 막대를 수백 개로 늘리면 위 변형 표처럼 재생 시간이 길어지는 한계도 같이 따른다.

참고로 MDN 의 requestAnimationFrame 문서와 Canvas 2D 문서, 움직임 줄이기 설정은 prefers-reduced-motion 문서가 기준이다. 막대가 커지는 방식이 궁금하면 막대 차트 스프링 편을, 격자 위에서 길을 찾는 장면은 미로 생성기 편을 이어서 보면 된다.

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

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