ブラウザーで動く発送センターの模型「小さな発送センター・ラボ」を作りました。24件の注文を、棚から商品を集める「収集」と、箱に詰める「梱包」の2工程で出荷します。担当の人数や運び方を変えると、人と荷物の流れがどう変わるかを比べられます。
この模型は、業務改善の記事で根拠として数字を引用しています(出荷・発送作業のボトルネックの見つけ方)。たとえば「収集を2人から3人にしても、最後の積み込みは409秒のまま変わらない」という数字です。
数字を記事に載せるなら、同じ条件で何度動かしても同じ値が出ることと、途中で注文が消えたり二重に数えられたりしていないことを保証する必要があります。この記事では、そのために入れた設計と検査をまとめます。
前提
- TypeScript + Vite の静的サイト。Canvas 2Dで描画し、計算はブラウザーの中で完結(サイト共通のアクセス解析以外の通信なし)
- 単体テスト:Vitest(シミュレーションは描画から切り離した純粋なロジック)
- 画面のテスト:Playwright + Chromium
- 条件は5つの軸で、2×3×5×4×2=240通り
| 軸 | 選択肢 |
|---|---|
| 棚配置 | 人気商品を近く/奥へ |
| 注文の入り方 | 一定ペース/朝にまとめて/波がある |
| 担当配分 | 収集1・梱包1/収集1・梱包2/収集2・梱包1/収集2・梱包2/収集3・梱包1 |
| 運び方 | 1件ずつ/台車で最大2件・3件・5件 |
| 梱包の速さ | 1件16秒/12秒 |
1. 作業の時刻は整数tickで持ち、秒への換算は表示のときだけ
時間の単位は1tick=100ミリ秒の整数です。すべての作業は「開始tick」と「終了tick」を持ち、終了tickになった瞬間に完了します。
export const DEFAULT_TIMINGS: Readonly<Timings> = {
singleEdge: 10, // 1件ずつ運ぶとき、床の1マスを進むtick
cartEdge: 12, // 台車を押すとき
picking: 40, // 棚から1個取る
unloadingBase: 20,
unloadingPerItem: 10,
packing: 160, // 梱包1件(道具を整えた台は120)
conveying: 60,
truckDeparture: 80,
};
歩いた距離も、メートルを足し合わせるのではなく、通った辺の数で数えます。移動は格子上の最短経路で、path.length - 1 がそのまま距離になります。
worker.action = {
type: "moving", path,
startTick: state.timeTick,
endTick: state.timeTick + edges * edgeTicks,
purpose,
};
作業の開始・終了時刻と距離は、すべて整数のまま計算します。小数が出るのは、平均所要時間の割り算と、画面で人を前後のtickの間に描く補間(後述の fraction)だけです。時刻そのものに 0.1 + 0.2 型の誤差が入らないので、同じ条件なら完了tickは必ず同じ値になります。
2. 同時刻に起きることの順番を、コードで1本に決める
離散時間のシミュレーションでいちばん結果を揺らすのは、同じtickに複数の出来事が重なったときです。たとえば同じtickに「収集担当が荷物を仮置き場に下ろし終える」と「梱包担当の手が空く」が起きたとします。梱包の割当を荷下ろしの完了処理より後に置けば、下ろしたばかりの荷物にすぐ梱包が始まります。前に置けば、梱包担当は1tick空振りし、その荷物は次のtickまで待たされます。どちらも間違いではありませんが、決めておかないと結果が揺れます。
そこで、1tickの処理順を step の中で固定しました。
export function step(input: SimulationState): { state: SimulationState; events: DomainEvent[] } {
if (input.status !== "active") return { state: clone(input), events: [] };
const state = clone(input); const events: DomainEvent[] = []; state.timeTick += 1;
// (トラック出発の判定は省略)
completeActions(state, events); // 1. 終了tickに達した作業を完了させる
arrive(state, events); // 2. この時刻に届く注文を受け付ける
continueTrips(state); // 3. 収集担当の便を次の段階へ進める
state.maximumPackingQueue = Math.max(/* 梱包待ちの件数 */);
assignPackers(state, events); // 4. 空いた梱包担当に次の注文を渡す
assignPickers(state, events); // 5. 空いた収集担当に次の便を組む
startTruckIfReady(state);
return { state, events };
}
各段階の中でも、作業者はID順、注文は「到着tick→注文ID」の順、梱包待ちは「荷下ろしtick→注文ID」の順に並べてから処理します。Map や Set の走査順や、配列の元の並びに頼る場所を残さないためです。
const next = state.orders
.filter((item) => item.state === "packingQueue")
.sort((a, b) => (a.unloadedTick! - b.unloadedTick!) || a.id.localeCompare(b.id))[0];
乱数は使っていません。そのため、240通りのどれを何回動かしても同じ結果になります。記事の表は、アプリで同じ条件を選べば誰でも同じ値を確かめられます。
3. stepは純粋関数にし、描画とは「割合」でだけつなぐ
step は受け取った状態を複製し、1tick進めた新しい状態を返します。元の状態は書き換えません。
画面側は、requestAnimationFrame で実時間を積み上げ、100ミリ秒(×再生速度)たまるごとに step を呼びます。端数は fraction(0〜1)として描画に渡し、人や台車の位置を前後のtickの間で補間します。
const snapshot = (): PlayerSnapshot =>
structuredClone({ phase, state, fraction: accumulator / 100, runId });
この分け方にしておくと、テストでは finish() で最後まで一気に回し、画面では同じ step を少しずつ回せます。画面に出る数字と、テストが検査した数字が同じ関数から出るので、「アニメーションでは速そうに見えたのに、数字は違う」というずれが起きません。
4. 1tickごとに不変条件を検査する
結果の数字が合っていても、途中で注文が二重に処理されていたら意味がありません。そこで、代表的な16の条件について、1tick進めるたびに次の不変条件を確かめています。
while (state.status === "active" && state.timeTick < 100000) {
state = step(state).state;
if (state.orders.length !== 24 || new Set(state.orders.map((o) => o.id)).size !== 24)
throw new Error("注文の消失または重複");
if (!state.workers.every((w) => !w.trip || w.trip.orderIds.length <= CART_CAPACITY[config.transport]))
throw new Error("便の上限超過");
const owned = state.workers.flatMap((w) => w.trip?.orderIds ?? []);
if (new Set(owned).size !== owned.length) throw new Error("注文の二重所有");
if (state.orders.filter((o) => o.state === "packing").length > plan.packers)
throw new Error("梱包担当より多い同時梱包");
for (const order of state.orders) {
const rank = ranks.indexOf(order.state);
if (rank < previousRanks.get(order.id)!) throw new Error("注文状態の逆行");
previousRanks.set(order.id, rank);
if (order.state === "packing" && order.unloadedTick === undefined)
throw new Error("受渡前の梱包開始");
}
}
注文の状態は scheduled → waiting → assigned → picked → packingQueue → packing → conveying → shipped の一方向にしか進みません。この並びを数値の順位にしておけば、「逆戻りしていないか」を1行で検査できます。
expect ではなく throw にしているのは、1tickごとに数千回呼ばれるループの中だからです。ただし、いまのエラー文は壊れ方の種類だけで、どの条件のどのtickで壊れたかは入っていません。条件が16に増えた今は、throw new Error(注文の消失または重複 ${JSON.stringify(config)} tick=${state.timeTick}) のように条件とtickを文面に含めておくほうが、失敗したときに追いやすくなります(未対応)。
5. 240通りを総当たりして、記事の数字と照合する
代表16条件の不変条件テストとは別に、記事を書くときには240通りすべてを finish() で最後まで回しました。finish() は10万tick以内に終わらなければ例外を投げるので、これで全条件が完走することも確かめられます。ただし、1tickごとの不変条件検査は代表16条件だけで、240通り全部にはかけていません。
使い捨てのVitestファイルで結果をTSVに書き出し、READMEの表の行と突き合わせました。
const rows: string[] = [];
for (const placement of ["popularNear", "popularFar"] as const)
for (const arrival of ["steady", "burst", "waves"] as const)
for (const staffing of ["pick1Pack1", "pick1Pack2", "pick2Pack1", "pick2Pack2", "pick3Pack1"] as const)
for (const transport of ["single", "cart2", "cart3", "cart5"] as const)
for (const packing of ["standard", "tooled"] as const) {
const r = getResult(finish({ placement, arrival, staffing, transport, packing },
createScenario(placement, arrival)))!;
rows.push([placement, arrival, staffing, transport, packing,
r.lastShipmentTick, r.averageLeadTicks.toFixed(2),
r.totalWalkingDistance, r.maximumPackingQueue].join("\t"));
}
writeFileSync("dispatch_all240.tsv", rows.join("\n") + "\n");
READMEの比較表に載せていた24行の値とは、すべて一致しました。そのうえで、総当たりにしたことで、代表条件だけでは気づきにくい結果が見えました。
- 収集が1人の体制では、梱包を1人から2人に増やしても、結果が1tickも変わらない(詰まりが収集側にあるため)
- 注文が一度に届く条件で収集2・梱包2にすると、台車5件は台車3件より遅い(梱包の手前に荷物がまとまって届く)
総当たりは2分かかった:純粋関数の代償
この総当たりは、手元のPC(Node.js 22)で111秒かかりました。合計で約102万tickなので、1tickあたり約0.11ミリ秒です。Vitestの中で回す場合は、テストのタイムアウトを数分に延ばしておく必要があります。
原因は、step が毎tick structuredClone で状態全体を複製していることです。画面の再生は4倍速で1秒あたり40tick、8倍速でも80tickしか進めないので、この作りで十分です。一方、100万tick単位で回すと、複製の費用がそのまま効いてきます。
いまは「記事を書く前に1回だけ回す検証」なので、2分は許容範囲として、作りは変えていません。条件の軸がさらに増えて総当たりを日常的に回すようになったら、finish() の中だけ状態を複製せずに直接書き換える入口を分けるのが次の一手です(未実装)。
6. 条件の軸を増やしたら、描画の分岐も全部の選択肢で回す
運び方は、もともと「1件ずつ」と「台車3件」の2択で設計し、あとから台車2件と5件を足しました。こうした軸の追加では、描画側に transport === "cart3" のような決め打ちが残りがちです。残っていると、台車2件と5件では台車が描かれず、手で運んでいるように見えます。 しかも計算は正しいので、ロジックのテストはすべて通ります。
そこで、描画のテストを選択肢ごとに回しています。ただし、このテストが確かめているのはCanvasに描かれた絵そのものではなく、描画関数に渡すデータです。
画面に出るまでには、3つの段階があります。
| 段階 | 中身 | このテストで見ているか |
|---|---|---|
| ① 計算 |
step が作るシミュレーションの状態(誰がどの注文を運んでいるか) |
見ていない(別のテストで検査) |
| ② 描画用のデータ |
buildSceneFrame が状態から作る「何をどこに描くか」(台車の数、人の姿勢、荷物の高さ) |
見ている |
| ③ 実際の表示 |
drawCart などがCanvasに描いた画素 |
見ていない |
test.each(["cart2", "cart3", "cart5"] as const)("draws a cart and pushes it for %s", (transport) => {
// (収集担当が台車で荷物を運んでいる途中の状態を作る:省略)
const cartFrame = buildSceneFrame(cartRun, 0, view);
expect(cartFrame.people[0].pose).toBe("push"); // 押す姿勢
expect(cartFrame.carts).toHaveLength(1); // 台車が1台
expect(cartFrame.packages[0].baseZ).toBe(17); // 荷物は荷台の高さ
});
冒頭に書いた「transport === "cart3" の決め打ち」は②の段階にあったので、このテストで防げます。一方で、③の drawCart 自体が壊れて台車が透明になった場合は、このテストは通ってしまいます。②を確かめたからといって、③の表示まで確かめたことにはなりません。
③まで見るなら、Playwrightで運び方ごとに同じ時刻まで進め、toHaveScreenshot() で画面を比べるのが次の一手です(未実装)。
計算と描画を分けた設計では、軸を増やしたときに計算側だけ直して描画側が取り残されることがあります。選択肢を足したら、=== "既存の値" で分岐している箇所を検索し、テストも test.each で全選択肢に広げます。そのうえで、どの段階まで観測しているテストなのかを、テスト名やコメントで明示しておきます。
2026-10-11追記:コメントでご指摘をいただき、このテストが観測している範囲(②の描画用のデータまで)を明確にしました。
7. 比較画面で、因果を言い過ぎない
比較画面では、最初に最後まで動かした結果を自動で「比較基準」にし、次の結果との差を秒で表示します。2つ以上の条件を同時に変えた場合は、比較欄に「複数の条件を変えた結果です」と出します。どの条件が効いたのかを、画面が断言しないためです。
この挙動はPlaywrightで確かめています。page.clock.install() で時計を差し替え、runFor で再生を早送りします。
await page.clock.install();
await page.goto("/dispatch-lab/");
// テスト専用に80倍速を足す(画面の選択肢は4倍速・8倍速)
await page.getByLabel("再生速度").evaluate((select: HTMLSelectElement) => {
select.add(new Option("テスト", "80")); select.value = "80";
select.dispatchEvent(new Event("change", { bubbles: true }));
});
await page.getByRole("button", { name: "発送を始める" }).click();
await page.clock.runFor(20_000);
await expect(page.getByTestId("run-status")).toHaveText("全注文の発送が完了しました");
await page.getByLabel("棚配置").selectOption("popularFar");
await page.getByLabel("運び方").selectOption("cart3");
await page.getByRole("button", { name: "発送を始める" }).click();
await page.clock.runFor(20_000);
await expect(page.locator("[data-comparison]")).toContainText("複数の条件を変えた結果です");
実時間で待たないので、1回の再生に数分かかるアニメーションでも、待ち時間なしで最後まで進められます。
まとめ
- 作業の時刻は整数tick、距離は辺の数で持ち、秒への換算は表示のときだけ
- 同時刻の処理順を
stepの中で1本に決め、各段階の中もID順に並べてから処理する -
stepを純粋関数にして、テストと画面が同じ関数から数字を得るようにする - 代表条件で、1tickごとに不変条件(消失・重複・二重所有・上限超過・状態の逆行)を検査する
- 記事に数字を載せる前に、全条件を総当たりで完走させて照合する。純粋関数の複製費用は総当たりで効く
- 軸を増やしたら、描画の分岐も全選択肢でテストする。ただし、描画用のデータの検査と、実際の表示の検査は別物として扱う
模型の数字はすべて架空の条件での計算結果で、実在の倉庫の実績ではありません。それでも、「同じ条件なら必ず同じ値になる」「代表条件で途中の注文が壊れていない」ことを確かめておけば、少なくとも模型の中の比較としては根拠に使えます。
業務改善の観点で、この模型から何が読み取れるか(人を増やしても速くならない条件、まとめ運びが逆効果になる条件)は、こちらにまとめています。
ママゴトラボは、業務の自動化ツールを**「何を作るか」を決めるところ(要件定義)から設計してつくる**開発ラボです。
業務の流れの見える化から、自動化ツールの開発・保守まで承っています。
👉 ママゴトラボ|業務自動化の開発ラボ
