GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

ガントチャート js で作る工程表 9種 — 依存・余裕・押し出しを計算で

ガントチャート js は、作業を日付の横棒として並べ、前後関係を矢印で結んだ工程表をスクリプトで描いて計算させる作り方です。デザイン案が三日遅れたとき、バーを一本動かすだけでは工程表がうそをつきます。

バーを描くより難しいこと — 日付が一つずれたとき、後ろの作業が自分で位置を取り直す工程表

自動再生 · タイルを押すとその項目へ · すべて1つのzip

工程表は、作業ごとに開始日を左端、所要日数を長さとする横棒を日付の軸に置き、誰が誰を待っているかを矢印で一枚に見せる図です。九つの場面はどれも同じ案件を扱います。BASE_TASKS に入った十件で、要件整理から始まり、デザイン案・データ設計・画面開発・サーバー開発・機能連携・文書作成・総合検収・不具合修正を経てリリースに至ります。05 は所要日数を5倍した105日の計画、08 はリリースを外した九件を三つのグループに分けた形、09 は乱数の種を決めて作った240件に入れ替えています。日付は0日目から数えた整数の日数で持ち、画面に出る「3月11日」のような月日は月の長さの表を calendarOf が足し算して作るので、デモのコードに Date は一度も出てきません。

ばねで伸びる棒グラフの記事のバーは値一つに向かって伸びますが、ここのバーは開始と長さという日付軸の値を二つ持ち、前の作業が変わった瞬間に両方とも計算し直されます。高度なチャート9種の記事のウォーターフォールは、次の棒を前の棒が終わった高さから始めて増減を一本に積み上げるものでしたが、02 の矢印は「前の作業が終わるまで後ろの作業は始められない」という約束を背負い、その約束を破らない道筋を選びます。

チームの定例とリリース日程の会議では

週ごとのチーム定例なら、01 と 06 の組み合わせが向いています。01 は開始の早い順にバーを伸ばすので、見る人がまず受け取るのは「何から始まるか」です。06 は今日の線が通り過ぎたあと、計画より遅れている作業にだけ斜線を濃く重ねます。リリース日を決める会議では 03 が話をまとめてくれます。余裕ゼロの七件が一本の鎖として光るので、「このどれかが一日ずれればリリースも一日ずれる」という説明を文字のスライドなしで伝えられます。

人員の割り振りと日程変更のお知らせでは

人数が決まっているチームなら、最初に手が伸びるのは 07 です。日ごとに何人がふさがるかを下のグラフに積み、定員を超える日があれば余裕のいちばん大きい作業を一日ずつ後ろへ送って、終わりの日はそのまま守ります。取引先と日程を共有するページには 02 と 04 が合います。矢印は「なぜこの作業は来週からなのか」に答え、デザイン案が遅れたというお知らせには、後ろの作業が段を追ってずれていく 04 の場面を添えられます。

html ガントチャート 自作では、結局なにを計算して見せているのか?

バー一本が持つ数は三つです。始まる日、かかる日数、そしてリリースを動かさずに何日までずれてよいかという余裕です。前の二つは人が入力しますが、三つ目は入力できません。余裕はそのバーの後ろにぶら下がる作業を最後までたどって初めて出てくる値で、矢印を一本変えるだけで、ずっと下の行にあるバーの余裕まで変わることがあります。

手で置いた座標は一日で古くなる

ガントチャート html css でバーの左位置と幅を一本ずつ手で書いて試作すると、デザイン案が一日ずれただけで、その後ろにある六本の座標を書き直すことになります。ここの九つの場面は座標を一つも保存していません。各作業が持つのは名前・日数・待っている作業の番号・人数の四つだけで、開始日・余裕・画面の x は毎回その四つから導き出します。

描くのはキャンバス、読ませるのは隠した表

九つの場面はすべて、バーごとに要素を組み立てるのではなく 2D キャンバスに塗っています。240本のバーがカメラの下で毎フレーム動く 09 があるため、一つの描画処理を全場面で使い回せる形を選んだと考えられます。キャンバスはスクリーンリーダーから中身が見えないので、各場面は同じ日程を作業名・開始日・終了日・余裕日数の4列で隠した表にも書き込みます。

ガントチャート js で作る工程表 9種 — ばねのバー・折れる依存矢印・いちばん長い経路・連鎖の押し出し・基準日ズーム・計画と実績・人員負荷のならし・グループの折りたたみ・スプリントのファーストビュー

各場面を2秒撮って差を数えた結果

場面ごとに480×300で2秒、24枚を撮り、隣り合う2枚の差を比べました。比べた組は1場面あたり23組です。240行がカメラの下を流れる 09 と、十本が一斉に伸びる 01 は画面の半分を超える面積が変わり、細い線しか動かない 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 が動いたのは23組のうち11〜16組にとどまります。この五つは時刻表どおりに一度伸びる・ずれる・寄る・たたむと、次の変化(01 は5.6秒、04 は4.8秒、08 は4.6秒)まで止まっているため、撮影の後半が静止画に近くなったと考えられます。破線が流れる 02、なぞる線と光の帯がある 03、今日の線が進む 06 と 09 は23組すべてで動きました。

01日付をピクセルに変えて開始日の順に伸びるばねのバー

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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.25秒から85msごとに一件ずつ計算し、バーがばねで現れるたびに縦のなぞり線がその作業のいちばん早い終わりで止まり、1.25秒からは線が逆向きにいちばん遅い開始へ戻りながら、「余裕11日」のような文字を添えた余裕の尾(硬さ120・減衰13、減衰比0.593)を伸ばして2.1秒で終えます。そのあと余裕ゼロの七件、要件整理・データ設計・サーバー開発・機能連携・総合検収・不具合修正・リリースの枠とそれをつなぐ矢印が濃い紫になり、明るい帯が毎秒9日の速さで経路をたどり、文書作成の尾は12.03日まで行き過ぎてから11日に落ち着きます。リリース日程の会議では、ずらすと終わりの日まで動いてしまう作業がどれかを、この一枚で指させます。

前後に二度なぞって余裕ゼロの道を光らせるいちばん長い経路
新しいタブで開く

03 · 前後に二度なぞって余裕ゼロの道を光らせるいちばん長い経路 — 計測 累積面積 34.583% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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秒でデザイン案に3日が足されると、pushFrom が矢印を幅優先でたどって画面開発と文書作成を3日、機能連携・総合検収・不具合修正・リリースを2日ずつ押し、五段にわたって六件が動きます。機能連携は13日目に終わるサーバー開発も待っているので、画面開発が持っていた余裕1日が三日の遅れのうち一日を吸収し、リリースは22日目から24日目へ移るだけで済み、各バーの開始と長さは段数 × 60ms から出発するばね(硬さ210・減衰19、減衰比0.656)で動いて、最後の出発が0.6秒、リリースのひし形が落ち着くのは1.037秒です。元の位置は破線の輪郭で残り、増えた三日は赤い斜線で塗られ、状態の行に「デザイン案が3日遅れ → 6件ずれ · リリース 22日目 → 24日目」と出るので、日程変更のお知らせのプレビューにそのまま使えます。

一つの作業がずれると矢印に沿って順に押し出される後ろの作業
新しいタブで開く

04 · 一つの作業がずれると矢印に沿って順に押し出される後ろの作業 — 計測 累積面積 40.26% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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基準日を押さえたまま日・週・月に伸び縮みする目盛り

所要日数を5倍した105日の計画で、dayX は左端ではなく基準日から 300 + (日数 − 52) × 一日の幅 を測るため、一日の幅が26pxから2.2pxまで変わる4秒の間、基準日の5月2日は画面の x が0pxもずれませんでした。幅は ln 26 と ln 2.2 の間を混ぜてから exp で戻す対数の補間なので、半分の地点では両端の幾何平均の7.56px(直線で混ぜれば14.1px)になり、日・週・月の目盛りの層は幅に応じて重なりながら入れ替わって、週の層が不透明度の半分を越えるのは17.92px、月の層は4.49pxのときです。二週間の表示と四半期の表示を切り替えるロードマップでも、読んでいた日付が手元から逃げません。

基準日を押さえたまま日・週・月に伸び縮みする目盛り
新しいタブで開く

05 · 基準日を押さえたまま日・週・月に伸び縮みする目盛り — 計測 累積面積 24.965% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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今日の線が通るたびに計画より遅れたバーを浮かび上がらせる実績比較

今日の線は0.25秒から −0.5日の位置を毎秒6.5日の速さで進み、作業ごとに expectedAt が計画上の進み具合を、actualAt が経過日数に作業ごとの速さ(文書作成0.5、デザイン案0.6など)を掛けた実際の進み具合を出します。その差を0.3で割った値が実際の終わりから期待の終わりまでの斜線の濃さになり、線の間隔は7pxから2.5pxへ詰まり、不透明度は0.35から0.95へ上がって、濃さが0.15を超えると「50%遅れ」のような文字が出て、差がいちばん大きかったのは文書作成の50%とデザイン案の40%でした。計画のバーは右下へ4pxずらした淡い影として下に敷かれるので、週次の業務報告画面で計画を消さずに控えめに残せます。

今日の線が通るたびに計画より遅れたバーを浮かび上がらせる実績比較
新しいタブで開く

06 · 今日の線が通るたびに計画より遅れたバーを浮かび上がらせる実績比較 — 計測 累積面積 14.033% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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一日の定員を超える日を削る人員負荷のならし

levelSteps は定員を超える最初の日を見つけ、その日に始まる作業のうち余裕のいちばん大きいものを一日遅らせて、後ろは pushFrom に押させることを、超過日がなくなるか40回使い切るまで繰り返します。定員3では4人がかかる日が三日あり、余裕11日の文書作成だけが一日ずつ5回後ろへ送られて、いちばん混む日は3人に下がり、全体は21日のまま変わりませんでした。0.3秒から150msごとに一歩ずつ進んで1.05秒で終わり、バーの位置と下のグラフはばね(硬さ260・減衰20)で動き、動かしたバーには薄れていく濃い紫の枠が付き、「定員3人」の線を超えた分は赤で塗られるので、チームの人員配置計画で一つの作業を動かした理由を説明するときに見せられます。

一日の定員を超える日を削る人員負荷のならし
新しいタブで開く

07 · 一日の定員を超える日を削る人員負荷のならし — 計測 累積面積 10.849% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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日目から数えた日数で0〜7・6〜16・7〜21です。開発グループが0.3秒、企画グループが0.65秒に硬さ150・減衰15(行き過ぎ8.2%)のばねでたたまれ、下位のバーは上位のバーへ寄りながらその高さまで縮み、layoutTargets がたたむ行を ROW_H × (1 − 目標) の高さとして数えるので、下の行はばねで上がり、検収グループの見出しは102px上がって1.104秒からは止まったままです。検収グループは開いたまま、上の二つが空けた高さの分だけ上がってくるので、部署別のスケジュール概要で「チームごとに一行で」と頼まれたときの形になります。

下位の作業が上位のバー一本に集まるグループの折りたたみ
新しいタブで開く

08 · 下位の作業が上位のバー一本に集まるグループの折りたたみ — 計測 累積面積 26.79% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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カメラが今日の線を追ってマイルストーンに届くスプリントのファーストビュー

240件の作業は種481の makeRng から作られ、24件ずつ十のスプリントに並んでそれぞれ「スプリント{n}締め切り」のマイルストーンで終わり、そのうち75件は始まってから追加の日数が分かる作りで合計147日あるので、486日目に終わる計画が568日目まで延びます。整数の日数が変わるたびに recompute が始まった作業を実際の日数に差し替えて sweepBoth を二回回し、20秒間で64回になり、カメラは follow の速さ2.2(0.455秒で63%追いつく)で今日の線を一拍遅れて追い、描くのは視野に入った行だけ、240行のうち14〜15行です。コラボレーションツールのランディングのファーストビューをこの場面で始め、計画の全体は隠した表に任せられます。

カメラが今日の線を追ってマイルストーンに届くスプリントのファーストビュー
新しいタブで開く

09 · カメラが今日の線を追ってマイルストーンに届くスプリントのファーストビュー — 計測 累積面積 52.882% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 };
    }

余裕は二度のなぞりの差

ガントチャート 作り方 コツ を一つに絞るなら、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] が余裕です。デザイン案と画面開発は一日ずつ、文書作成は十一日ずれても、21日で終わる計画は動きません。

押し出しもならしも同じ計算を呼び直す

04 の連鎖と 07 のならしは、新しい計算を何も足していません。どちらも後ろの作業を pushFrom でずらし、07 はどの作業を遅らせるか選ぶ前に、今の開始日を渡して sweepBoth を呼び、その配置での余裕を得ます。09 も日が変わるたびに sweepBoth を二回回すので、前後一往復のなぞりが九つの場面すべての下で共通に回っている計算です。

互いを待ち合う循環依存・時間帯が切り替わる日の日数計算・数百行に増えた作業 — 工程表が壊れやすい場面

デザイン案が総合検収を待つことになったら

02 は4.4秒から6.6秒の間、LOOP_EDGE = [7, 1] を使って、デザイン案の待ち先に総合検収を一つ足した写しを sortByDeps に渡します。並べられるのは要件整理・データ設計・サーバー開発の三件だけで、七件が stuck に残ります。輪そのものはデザイン案 → 画面開発 → 機能連携 → 総合検収 → デザイン案の四件ですが、その下流にぶら下がる文書作成・不具合修正・リリースも列に並べません。並べた結果だけを前後のなぞりに渡すと、この七件は開始0のまま何の警告もなく素通りします。02 は stuck を確かめ、空でなければその作業に斜線を引き、破線の矢印を ✕ で断ち、状態の行に「循環依存 — 並べられない作業 7件」と出し、pushFrom は取り出す回数を n² + 1 で打ち切るので、輪があっても止まります。この輪はダウンロードした 02 で組み直せて、zip のパスワードは dvx72mdg です。別の輪をわざと作りたいときは LOOP_EDGE の二つの数を書き換えます。

時計が1時間進められた日を含む十日間

外から来た二つの時刻を時間で引いて24で割るとき、10日目から20日目までの間に時計を1時間進めた日があると、経過は239時間で、239 ÷ 24 = 9.958 になります。切り捨てれば9で一日足りず、逆に1時間戻した日なら10.042 です。hoursToDayIndex で四捨五入すれば、どちらも10に揃います。デモは最初から整数の日数を足し引きしているのでこの落とし穴が入り込む隙はなく、hoursToDayIndex は外の時刻を受け取る日のために定義だけしてあり、デモの中では呼ばれていません。

240行のうち14〜15行だけを描く

09 は先頭の行 = floor((camY + 見出しの高さ) ÷ 行の高さ)、最後の行 = floor((camY + 視野の高さ) ÷ 行の高さ) を求め、その間だけバー・名前・矢印を描きます。20秒間でそれは240行中14〜15行で、右上に「描いた行 a / 全体 b」と出ます。前の作業が視野より3行を超えて上にある矢印も描かずに飛ばします。なぞり直しは整数の日数が変わったときだけで20秒に64回、毎フレーム回せばおよそ1,200回になるところです。行の数を増やして試すなら、ダウンロードした 09 の HERO_TASKS を上げてください。

ばねを硬く、押し出しの間隔を遅く、一日の定員を少なく — 値一つで変わる工程表の表情

名前 変えた値 感じ
ばねを硬く 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秒、押される六件と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日残ります。

バーだけでなく表でも読めるように — 作業一覧の表・一時停止ボタン・動きを減らす設定のアクセシビリティ

キャンバスの後ろにある作業の表

九つのキャンバスはどれも role="img" と場面を説明する aria-label を持ち、fillTable が隣の隠した <table> に見出し・列の見出し(作業名・開始日・終了日・余裕日数)・行の見出しを書き込みます。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 のループを止め、戻ったときは1フレームの時間を0.05秒で切るので、バーが先へ飛びません。

サイトにガントチャートを自前で置く前のよくある質問

「ガントチャート 作り方 簡単」で探してきましたが、このコードは自分のページにそのまま貼れますか?

貼れます。各場面のスクリプトは一つの index.html の中にあり、ライブラリ・CDN・画像ファイルを一つも読み込みません。zip を開いて、置きたいページに貼り付ければその場で動きます。

自分のチームの作業名と日数に変えるには、どこを書き換えますか?

BASE_TASKS の各行の name(作業名)・dur(日数)・deps(前の作業の番号)・load(人数)を書き換えます。開始日・余裕・画面の位置はその値から計算し直されるので、座標に手を入れる必要はありません。

幅の狭いスマートフォンの画面でも文字はぼやけませんか?

舞台は480:300の比率のまま画面幅の92%以内に収まり、キャンバスは画素を端末の画素比(上限2)倍にして描くので、小さな画面でも文字と線がにじみません。作業が多い計画なら、09 のように見えている行だけを描くやり方と組み合わせてください。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。