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

간트 차트 만들기 9종 — 의존 화살표·가장 긴 경로·연쇄 밀기를 계산으로

간트 차트 만들기는 할 일마다 시작일과 기간을 날짜 눈금 위 가로 막대로 놓고 앞뒤 순서를 화살표로 이어 일정 전체를 한 장에 보이게 하는 일이다. 디자인 시안이 사흘 늦어졌는데 막대 하나만 옮기면 일정표는 그날부터 거짓말을 한다.

막대를 그리는 것보다 어려운 일 — 날짜 하나가 밀릴 때 뒤 작업이 스스로 자리를 다시 잡는 일정표

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

아홉 장면은 같은 프로젝트 하나를 서로 다른 각도로 보여 준다. 요구 정리에서 시작해 디자인 시안·데이터 설계·화면 개발·서버 개발·기능 연동·문서 작성·통합 검수·오류 수정을 거쳐 출시로 끝나는 작업 열 개가 BASE_TASKS 한 곳에 있고, 05 는 그 일수를 5배로 늘려 105일짜리로, 08 은 출시를 뺀 아홉 개를 세 묶음으로, 09 는 시드로 만든 작업 240개로 바꿔 쓴다. 날짜는 기준일 0 부터 센 정수 일수로만 다루고, 화면에 찍히는 3월 11일 같은 숫자는 12개월 길이 표로 calendarOf 가 산수로 만든다. 데모 코드에는 Date 가 한 번도 나오지 않는다.

막대 차트를 스프링으로 키우는 편 의 막대는 값 하나만큼 높이가 자라지만, 여기 막대는 시작일과 기간이라는 두 값을 날짜 눈금에서 받고, 앞 작업이 바뀌면 그 두 값부터 다시 계산된다. 고급 차트 9종 의 워터폴 브리지가 앞 막대가 끝난 높이에서 다음 막대를 시작해 증감을 이어 붙인다면, 02 의 화살표는 「앞 작업이 끝나야 뒤 작업이 시작한다」 는 조건을 들고 있어서 그 조건이 지켜지는 길을 직접 고른다.

팀 일정표와 출시 계획 회의

팀 주간 회의에 띄우는 일정표라면 01 과 06 의 조합이 맞는다. 01 은 처음 열었을 때 시작일 순서대로 막대가 뻗어 「무엇이 먼저 시작하는지」 를 먼저 보여 주고, 06 은 오늘 선이 지나간 자리에서 계획보다 늦은 작업만 빗금으로 짙어진다. 출시일을 정하는 회의에서는 03 이 핵심이다. 여유가 0 인 작업 일곱 개가 한 줄로 빛나니 「이 중 하나라도 하루 늦으면 출시가 하루 늦는다」 는 말을 그림 하나로 대신할 수 있다.

인력 배분과 일정 변경 공지

사람 수가 정해진 팀이라면 07 이 먼저 쓰인다. 날마다 몇 명이 묶이는지 아래 그래프로 쌓고, 정원을 넘는 날이 생기면 여유가 큰 작업부터 하루씩 미뤄 출시일을 건드리지 않고 넘침을 없앤다. 외주 제작처럼 일정을 고객과 나누는 페이지에는 02 와 04 가 어울린다. 고객이 「왜 이 작업은 다음 주에 시작하나」 를 물으면 화살표 하나가 답이 되고, 시안이 늦어졌다는 공지에는 04 처럼 밀린 작업이 단계 순서로 움직이는 장면을 함께 붙일 수 있다.

간트 차트란 결국 무엇을 계산해서 보여 주는 표인가?

막대 하나는 숫자 셋을 품는다. 언제 시작하는지, 며칠 걸리는지, 그리고 늦어도 출시에 영향이 없는 날이 며칠인지(여유)다. 앞의 둘은 사람이 적지만 세 번째는 적을 수 없다. 여유는 이 작업 뒤에 무엇이 매달려 있는지를 끝까지 따라가야 나오는 값이라, 화살표 하나가 바뀌면 멀리 떨어진 막대의 여유까지 달라진다.

손으로 적은 좌표는 하루 만에 낡는다

간트 차트 예시를 그림 파일처럼 만들어 막대 좌표를 손으로 적어 두면, 디자인 시안 기간이 하루 바뀌는 순간 그 뒤 막대 여섯 개의 좌표를 사람이 다시 고쳐야 한다. 이 편의 아홉 장면은 좌표를 적지 않는다. 작업 이름·일수·앞 작업 번호·사람 수 네 가지만 적고, 시작일과 여유와 화면 x 는 그 네 가지에서 매번 계산한다.

그리기는 캔버스, 읽기는 숨긴 표

아홉 장면 모두 SVG 가 아니라 캔버스 2D 에 그린다. 09 처럼 막대 240개가 카메라를 따라 매 프레임 움직이는 장면도 같은 그리기 함수로 다루려는 선택으로 보인다. 대신 캔버스는 화면 읽기 프로그램이 내용을 읽을 수 없으니, 같은 일정을 작업 이름·시작일·끝나는 날·여유 일수 네 열의 숨긴 표로 한 벌 더 채운다.

간트 차트 만들기 9종 — 스프링 막대·꺾인 의존 화살표·가장 긴 경로·연쇄 밀기·기준 날짜 확대·계획 대비 실적·인력 부하 평준화·묶음 접기·스프린트 첫 화면

아홉 장면을 2초씩 찍어 잰 움직임

480×300 판을 2초 동안 24장 찍고 이웃한 두 장의 차이 23개로 잰 값이다. 240행이 카메라를 따라 흐르는 09 와 막대 열 개가 한꺼번에 뻗는 01 이 50% 를 넘고, 선 몇 가닥만 움직이는 02 는 2.3% 지만 23번 모두 화면이 바뀌었다.

장면 누적면적 움직인 프레임 강도 평균
01 스프링 막대 53.3% 13/23 27.4
02 직각 의존 화살표 2.3% 23/23 61.6
03 가장 긴 경로와 여유 꼬리 34.6% 23/23 32.0
04 연쇄 밀기 40.3% 11/23 36.1
05 기준 날짜 고정 확대 25.0% 16/23 48.3
06 계획 대비 실적 14.0% 23/23 93.2
07 인력 부하 평준화 10.8% 11/23 39.8
08 묶음 접기 26.8% 14/23 56.0
09 스프린트 첫 화면 52.9% 23/23 59.7

01·04·05·07·08 은 움직인 프레임이 11~16 에 그친다. 이 다섯 장면은 시각표대로 한 번 자라거나 밀리거나 접힌 뒤 다음 변화(01 은 5.6초, 04 는 4.8초, 08 은 4.6초)까지 그대로 멈춰 있어서, 2초 촬영의 뒤쪽이 정지 화면으로 찍힌 것으로 보인다. 점선과 점이 쉬지 않고 흐르는 02, 훑는 선과 빛 띠가 이어지는 03, 오늘 선이 계속 움직이는 06·09 는 23번 모두 바뀌었다.

01날짜를 픽셀로 바꿔 시작일 순서로 뻗어 나오는 스프링 막대

막대 시작 x 는 dayX 가 102 + 일수 × 16 으로 정하므로 요구 정리는 102px 에서 48px 폭으로, 서버 개발은 198px 에서 112px 폭으로 서고, 시작일이 이른 순서(같은 날이면 위 행)로 70ms 씩 늦게 0.25초부터 0.88초까지 출발한다. 폭은 단단함 170·감쇠 14(감쇠비 0.537)인 스프링으로 0 에서 1 로 자라 제 길이를 13.0% 넘쳤다가 출발 650ms 뒤부터 ±1% 안에 머물고, 빨리 자랄수록 그림자가 최대 4px 까지 떠올라 막대가 들렸다가 내려앉는 듯 보인다. 프로젝트 관리 대시보드에 처음 들어온 사람은 숫자를 읽기 전에 막대가 뻗는 순서로 어떤 작업이 먼저 시작하는지부터 알게 된다.

날짜를 픽셀로 바꿔 시작일 순서로 뻗어 나오는 스프링 막대
새 창에서 열기

01 · 날짜를 픽셀로 바꿔 시작일 순서로 뻗어 나오는 스프링 막대 — 측정 누적면적 53.288% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BAR_STIFFNESSBAR_DAMPINGBAR_STAGGER_MS
    function dayX(d) { return PLOT_X + d * DAY_WIDTH; }
    // 시작일 순위 — 같은 날이면 위 행 먼저
    const rank01 = (function () {
        const idx = tasks01.map(function (_, i) { return i; });
        idx.sort(function (a, b) { return plan01.es[a] - plan01.es[b] || a - b; });
        const r = new Array(tasks01.length).fill(0);
        idx.forEach(function (i, k) { r[i] = k; });
        return r;
    })();
    const grow01 = tasks01.map(function () { return makeSpring(0); });
    function growAt(i) { return GROW_START_S + rank01[i] * BAR_STAGGER_MS / 1000; }
    function reset() { grow01.forEach(function (s) { snapSpring(s, 0); }); }
    function update(dt) {
        grow01.forEach(function (s, i) {
            s.to = sceneT >= growAt(i) && sceneT < SHRINK_AT_S ? 1 : 0;
            stepSpring(s, BAR_STIFFNESS, BAR_DAMPING, dt);
        });
    }
    function restPose() { grow01.forEach(function (s) { snapSpring(s, 1); }); }

02앞 작업 끝에서 직각으로 꺾여 내려가는 의존 화살표

elbowRoute 는 앞 작업 끝에서 7px 나간 자리보다 뒤 시작이 7px 이상 더 오른쪽이면 점 4개로 곧장 꺾여 들어가고, 아니면 앞 행 아래 통로까지 내려가 왼쪽으로 돌아 점 6개로 들어가는데, 의존 11개 가운데 6개가 이렇게 되돌아 들어가는 진한 보라 길이다. 화살표는 80ms 간격으로 출발해 360ms 동안 그려져 나가고, 다 그려진 길 위로 점선이 초당 26px, 점 하나가 초당 70px 로 흘러 길이 41~221px 인 경로마다 방향이 읽힌다. 외주 제작 일정을 고객과 나누는 페이지라면 「이 작업이 무엇을 기다리는지」 를 선 하나로 보여 줄 수 있다.

앞 작업 끝에서 직각으로 꺾여 내려가는 의존 화살표
새 창에서 열기

02 · 앞 작업 끝에서 직각으로 꺾여 내려가는 의존 화살표 — 측정 누적면적 2.342% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ROUTE_STUBDASH_FLOW_SPEEDARROW_STAGGER_MS
    // 앞 끝(ex, ey)에서 오른쪽으로 ROUTE_STUB 나간 뒤 아래로 꺾는다. 뒤 시작(sx)이 그 꺾인 자리 + ROUTE_STUB 보다 오른쪽이면 곧장 내려가 오른쪽으로 들어가고,
    // 아니면 앞 행 바로 아래 통로(gapY)까지 내려가 왼쪽으로 돌아간 다음 뒤 시작보다 ROUTE_STUB 앞에서 내려가 오른쪽으로 들어간다
    const ROUTE_STUB = 7;
    function elbowRoute(ex, ey, sx, sy, gapY) {
        const x1 = ex + ROUTE_STUB;
        if (sx - ROUTE_STUB >= x1) {
            return [ex, ey, x1, ey, x1, sy, sx, sy];
        }
        const x2 = sx - ROUTE_STUB;
        return [ex, ey, x1, ey, x1, gapY, x2, gapY, x2, sy, sx, sy];
    }

03앞뒤로 두 번 훑어 여유 0인 길을 빛내는 가장 긴 경로

줄 선 순서대로 85ms 마다 한 작업씩 가장 이른 시작을 구하며 세로선이 오른쪽으로 훑고, 1.25초부터는 거꾸로 돌아오며 가장 늦은 시작을 구해 두 값의 차이만큼 여유 꼬리를 스프링(120·13, 감쇠비 0.593)으로 뻗는다. 여유가 0 인 요구 정리·데이터 설계·서버 개발·기능 연동·통합 검수·오류 수정·출시 일곱 개가 진한 테두리와 직각 화살표로 이어지고 밝은 띠가 초당 9일 속도로 그 길을 흐르며, 여유가 가장 큰 문서 작성의 꼬리는 12.03일까지 넘쳤다가 11일에 선다. 출시 일정 회의에서 「어느 작업이 하루만 늦어도 출시가 늦는가」 를 물으면 빛나는 일곱 막대가 그대로 답이다.

앞뒤로 두 번 훑어 여유 0인 길을 빛내는 가장 긴 경로
새 창에서 열기

03 · 앞뒤로 두 번 훑어 여유 0인 길을 빛내는 가장 긴 경로 — 측정 누적면적 34.583% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SWEEP_STEP_MSTAIL_STIFFNESSGLOW_SPEED_DAYS
        for (const i of order) {
            let s = minStart ? minStart[i] : 0;
            for (const p of tasks[i].deps) {
                s = Math.max(s, ef[p]);
            }
            es[i] = s;
            ef[i] = s + tasks[i].dur;
        }
        let end = 0;
        for (const i of order) {
            end = Math.max(end, ef[i]);
        }
        const succ = successorsOf(tasks);
        const ls = new Array(n).fill(0);
        const lf = new Array(n).fill(0);
        for (let k = order.length - 1; k >= 0; k -= 1) {
            const i = order[k];
            let f = end;
            for (const j of succ[i]) {
                f = Math.min(f, ls[j]);
            }
            lf[i] = f;
            ls[i] = f - tasks[i].dur;
        }
        const slack = es.map(function (s, i) { return ls[i] - s; });

04한 작업이 밀리면 화살표를 따라 차례로 밀려나는 뒤 작업

0.3초에 디자인 시안이 사흘 늘어나면 pushFrom 이 뒤 작업을 가까운 것부터 넓게 훑어 새 시작을 앞 작업 끝 가운데 가장 늦은 날로 다시 정하고, 몇 단계 뒤인지(depth) × 60ms 만큼 늦게 단단함 210·감쇠 19 스프링으로 옮긴다. 화면 개발과 문서 작성은 사흘 그대로 밀리지만 기능 연동부터는 서버 개발 쪽 끝(13일)과 겨뤄 이틀만 밀려, 밀린 6개 끝의 출시 마름모는 22일차에서 24일차로 옮겨 가 장면 시작 1.037초에 자리를 굳힌다. 일정 변경 알림을 보내기 전 미리보기로 띄우면 받는 사람이 무엇이 어떤 순서로 며칠씩 밀렸는지를 한 장면으로 본다.

한 작업이 밀리면 화살표를 따라 차례로 밀려나는 뒤 작업
새 창에서 열기

04 · 한 작업이 밀리면 화살표를 따라 차례로 밀려나는 뒤 작업 — 측정 누적면적 40.26% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PUSH_STAGGER_MSPUSH_STIFFNESSSLIP_DAYS
        const succ = successorsOf(tasks);
        const out = starts.slice();
        const depth = tasks.map(function () { return -1; });
        depth[from] = 0;
        const queue = [from];
        let head = 0;
        // 고리가 있어도 끝나도록 꺼내는 횟수에 상한을 둔다
        const limit = tasks.length * tasks.length + 1;
        while (head < queue.length && head < limit) {
            const i = queue[head];
            head += 1;
            for (const j of succ[i]) {
                let need = 0;
                for (const p of tasks[j].deps) {
                    need = Math.max(need, out[p] + tasks[p].dur);
                }
                if (need > out[j]) {
                    out[j] = need;
                    depth[j] = Math.max(depth[j], depth[i] + 1);
                    queue.push(j);
                }
            }
        }
        return { starts: out, depth: depth };

05기준 날짜를 붙잡고 일·주·월로 늘었다 줄어드는 눈금

dayX 가 300 + (일수 − 52) × 하루 폭 으로 그리므로 기준 날짜인 5월 2일은 하루 폭이 26px 와 2.2px 사이를 오가는 동안 화면 x 가 0px 도 어긋나지 않고, 같은 통합 검수 시작일은 1028 에서 361.6 으로 기준 날짜 쪽으로 끌려온다. 하루 폭은 로그 값을 직선으로 섞어 되돌리는 zoomWidth 라 진행 절반에서 7.56px(두 끝의 기하평균, 직선 보간이면 14.1px)를 지나고, 폭이 문턱을 넘을 때마다 일 숫자·주차·월 경계 층이 겹쳐 흐려지며 바뀐다(주 층이 0.5 를 넘는 0.667초에 17.92px, 월 층은 1.346초에 4.49px). 분기 로드맵을 일 단위에서 월 단위 보기로 바꿀 때 지금 보던 날짜를 놓치지 않게 하는 장면이다.

기준 날짜를 붙잡고 일·주·월로 늘었다 줄어드는 눈금
새 창에서 열기

05 · 기준 날짜를 붙잡고 일·주·월로 늘었다 줄어드는 눈금 — 측정 누적면적 24.965% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

DAY_W_NEARDAY_W_FARANCHOR_DAY
    // 로그 보간 — ln(하루 폭)을 선형으로 섞은 뒤 되돌린다
    function zoomWidth(p) { return Math.exp(lerp(Math.log(DAY_W_NEAR), Math.log(DAY_W_FAR), p)); }
    function zoomProgress(t) {
        if (t < ZOOM_IN_AT_S) {
            return smooth((t - ZOOM_OUT_AT_S) / ZOOM_S);
        }
        return 1 - smooth((t - ZOOM_IN_AT_S) / ZOOM_S);
    }
    function dayX(d) { return ANCHOR_X + (d - ANCHOR_DAY) * dayW05; }
    function xDay(x) { return ANCHOR_DAY + (x - ANCHOR_X) / dayW05; }
    // 층 불투명도 — 하루 폭 하나에서 세 층이 겹쳐 흐려지는 비율
    function layersAt(w) {
        return {
            day: smooth((w - DAY_TICK_FADE[0]) / (DAY_TICK_FADE[1] - DAY_TICK_FADE[0])),
            week: smooth((w - WEEK_TICK_FADE[0]) / (WEEK_TICK_FADE[1] - WEEK_TICK_FADE[0])) * (1 - smooth((w - WEEK_TICK_OUT[0]) / (WEEK_TICK_OUT[1] - WEEK_TICK_OUT[0]))),
            month: 1 - smooth((w - MONTH_TICK_FADE[0]) / (MONTH_TICK_FADE[1] - MONTH_TICK_FADE[0]))
        };
    }

06오늘 선이 지나가며 계획보다 늦은 막대를 드러내는 실적 비교

오늘 선이 초당 6.5일로 지나가는 동안 작업마다 기대 진행률(경과 ÷ 기간)과 실제 진행률(경과 ÷ 일수 × ACTUAL_PACE)을 0~1 로 잘라 구하고, 그 차이를 0.3 으로 나눈 짙기만큼 실제 끝과 기대 끝 사이의 빗금 간격을 7px 에서 2.5px 로 좁히며 진하게 칠한다. 문서 작성은 오늘이 기준일에서 10일 지났을 때 50%, 디자인 시안은 7일 지났을 때 40% 늦어 가장 크게 벌어지고, 계획 막대는 4px 오른쪽 아래로 민 옅은 그림자로 뒤에 깔린다. 주간 업무 보고 화면에서 늦은 정도를 '{n}% 늦음' 글자와 빗금 농도 두 겹으로 보여 준다.

오늘 선이 지나가며 계획보다 늦은 막대를 드러내는 실적 비교
새 창에서 열기

06 · 오늘 선이 지나가며 계획보다 늦은 막대를 드러내는 실적 비교 — 측정 누적면적 14.033% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ACTUAL_PACELAG_FULLPLAN_SHADOW_OFFSET
    function expectedAt(i, today) {
        const t = tasks06[i];
        if (t.dur === 0) {
            return today >= sweep06.es[i] ? 1 : 0;
        }
        return clamp((today - sweep06.es[i]) / (sweep06.ef[i] - sweep06.es[i]), 0, 1);
    }
    function actualAt(i, today) {
        const t = tasks06[i];
        if (t.dur === 0) {
            return today >= sweep06.es[i] ? 1 : 0;
        }
        return clamp((today - sweep06.es[i]) / t.dur * ACTUAL_PACE[i], 0, 1);
    }
    // 늦음 → 빗금 짙기 0~1
    function lagLevel(i, today) { return clamp((expectedAt(i, today) - actualAt(i, today)) / LAG_FULL, 0, 1); }

07하루 정원을 넘는 날을 깎아 내는 인력 부하 평준화

아래 그래프가 날마다 묶인 사람 수를 더해 정원 3명을 넘는 날(8~10일차에 4명) 가운데 첫날을 찾으면, levelSteps 가 그날 시작하는 작업 가운데 여유가 가장 큰 것을 하루 늦추고 pushFrom 으로 뒤를 미는 걸음을 넘침이 0 이 되거나 40번이 찰 때까지 되풀이한다. 정원 3 에서는 여유 11일인 문서 작성만 다섯 번 하루씩 늦어져 넘침 0·최고 3명이 되고 전체 21일은 그대로이며, 걸음은 0.3초부터 150ms 마다 하나씩 막대 x 와 그래프 높이를 스프링으로 다시 흘린다. 팀 인력 배치 계획에서 출시일을 건드리지 않고 누구의 시작을 미루면 하루 정원이 지켜지는지를 찾는 계산이다.

하루 정원을 넘는 날을 깎아 내는 인력 부하 평준화
새 창에서 열기

07 · 하루 정원을 넘는 날을 깎아 내는 인력 부하 평준화 — 측정 누적면적 10.849% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

DAY_CAPACITYLEVEL_MAX_STEPSLEVEL_STEP_MS
            // 지금 배치 기준의 여유 — 정해 둔 시작일을 지키며 앞뒤로 훑는다
            const sw = sweepBoth(tasks, order, starts);
            let pick = -1;
            // 후보는 그날 「시작하는」 작업만 — 이미 시작한 작업은 건드리지 않는다. 첫 넘침 날은 전날보다 일감이 늘어난 날이라 그날 시작하는 작업이 늘 하나 이상 있고,
            // 시작한 작업까지 후보로 두면 겹친 작업들이 여유를 주고받으며 번갈아 하루씩 밀려 끝없이 뛰어넘는다(정원 2 에서 40번을 다 쓰고도 넘침 5일)
            tasks.forEach(function (t, i) {
                if (t.load === 0 || starts[i] !== day) {
                    return;
                }
                if (pick < 0 || sw.slack[i] > sw.slack[pick] || (sw.slack[i] === sw.slack[pick] && i > pick)) {
                    pick = i;
                }
            });
            if (pick < 0) {
                break;
            }
            const nudged = starts.slice();
            nudged[pick] += 1;
            starts = pushFrom(tasks, nudged, pick).starts;
            const after = dailyLoad(tasks, starts, span + 1);
            steps.push({ starts: starts.slice(), moved: pick, day: day, over: overflowDays(after, cap) });

08하위 작업이 상위 막대 하나로 모여드는 묶음 접기

상위 막대 구간은 하위 작업의 가장 이른 시작과 가장 늦은 끝으로 계산하고(기획 0~7일·개발 6~16일·검수 7~21일), 0.3초에 개발·0.65초에 기획이 접히면 하위 막대가 세로 위치와 높이를 상위 막대 쪽으로 보간해 색 조각으로 겹쳐 남는다. layoutTargets 는 접히는 하위 행 높이를 17 × (1 − 목표 접힘)으로 쳐서 아래 행 목표 y 를 다시 쌓고, 행은 스프링(190·17)으로 따라가 펼친 채 남는 검수 묶음 머리 행이 102px 올라와 1.104초에 자리를 굳힌다. 부서별 일정 요약에서 지금 볼 묶음 하나만 펼치고 나머지는 한 줄로 접어 두는 화면이다.

하위 작업이 상위 막대 하나로 모여드는 묶음 접기
새 창에서 열기

08 · 하위 작업이 상위 막대 하나로 모여드는 묶음 접기 — 측정 누적면적 26.79% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

COLLAPSE_STIFFNESSROW_STIFFNESSGROUP_BAR_H
    // 목표 y — 접히는 묶음의 하위 행은 높이 0 으로 친다(목표 접힘 기준)
    function layoutTargets() {
        const out = [];
        let y = ROWS_TOP;
        rows08.forEach(function (r) {
            out.push(y);
            const h = r.kind === 0 ? ROW_H : ROW_H * (1 - fold08[r.group].to);
            y += h;
        });
        return out;
    }
    function reset() {
        fold08.forEach(function (s) { snapSpring(s, 0); });
        rows08.forEach(function (_, i) { snapSpring(rowY08[i], ROWS_TOP + i * ROW_H); });
    }
    function update(dt) {
        fold08.forEach(function (s, gi) {
            s.to = sceneT >= COLLAPSE_AT_S[gi] && sceneT < EXPAND_AT_S[gi] ? 1 : 0;
            stepSpring(s, COLLAPSE_STIFFNESS, COLLAPSE_DAMPING, dt);
        });
        const ty = layoutTargets();
        rowY08.forEach(function (s, i) { s.to = ty[i]; stepSpring(s, ROW_STIFFNESS, ROW_DAMPING, dt); });
    }

09오늘 선을 카메라가 따라가며 마일스톤에 닿는 스프린트 첫 화면

시드 481 로 만든 작업 240개(스프린트와 마일스톤 각 10개)를 오늘 선이 초당 3.2일로 지나가고, 카메라는 오늘 선과 지금 일하는 행을 목표로 비율 2.2 의 지연 추종으로 따라가(63% 따라가는 데 0.455초) 한 박자 늦게 화면을 흘린다. 시작한 뒤에야 드러나는 추가 일수(작업 75개, 합 147일)가 들어올 때마다 recompute 가 앞뒤 훑기를 다시 돌려 진한 테두리의 여유 0 경로가 옮겨 가고, 마일스톤 마름모에 닿으면 고리 두 겹이 초당 120px 로 퍼진다. 협업 도구 랜딩 첫 화면에서 일정이 저절로 굴러가는 모습을 영상 없이 보여 주는 장면이다.

오늘 선을 카메라가 따라가며 마일스톤에 닿는 스프린트 첫 화면
새 창에서 열기

09 · 오늘 선을 카메라가 따라가며 마일스톤에 닿는 스프린트 첫 화면 — 측정 누적면적 52.882% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

CAMERA_FOLLOW_RATETODAY_DAYS_PER_SRING_SPEED
    // 하루가 바뀔 때마다 실제 일수를 반영해 다시 훑는다 — 정수 일수가 바뀔 때만이라 프레임마다 240개를 다시 세지 않는다
    function recompute(day) {
        let base = plan09;
        // 시작 여부가 다시 계산한 시작일에 따라 바뀌므로 두 번 돌려 안정시킨다
        for (let k = 0; k < 2; k += 1) {
            const ts = knownTasks(day, base);
            base = sweepBoth(ts, order09, null);
            dur09 = ts.map(function (t) { return t.dur; });
        }
        now09 = base;
    }

날짜는 픽셀로, 화살표는 직각 경로로, 여유는 앞뒤 두 번의 훑기로 — 일정표 한 장을 세우는 계산

날짜에서 픽셀까지 곱셈 한 번

화면 x 는 그래프 왼쪽 끝에 일수 × 하루 폭을 더한 값이다. 01 은 102 + 일수 × 16 이라 출시(기준일로부터 21일 뒤, 화면 표기 22일차)가 438px 에 오고, 05 는 왼쪽 끝 대신 기준 날짜를 원점으로 삼아 300 + (일수 − 52) × 폭 으로 바꿨을 뿐이다. 이 곱셈이 단순하게 남는 이유는 일수가 정수라서다. 시작일·기간·여유가 모두 정수 일수라 막대 끝이 하루 칸 경계에 정확히 맞고, 표의 '{n}일차' 글자와 막대 위치가 어긋날 틈이 없다.

줄 세우기가 먼저 오는 이유

앞 작업이 아직 계산되지 않았는데 뒤 작업의 시작일을 구할 수는 없다. 그래서 모든 장면은 먼저 sortByDeps 로 작업을 선후 순서로 줄 세운다. 들어오는 화살표 수(waiting)가 0 인 작업부터 꺼내고, 하나를 꺼낼 때마다 그 뒤 작업의 수를 1 씩 줄이며, 끝까지 못 꺼낸 작업은 stuck 에 따로 담는다.

    function sortByDeps(tasks) {
        const succ = successorsOf(tasks);
        const waiting = tasks.map(function (t) { return t.deps.length; });
        const ready = [];
        waiting.forEach(function (w, i) { if (w === 0) {
            ready.push(i);
        } });
        const order = [];
        while (ready.length) {
            ready.sort(function (a, b) { return a - b; });
            const i = ready.shift();
            order.push(i);
            succ[i].forEach(function (j) { waiting[j] -= 1; if (waiting[j] === 0) {
                ready.push(j);
            } });
        }
        const stuck = [];
        waiting.forEach(function (w, i) { if (w > 0) {
            stuck.push(i);
        } });
        return { order: order, stuck: stuck };
    }

앞 훑기와 뒤 훑기의 차이가 여유

간트 차트 js 로 일정표를 직접 짤 때 가장 값진 줄은 위 03 의 sweepBoth 두 반복문이다. 줄 선 순서대로 가며 가장 이른 시작 = 앞 작업 끝 중 가장 늦은 날을, 거꾸로 돌아오며 가장 늦은 시작 = 뒤 작업 시작 중 가장 이른 날 − 일수를 구하면 열 작업의 가장 이른 시작은 [0, 3, 3, 7, 6, 13, 7, 16, 19, 21], 가장 늦은 시작은 [0, 4, 3, 8, 6, 13, 18, 16, 19, 21]이 되고, 두 줄의 차이 [0, 1, 0, 1, 0, 0, 11, 0, 0, 0]이 여유다. 디자인 시안과 화면 개발은 하루, 문서 작성은 11일까지 늦어도 21일짜리 전체 일정이 그대로다.

밀기와 평준화는 같은 계산을 다시 부른다

04 의 연쇄 밀기와 07 의 평준화는 새 계산을 만들지 않는다. 둘 다 pushFrom 으로 뒤 작업을 밀고, 07 은 어떤 작업을 미룰지 고를 때 sweepBoth 에 지금 시작일을 넘겨 그 배치 기준의 여유를 다시 구한다. 09 역시 하루가 지날 때마다 같은 sweepBoth 를 두 번 돌릴 뿐이다. 앞뒤 훑기 하나가 아홉 장면의 공용 엔진인 셈이다.

스프링을 단단하게, 밀림 간격을 느리게, 하루 정원을 줄여서 — 값 하나로 바뀌는 일정표의 표정

변형 바꾼 값 느낌
스프링을 단단하게 01 BAR_STIFFNESS 170 → 320 감쇠비 0.537 → 0.391, 넘침 13.0% → 25.7%, 처음 닿는 시각 187ms → 112ms, 끝까지 ±1% 650ms → 621ms
밀림 간격을 느리게 04 PUSH_STAGGER_MS 60 → 160 마지막 출발 0.6초 → 1.1초, 출시 마름모가 자리 잡는 시각 1.037초 → 1.537초, 밀린 6개·출시 24일차는 그대로
하루 정원을 줄여서 07 DAY_CAPACITY 3 → 2 넘친 날 3 → 5, 늦춘 횟수 5 → 23, 전체 21일 → 27일

단단하게 했는데 더 튀는 막대

감쇠비는 14 ÷ (2√단단함)이라 감쇠 14 를 그대로 둔 채 단단함만 320 으로 올리면 0.391 로 내려간다. 막대가 제 길이에 처음 닿는 시각은 75ms 빨라지지만 넘침이 두 배 가까이 커져, 차분해지기는커녕 더 튀다가 거의 같은 시각에 자리를 잡는다. 덜 튀게 하려면 감쇠를 함께 올려야 한다.

한 막대씩 따로 보이는 밀림

간격을 160ms 로 늘리면 화면 개발 → 기능 연동 → 통합 검수 → 오류 수정 → 출시가 한 막대씩 따로 움직여 밀림이 번지는 길이 또렷해진다. 대신 새 일정이 다 서기까지 0.5초가 더 걸리니, 회의 화면처럼 설명이 필요한 자리에서만 느린 쪽이 낫다.

여유가 바닥나는 정원

정원을 2명으로 줄이면 문서 작성의 여유 11일만으로는 넘침을 다 못 지운다. 23번을 늦추는 동안 화면 개발까지 밀려 최종 시작일이 [0, 3, 3, 13, 6, 18, 24, 21, 24, 27]이 되고 전체가 6일 길어진다. 평준화가 출시일을 지키는 것은 여유가 남아 있을 때까지다. 그나마 23번에서 멈추는 것은 후보를 그날 시작하는 작업으로 좁혀 두었기 때문이고, 07 소스 주석에 따르면 이미 시작한 작업까지 후보로 두면 겹친 작업들이 여유를 주고받으며 번갈아 밀려 40번을 다 쓰고도 넘친 날이 5일 남는다.

일정표가 깨지는 순간 — 서로를 기다리는 순환 의존·시간대가 바뀌는 날의 하루 차 계산·수백 줄로 늘어난 작업

디자인 시안이 통합 검수를 기다리게 되면

02 는 4.4초부터 6.6초까지 디자인 시안이 통합 검수를 기다리게 하는 의존(통합 검수 → 디자인 시안) 하나를 더한 사본을 sortByDeps 에 넣는다. 그러면 요구 정리·데이터 설계·서버 개발 3개만 줄을 서고 7개가 stuck 에 남는데, 고리 자체는 디자인 시안 → 화면 개발 → 기능 연동 → 통합 검수 → 디자인 시안으로 도는 넷이고 나머지 문서 작성·오류 수정·출시는 그 고리 뒤에 매달려 줄을 못 선 작업이다. 남은 작업을 무시하고 정렬 결과만 앞뒤 훑기에 넘기면 그 일곱 작업의 시작일은 0 인 채로 지나가 버린다. 02 는 stuck 이 비어 있지 않으면 그 작업에 경고 빗금과 끊긴 점선·✕ 를 그리고 상태 줄에 「순환 의존 — 줄 세우지 못한 작업 7개」 를 띄우며, pushFrom 은 큐에서 꺼내는 횟수를 n² + 1 로 묶어 고리가 있어도 끝난다. 다른 고리를 일부러 만들어 보려면 받은 파일 02 의 LOOP_EDGE 두 번호를 바꿔 보면 되고, 내려받은 zip 의 암호는 dvx72mdg 이다.

시계가 한 시간 당겨진 날이 낀 열흘

바깥에서 받은 시각을 시간 단위로 빼서 24 로 나누면, 10일차에서 20일차 사이에 시계가 한 시간 당겨진 날이 끼었을 때 흐른 시간은 239시간이고 239 ÷ 24 = 9.958 이 된다. 이를 버림하면 9일로 하루가 모자라고, 반대로 한 시간 늦춰진 날이면 10.042 가 된다. 반올림하는 hoursToDayIndex 로 붙이면 둘 다 10 이 된다. 데모는 처음부터 정수 일수만 더하고 빼기 때문에 이 함정이 들어올 자리가 없고, hoursToDayIndex 는 바깥 시각을 받을 때를 위해 정의만 해 둔 함수로 데모 안에서는 부르지 않는다.

240행 가운데 14~15행만 그린다

09 는 카메라 위치에서 첫 행 = floor((camY + 머리 높이) ÷ 행 높이), 끝 행 = floor((camY + 화면 높이) ÷ 행 높이)를 구해 그 사이 행만 막대·이름·화살표를 그린다. 20초 동안 실제로 그린 행은 14~15 / 240 이고, 화면 오른쪽 위 「그린 행 a / 전체 b」 글자가 그 수를 보여 준다. 화살표도 앞 작업이 화면 위로 3행 넘게 벗어나면 건너뛴다. 다시 훑기는 프레임마다가 아니라 정수 일수가 바뀔 때만 돌아 20초에 64번이고, 프레임마다 돌렸다면 약 1,200번이었다. 행 수를 더 늘려 보려면 받은 파일 09 의 HERO_TASKS 값을 키워 보면 된다.

막대 대신 표로도 읽히게 — 작업 목록 표·일시정지 버튼·움직임 줄이기 접근성

캔버스 뒤에 숨긴 작업 표

아홉 캔버스 모두 role="img" 와 장면 설명 aria-label 을 달고, 바로 곁의 숨긴 <table> 을 fillTable 이 caption·열 머리(작업 이름·시작일·끝나는 날·여유 일수)·행 머리로 채운다. 09 는 240행 전부가 표에 들어가므로 화면에 14~15행만 그려져도 화면 읽기 프로그램은 일정 전체를 읽는다.

일시정지 버튼과 포커스 링

장면마다 aria-pressed 를 단 <button> 하나가 있어 클릭·Enter·Space 로 멈추고 다시 돌리며, 키보드로 왔을 때는 :focus-visible 에서 3px 진한 외곽선이 생긴다. 상태 줄이 있는 02·04·07 은 시연 중에는 aria-live="off" 로 조용하다가 버튼을 처음 누른 뒤에야 polite 로 바뀌어, 자동 시연이 몇 초마다 읽기를 끊지 않는다.

움직임 줄이기에서 남는 최종 배치

prefers-reduced-motion: reduce 이면 아홉 장면 모두 루프를 세우고 restPose 로 최종 배치 한 장만 그린다. 01 은 다 자란 막대, 04 는 밀린 뒤의 일정, 07 은 평준화가 끝난 그래프, 08 은 기획·개발이 접히고 검수만 펼친 요약이 남는다. 설정을 도중에 바꿔도 change 리스너가 바로 따르고, 탭이 숨으면 visibilitychange 로 requestAnimationFrame 루프를 멈췄다가 돌아오면 한 프레임 시간을 0.05초에서 잘라 막대가 한꺼번에 튀지 않는다.

웹에 간트 차트를 직접 넣기 전에 자주 묻는 질문

간트차트 만들기 사이트를 쓰지 않고 내 웹페이지에 바로 넣어도 되나요?

된다. 각 장면의 스크립트는 index.html 한 파일 안에 다 들어 있고 라이브러리·CDN·이미지 파일을 하나도 부르지 않으므로, 압축을 풀어 원하는 페이지에 붙이면 그 자리에서 돈다.

우리 팀 작업 이름과 기간으로 바꾸려면 어디를 고치나요?

BASE_TASKS 의 줄마다 name(작업 이름)·dur(일수)·deps(앞 작업 번호)·load(사람 수)를 고치면 된다. 시작일·여유·화면 위치는 이 값에서 다시 계산되니 좌표를 손댈 필요가 없다.

휴대폰처럼 좁은 화면에서도 글자가 흐려지지 않나요?

무대 폭은 화면 폭의 92% 를 넘지 않고 480 : 300 비율을 지키며, 캔버스는 기기 화소 배율(최대 2)만큼 실제 화소를 늘려 그리므로 좁은 화면에서도 글자와 선이 또렷하다. 다만 작업이 많은 일정이라면 09 처럼 보이는 행만 그리는 방식을 같이 쓰는 편이 낫다.

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

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