AI Dev Lab
GanttPad

GanttPad ができるまで — 日付軸タイムラインのレイアウト計算

ガントチャート作成ツールの開発ログ。 開始日と終了日から全体期間を求めてバーの位置と長さを算出し、 期間に応じて日・週・月の目盛りを自動で切り替える描画の作り方を記録しています。

プロジェクトの工程表——ガントチャートを作りたいだけなのに、いざ探すと選択肢が極端です。

Excel で手作りするとバーの位置合わせが地獄。専用ツールは高機能な代わりに、アカウント登録して、チームを作って、有料プランに誘導されて……「1 枚のガントチャートを PNG で欲しいだけ」には過剰です。

GanttPad は、タスクと開始日・終了日を入れると即座にガントチャートを描く、それだけのツールです。登録なし、その場で PNG / SVG に書き出し。日付をバーの位置に変換する部分を自前で実装しました。


#🎯 「表を入れたら図になる」に絞る

ガントチャート作成の面倒さは、ほぼ 「日付とバーの位置合わせ」 に集約されます。3 月 10 日開始・3 月 20 日終了のタスクを、横軸のどこからどこまでのバーにするか。タスクが増えるほど手作業では合わなくなる。

GanttPad はここだけを自動化しました。タスク名・開始日・終了日を入力すると、あとは全部計算で位置が決まります。進捗の塗り、今日の線、目盛りの自動調整——「表を入れたら図が出る」以上のことはしません。


#🏗️ 日付を「オフセット」に変換する

ガントチャートの本質は、それぞれのタスクを「全体の開始日から何日目に、何日間」のバーに変換することです。GanttPad の computeLayout がこれをやっています。

まず、全タスクの中で最も早い開始日と最も遅い終了日を見つけて、チャート全体の期間を決めます。

ts
const rangeStart = Math.min(...valids.map((p) => p.s))
const rangeEnd = Math.max(...valids.map((p) => p.e))
const totalDays = dayDiff(rangeStart, rangeEnd) + 1

そのうえで、各タスクを「全体開始日からのオフセット(何日目に始まるか)」と「日数(何日間続くか)」に変換します。

ts
const bars: Bar[] = parsed.map((p) => {
 if (!p.valid) return { task: p.t, offset: 0, duration: 1, valid: false }
 return {
 task: p.t,
 offset: dayDiff(rangeStart, p.s as number), // 何日目から
 duration: dayDiff(p.s as number, p.e as number) + 1, // 何日間
 valid: true,
 }
})

これで、描画側は offset × 1日の幅 の位置に、duration × 1日の幅 の長さのバーを置くだけになります。日付という扱いにくいものを、一度「整数の日数」に落としてしまえば、あとはただの掛け算です。

#日付は UTC で扱う

地味に重要なのが、日付をすべて UTC で計算していることです。

ts
export function todayUTC: number { ... }
function fmtMD(t: number): string {
 const d = new Date(t)
 return `${d.getUTCMonth + 1}/${d.getUTCDate}`
}

ローカルタイムで日付を引き算すると、夏時間やタイムゾーンの境目で「1 日ずれる」バグが出ます。工程表で 1 日ずれると信用を失う。日付だけを扱うなら、時刻・タイムゾーンの要素を持たない UTC で通すのが安全でした。


#📏 目盛りは期間の長さで自動的に切り替える

2 週間のプロジェクトと 2 年のプロジェクトを、同じ目盛りで描くことはできません。前者は 1 日ずつ、後者は月ごとに刻むべきです。

GanttPad は、全体の日数に応じて 1 日あたりの幅目盛りの粒度を自動で変えます。

ts
const dayWidth = totalDays <= 21 ? 26 : totalDays <= 70 ? 12 : totalDays <= 210 ? 6 : 3
const dayMode = totalDays <= 45
  • 短い(〜45 日)→ 日単位の目盛り、週末に色を敷く
  • 中くらい(〜210 日)→ 週単位(月曜揃え)の目盛り
  • 長い(それ以上)→ 月単位の目盛り

日単位モードでは、土日を検出して薄く塗ります。

ts
if (dayMode) {
 for (let i = 0; i < totalDays; i++) {
 const t = rangeStart + i * DAY
 const dow = new Date(t).getUTCDay
 if (dow === 0 || dow === 6) weekends.push(i)
 const major = new Date(t).getUTCDate === 1 || i === 0
 ticks.push({ offset: i, label: fmtMD(t), major })
 }
}

週末が見えると「実働日」が直感的に分かるので、短期のスケジュールでは効きます。逆に半年を超える表で週末を全部塗ると真っ黒になるので、日数が増えたらこの装飾は自動で消えます。表示の密度は、データ量に応じて自分で調整する——これをやらないと、短くても長くても見づらいチャートになります。


#📍 「今日」の線

進行中のプロジェクトで一番見たいのは「今、予定に対してどこにいるか」です。GanttPad は今日の位置に縦線を引きます。

ts
const today = todayUTC
const todayOffset = today >= rangeStart && today <= rangeEnd ? dayDiff(rangeStart, today) : null

ポイントは、今日がチャートの期間内にあるときだけ線を出すこと(null なら描かない)。過去のプロジェクトや未来の計画では今日の線は意味がないので、範囲外なら黙って消えます。この縦線と、各バーの進捗塗りを見比べると、「予定より進んでいる/遅れている」が一目で分かります。


#😓 苦労したところ

#不正な入力で全体を壊さない

ユーザーは日付を空欄にしたり、終了日を開始日より前にしたりします。1 つのタスクが壊れているせいでチャート全体が描けなくなると、使い物になりません。

そこで、各タスクを個別に検証し、有効なものだけで全体の期間を決めるようにしました。

ts
const valids = parsed.filter((p) => p.valid) as Array<...>
if (valids.length === 0) return null

不正なタスクは valid: false のバーとして持っておき、描画側でグレー表示などにして「これは日付がおかしい」と示す。有効なタスクが 1 つもないときだけ、チャート自体を出さない。1 行の入力ミスで全部が消える、という事態を避けています。

#色を配列で回す

タスクごとに色を変えたいけれど、ユーザーに毎回色を選ばせるのは面倒です。あらかじめ見分けやすい 8 色を用意して、順番に割り当てています。

ts
export const BAR_COLORS = ["#2d6cdf", "#16a085", "#e08a1e", "#c0392b", "#8e44ad", "#0e7c86", "#d6336c", "#5c6bc0"]

隣り合うバーが似た色にならないよう、色相を散らした並びにしてあります。8 色を超えたら先頭に戻りますが、隣接タスクが同色になる確率は低く、実用上は困りません。

#SVG で作れば PNG も SVG も出せる

チャートは SVG で描いています。SVG なら、そのまま「SVG で保存」できるうえ、canvas に一度描いてから「PNG で保存」もできる。ベクターで持っておけば両方の出力形式に対応できるので、描画のソースを SVG にしたのは自然な選択でした。拡大しても劣化しないので、資料に大きく貼るときにも困りません。


#🔭 今後の拡張

  • 依存関係(矢印) — 「タスク A が終わってから B」を線で結ぶ
  • マイルストーン — 特定の日に菱形のマーカーを置く
  • 担当者ごとの色分け・グループ化
  • CSV の読み込み/書き出し — 表計算ソフトとの行き来
  • クリティカルパスの強調 — 全体の期間を決めている連鎖を色で示す

依存関係まで入ると本格的な工程管理に近づきますが、そこは専用ツールの領域でもある。GanttPad は「表を図にする」手軽さを保つ範囲で機能を選ぶつもりです。


#💡 このサービスから言えること

扱いにくいデータは、一度シンプルな形に落とすと急に楽になる。 日付はタイムゾーンも桁もあって扱いにくいものですが、「全体開始日からの日数(整数)」に変換してしまえば、あとの計算は全部ただの掛け算と足し算です。ガントチャートというと難しそうに見えて、核心は「日付 → オフセット」の一手でした。

もうひとつは、表示の密度を自分で調整するという発想です。同じ描画コードでも、2 週間と 2 年では見せ方を変えないと使えない。データ量に応じて目盛りの粒度や装飾を切り替えるのは、データ可視化ツールを作るときに必ず出てくる判断で、ここを自動化できると「どんな入力でも見やすい」ツールになります。

そして、ガントチャートのような「入力さえあれば計算だけで完結する」ものは、サーバーを持つ理由がありません。ブラウザ内で描いて書き出すだけなら、登録も課金もなしに、その場で完結できます。

[ ./next_action ]

読んだら、 GanttPad を実際に動かす。

この開発ログは GanttPad をどう作ったかの記録です。 読み終わったらそのままサービス本体へ戻って、 実物で価値を確かめてください。

[ ./related_logs ]

関連する開発ログ

all logs →
ToonCast

ToonCast ができるまで — AnimeGANv2 をブラウザで動かす

AnimeGANv2 の小さな ONNX (約9MB) を onnxruntime-web (単一スレッド WASM=COOP/COEP不要、 color-revive で承認済みライブラリの再利用) で実行。 512x512・[-1,1] 正規化で推論し、 結果を元解像度に戻して表示する設計記録。 写真は端末内処理。

read log →
ColorRevive

ColorRevive ができるまで — onnxruntime-web で白黒写真をカラー化

DeOldify の量子化 ONNX を onnxruntime-web (CDN side-load・単一スレッド WASM=COOP/COEP不要) で実行。 256x256 でモデル推論し、 輝度は元写真・色だけ AI を YCbCr で再合成して輪郭を保つ設計記録。 写真は端末内処理。

read log →
PhotoTwin

PhotoTwin ができるまで — CLIP画像埋め込みで似た写真を見つける

CLIP (Xenova/clip-vit-base-patch32) の image-feature-extraction を transformers.js の CDN ESM で side-load し、 各写真を正規化ベクトル化。 cosine 類似度で重複・似た写真をブラウザ内で検出する設計記録 (新ライブラリ追加なし=what-cam と同じ CLIP の再利用)。

read log →
AkinFind

AkinFind ができるまで — ブラウザ内で完結する embeddings 意味検索

多言語の文章埋め込みモデル (Xenova/multilingual-e5-small) を transformers.js の CDN ESM で side-load し、 各文を正規化ベクトル化。 cosine 類似度で意味検索と似ている文ペア検出を全て端末内で行う設計記録。

read log →
WhatCam

WhatCam ができるまで — CLIP のゼロショット画像分類をブラウザで動かす

CLIP (Xenova/clip-vit-base-patch32) を transformers.js の CDN ESM で side-load し、 写真と候補ラベルの近さをブラウザ内で計算。 日本語ラベルを英語プロンプトに変換し、 図鑑と自由入力の両モードで「これ何?」を判定する設計記録。

read log →
DepthCast

DepthCast ができるまで — 1枚の写真をAIの深度推定で立体にする

Depth Anything (transformers.js) を CDN ESM で side-load し、 1枚の写真から深度マップを推定。 WebGL2 フラグメントシェーダで深度に比例した視差 (iterative backward parallax) を作り、 赤青アナグリフ / WebM 書き出しまで端末内で完結させた設計記録。

read log →