Chapter 11

ガントエディタの UI/UX 設計 — ドロップから作業者割当まで

画面の左、未割当の工程カードが並んでいる。
中央には設備ごとのレーン。A、B、C、D の4本が縦に積まれて、右に時間軸が伸びている。

主任が「製品 Z、100個」のカードを掴んで、設備 B のレーンの火曜午前にドロップする。
バーの幅が自動で計算され、3時間20分のブロックが現れる。

──さあ、ここからだ。
誰がやるのか? メイン1人で済むのか? サポートを付けたら短くなるか?
ドロップ直後に決めなければならないことが、まだ残っている。

第10章で「ガントが主役、最適化は裏方」という方針は決まった。
この章はその具体形 — D&D の瞬間から作業者割当が確定するまで の UI/UX 設計案を書く。

11.1 解くべきは「ドロップ後の割当」

設備割当は D&D の着地点 で決まる。これは直感的で、ほとんど迷わない。
迷うのは:

これら4つを ドロップ後 5 秒以内に解決させる UI が今回のスコープだ。

ここで「自動最適化ボタン」を押させてはいけない。
人間が ドロップした瞬間の文脈 (誰を頭に思い浮かべていたか) を消費しないと、知識が UI に乗らない。
逆に「全部選ばせる」のも面倒。候補を提示して、1クリックで確定 が筋。

11.2 画面全体の構成 — 三層レイアウト

KPI ライブ総遅れ 0h ・ makespan 32h ・ 切替 4h ・ 残業 1h ・ 前計画差分 12%未割当工程Z-100個タクト60sY-200個タクト30sX-50個 急ぎ設備レーンAJ1J5BJ2Z (ゴースト)CJ3DJ4作業者ロウ (集計)田中佐藤鈴木サジェスト・警告⚠ 鈴木さん金 14:00 重複💡 Z をサポート追加で -40分候補: 高橋ドロップ時パネル (モーダル):設備 = B / メイン: [鈴木 ✓推奨] / サポート: [+] / 数量: 100 / 所要 3h20m / 検証: OK
図 11.1 — 三層レイアウト。左: 未割当、中央: 設備レーン + 作業者ロウ、右: サジェスト/警告、下: ドロップ時パネル。

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 側では:

バーの幅が動的に変わると、隣のバーに衝突する。
このとき自動で押し出す衝突を警告して止めるかで、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 ではなく、結果の集計表示にする。

作業者レーンに直接ドロップさせない

「作業者レーンにバーをドロップ」「設備レーンにもドロップ」と二系統の操作モデルを作ると、ユーザーが混乱する。
操作は設備レーンに一本化。作業者はドロップ時パネルでの選択 + 集計ロウでの可視化 に留める。

11.8 サブロット (lot streaming) の扱い

第9章で出した「100個のうち50個できたら次工程」を UI に出すには、バーを 入れ子のセグメント として表現する。

+----------------------------------------------+
| 設備 B  | [▮▮▮ Z-50] [▮▮▮ Z-50]              |
|           前半         後半                    |
+----------------------------------------------+
| 設備 C  |       [▮▮▮ Z-50] [▮▮▮ Z-50]        |
|              ↑ 設備Bで前半完了次第           |
+----------------------------------------------+

実装としては:

小工場でいきなり分割数を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 この章の振り返り