ガントエディタの UI/UX 設計 — ドロップから作業者割当まで
画面の左、未割当の工程カードが並んでいる。
中央には設備ごとのレーン。A、B、C、D の4本が縦に積まれて、右に時間軸が伸びている。
主任が「製品 Z、100個」のカードを掴んで、設備 B のレーンの火曜午前にドロップする。
バーの幅が自動で計算され、3時間20分のブロックが現れる。
──さあ、ここからだ。
誰がやるのか? メイン1人で済むのか? サポートを付けたら短くなるか?
ドロップ直後に決めなければならないことが、まだ残っている。
第10章で「ガントが主役、最適化は裏方」という方針は決まった。
この章はその具体形 — D&D の瞬間から作業者割当が確定するまで の UI/UX 設計案を書く。
11.1 解くべきは「ドロップ後の割当」
設備割当は D&D の着地点 で決まる。これは直感的で、ほとんど迷わない。
迷うのは:
- 誰を割り当てるか (スキル × 空き × 文脈)
- 何人必要か (メイン1人、サポート要 or 不要)
- 数量が変わったら所要時間がどう動くか
- 作業者を増やすとどれだけ短くなるか
これら4つを ドロップ後 5 秒以内に解決させる UI が今回のスコープだ。
ここで「自動最適化ボタン」を押させてはいけない。
人間が ドロップした瞬間の文脈 (誰を頭に思い浮かべていたか) を消費しないと、知識が UI に乗らない。
逆に「全部選ばせる」のも面倒。候補を提示して、1クリックで確定 が筋。
11.2 画面全体の構成 — 三層レイアウト
3つの領域がそれぞれ別の責務を持つ:
| 領域 | 責務 | 更新タイミング |
|---|---|---|
| 設備レーン | 「いつ・どの設備で」を確定 | D&D で即時 |
| 作業者ロウ (集計) | 「誰がどれだけ忙しいか」を可視化 | 割当確定で即時 |
| ドロップ時パネル | 「誰が・何人で」を確定 | ドロップ直後にモーダル |
| サジェスト/警告 | 「ここを変えるとどうなるか」を提示 | バックグラウンドで非同期 |
11.3 役割スロットモデル — 1工程 = 設備1 + 役割N
複数作業者ケースをきれいに扱うために、工程テンプレートにこの構造を入れる。
工程テンプレ: 旋盤加工
設備候補: [A, B]
役割スロット:
- id: main
必須: true
人数: 1
スキル要件: [旋盤操作]
所要時間係数: 1.0 # この役割の有無で所要時間がどう変わるか
- id: support
必須: false
人数: 0..1
スキル要件: [旋盤補助 or 旋盤操作]
所要時間係数: -0.33 # サポート1人で33%短縮
タクト: 60秒 / 個
工程ごとに「設備1つを取り、役割スロットを埋める」という単一モデルで全ケースが書ける。
| ケース | 表現 |
|---|---|
| 単独作業 | main 1人だけ、support 任意 |
| 2人必須 | main 1人 + helper 1人 (両方必須) |
| 段取りと処理が分離 | main の前に setup スロット (人員が違う) |
| 検査担当が別 | main + inspector スロット |
「複数作業者必要」は、役割スロット という1段抽象を挟むと「設備割当の弟分」として扱える。
ジョブ ↔ 機械の二項関係に直接 ↔ 作業者 を追加すると組合せが破綻するが、役割スロット経由にすると変数も UI も整理される。
11.4 数量変更で所要時間が動くロジック
工程テンプレートから所要時間を計算する関数を定義する。
function computeDuration(template: ProcessTemplate, qty: number, slots: SlotAssignment[]): Duration {
const base = template.takt * qty; // タクト × 数量
const skillFactor = product(slots.map(s => s.skillFactor));
const helpFactor = 1 + sum(slots.map(s => s.durationCoef)); // 補助による短縮
return Math.ceil(base * skillFactor / helpFactor);
}
// 例:
// 旋盤加工 X 100個、メイン田中 (skill=1.0)、サポートなし → 60 * 100 = 6000s
// 同じくサポート高橋追加 (短縮係数 -0.33) → 6000 / 1.33 ≈ 4511s
// メインを鈴木 (skill=1.2 = 20%遅い) に → 6000 * 1.2 = 7200s
UI 側では:
- バーの 幅 = 所要時間 にスケール固定
- 数量はバー内のインライン編集フィールドで
[100]のように表示。値変更で即時再描画 - 役割スロットの追加/削除でも即時再描画
バーの幅が動的に変わると、隣のバーに衝突する。
このとき自動で押し出すか衝突を警告して止めるかで、UX の性格が大きく変わる。
私の推奨は 「衝突したらバーを赤くするだけ、勝手には押し出さない」。人間の意図を勝手に書き換えない、が原則。
11.5 ドロップ時のインタラクションフロー
D&D が成立した瞬間に走る処理を、時系列で書く。
[1] ドロップイベント (時刻 t=0ms)
→ 設備 m と時刻 s が確定
→ 工程テンプレから役割スロットを取り出す
[2] 候補作業者を取得 (t=0..50ms)
→ main スロット:
- スキルマッチ: skill[w] ⊇ template.skills
- その時刻に空いている (集計レーンから判定)
- 過去の同種工程で多く担当した順 (履歴ボーナス)
→ 上位3候補にスコアを付ける
[3] ドロップ時パネル表示 (t=50..100ms)
+--------------------------------------+
| 設備 B / 火曜 09:00 / 製品 Z 100個 |
| |
| メイン: ◉ 鈴木 (推奨) |
| ○ 田中 |
| ○ 佐藤 |
| |
| サポート: [+ 追加] |
| 追加すると -40分 |
| |
| 所要時間: 3h 20m |
| 検証: ✓ 整合OK |
| |
| [ 確定 ] [ キャンセル ] |
+--------------------------------------+
[4] ユーザーが選択して確定 (t=数百ms..数秒)
→ 設備割当 + 役割スロット割当が一括で確定
→ CP-SAT で部分検証 (時間制限 500ms)
→ OK ならバーを実線化、NG なら赤化して理由を表示
[5] バックグラウンドで LNS が動く (非同期)
→ 周辺のジョブを少しだけ抜いて挿し直す近傍を10回くらい試す
→ 改善候補があれば右サイドのサジェストパネルに出す
ポイントは [4] と [5] の分離。確定は速く (500ms 以下、ユーザーは待てる)、改善提案は遅く (秒オーダー、後追いで構わない)。
11.6 候補作業者のスコアリング
[2] のスコアリングは、以下の重み付き和でシンプルに始めるのが実務的。
function scoreCandidate(w: Worker, ctx: DropContext): number {
const skillScore = skillFit(w, ctx.template.skills); // 0..1
const availability = isAvailable(w, ctx.timeRange) ? 1 : 0; // hard 0/1
const historyScore = recentSameJobCount(w, ctx.template) / 10; // 0..1
const loadScore = 1 - currentLoad(w) / shiftCapacity(w); // 0..1
const adjacencyScore = sameMachinePrevJob(w, ctx) ? 0.3 : 0; // 直前同設備ボーナス
return availability * (
1.0 * skillScore +
0.5 * historyScore +
0.7 * loadScore +
1.0 * adjacencyScore
);
}
スコアの理由は UI に併記する:
鈴木 (推奨) ─ スキル◎ / 直前B稼働中 / 余裕○
田中 ─ スキル○ / 直前A稼働中 / 余裕△
佐藤 ─ スキル△ / 余裕◎
「なぜこの順位か」が見えると、人間は安心して推奨を選ぶか理由付きで違う人を選ぶ。
そして「違う人を選んだ」という事実が、重みのフィードバックになる (第10章 10.8 の校正サイクル)。
11.7 作業者ロウは「集計」だけ持つ
中央キャンバスの下半分の作業者ロウは、割当の入力 UI ではなく、結果の集計表示にする。
- バーが直接そこに置かれる訳ではない (置けると操作モデルが二重になる)
- 設備レーンのバーがどの作業者を取っているかが色とハッチで集計される
- ロウ上で 過負荷の時間帯が赤くハイライトされる (
load > shiftCapacity) - 作業者をクリックすると、その人が関わるバーがハイライトされる (逆引き)
作業者レーンに直接ドロップさせない「作業者レーンにバーをドロップ」「設備レーンにもドロップ」と二系統の操作モデルを作ると、ユーザーが混乱する。
操作は設備レーンに一本化。作業者はドロップ時パネルでの選択 + 集計ロウでの可視化 に留める。
11.8 サブロット (lot streaming) の扱い
第9章で出した「100個のうち50個できたら次工程」を UI に出すには、バーを 入れ子のセグメント として表現する。
+----------------------------------------------+
| 設備 B | [▮▮▮ Z-50] [▮▮▮ Z-50] |
| 前半 後半 |
+----------------------------------------------+
| 設備 C | [▮▮▮ Z-50] [▮▮▮ Z-50] |
| ↑ 設備Bで前半完了次第 |
+----------------------------------------------+
実装としては:
- 1つのジョブカードに「分割数」のフィールドを足す
- 分割数を上げるとバーが内部で n 個のセグメント に分かれる
- 各セグメントは独立に D&D 可能 (時刻ずらせる)
- 次工程のバーはセグメント単位で前工程と precedence で繋がる
小工場でいきなり分割数を3以上に上げると、計画が読めなくなる。
デフォルトは1、ボトルネック工程の前後でだけ2-3 に上げる UX が現実的。
分割数のスライダーは「上級モード」に隠しておくくらいで良い。
11.9 検証フィードバック — 部分 CP-SAT を 500ms で
ドロップ確定時に走る検証は、全体最適化ではなく部分整合チェック。
def validate_drop(schedule, drop, time_limit=0.5):
# ドロップ周辺 ± 数時間と、関与する作業者・設備の同時間帯だけ抜く
scope = neighborhood_of(drop, before=2h, after=2h, include=[drop.machine, drop.workers])
model = build_cp_model_local(schedule, scope, drop)
solver = cp_model.CpSolver()
solver.parameters.max_time_in_seconds = time_limit
status = solver.Solve(model)
if status == cp_model.INFEASIBLE:
return Failure(reason=infer_conflict(schedule, drop))
if status == cp_model.OPTIMAL or status == cp_model.FEASIBLE:
violations = check_soft(schedule, drop)
return Success(soft_violations=violations)
infer_conflict は どの制約が壊れたか を返すのが大事。「無理です」だけでは情報がない。
✗ 鈴木さん 金曜 14:00-17:00 に既に J7 (設備 B) が割当てられています
✗ 田中さんは設備 D のスキルがありません
⚠ 佐藤さんはこの週すでに残業3時間超過、追加でさらに残業発生
3段階のフィードバック:
| 種類 | 表示 | アクション |
|---|---|---|
| 整合不可 (hard 違反) | バー赤、確定不可 | 修正必須 |
| ソフト違反 (残業・切替過大) | バー黄、確定可だが警告 | 確認して進めるか戻すか |
| 改善余地 | バー緑、サジェスト出る | 任意 |
11.10 バックグラウンド LNS — サジェストの源
ユーザーの編集が落ち着いた瞬間 (例: 3秒間操作なし) に、LNS をバックグラウンドで動かす。
// クライアント側 (debounced)
const triggerBackgroundLNS = debounce(() => {
workerThread.postMessage({
type: 'suggest',
schedule: currentSchedule,
pinned: userEdits, // ユーザーが触ったものは固定
timeLimit: 3000,
});
}, 3000);
// ワーカースレッド (Web Worker or WASM)
function onSuggest(msg) {
const suggestions = [];
for (let i = 0; i < 10 && elapsed() < msg.timeLimit; i++) {
const candidate = lnsStep(msg.schedule, msg.pinned, scope='global');
const delta = msg.schedule.objective - candidate.objective;
if (delta > epsilon) {
suggestions.push({ candidate, delta, description: diff(msg.schedule, candidate) });
}
}
postMessage({ type: 'suggestions', top: topK(suggestions, 3) });
}
提案は 「適用」ボタン1つで反映できるようにする。1つ受け入れると、それも userEdits に追加されて、また LNS が動く。人間と最適化が交互に手を動かす。
11.11 状態管理の設計
最後にデータモデルだけ整理しておく。フロントエンドは以下の3層で持つのがクリーンだ。
// 1. 不変な仕様
interface ProcessTemplate {
id: string;
eligibleMachines: MachineId[];
slots: RoleSlot[];
takt: number;
}
// 2. 計画スナップショット (永続化される)
interface Plan {
jobs: Job[];
assignments: Assignment[]; // ジョブ → 設備 + 役割スロット割当
pinnedBy: Map<JobId, UserEdit>; // 人間が触ったかどうか
kpis: Kpis;
parent?: PlanId;
}
// 3. UI セッション (揮発)
interface EditorSession {
draft: Plan;
pending: DropEvent[]; // 確定前のドロップ
suggestions: Suggestion[];
validations: Map<JobId, ValidationResult>;
}
pinnedBy が裏方 LNS の「触ってはいけない範囲」を教える唯一の真実 (第10章 10.6 のピン留め LNS と直結)。これを忘れて全部破壊する LNS を回すと、ユーザーが意図した配置を勝手に塗り替えて、信頼を失う。
11.12 この章の振り返り
- D&D は設備割当を確定する。ドロップ後の作業者割当こそが UX の中心課題
- 画面は3層: 未割当 / 設備レーン + 作業者ロウ集計 / サジェスト・警告
- 工程は 設備1 + 役割スロットN で表現。複数作業者・段取り・検査も同じモデル
- 所要時間は タクト × 数量 × スキル / 助勢効果 で動的計算。バー幅は即時反映
- ドロップ時パネルで 候補作業者を理由付きで提示、1クリック確定
- 作業者レーンは集計表示専用。操作は設備レーンに一本化
- 検証は 部分 CP-SAT (500ms) で。「なぜ無理か」を理由付きで返す
- サジェストは 3秒 debounce のバックグラウンド LNS で。
pinnedByを尊重して触っていない領域だけ動かす - 状態管理は テンプレ / 計画スナップショット / UI セッション の3層分離