「レジをもう1人増やせば、行列は短くなるはずだ」
そう決めて人を増やしたのに、終わる時刻がほとんど変わらなかった。そんな経験はないでしょうか。
原因は、たいてい「増やした場所」と「詰まっている場所」が違うことです。ただ、それを言葉で説明しても伝わりません。人が並んで、動いて、止まる様子が見えるほうが早い。そう考えて、喫茶店を模した待ち行列シミュレーションを作りました。
🔗 実物はこちらで触れます:喫茶店ラボ(架空の条件で流れを比べる模型です)
作り直す過程で効いた設計判断は、3つでした。
- 描画の順番は「一番手前の角」で決め、長い台は1マスずつに割る
- 計算の時計と、画面の時計を分ける
- 動きのテストは「止まっていること」で確かめる
以下は作り方の話です。TypeScript・Vite・Canvas 2D だけで、実行時の外部ライブラリも外部画像も使っていません。
結果だけ先に見たい方は、上のリンクから直接触れます。「どこに人を置くと全体が早く終わるか」を、条件を変えて比べられます。
なぜ「工程の箱」ではだめだったのか
最初の版は、工程ごとに箱を横並びにして、その中に人型を置いていました。見た目は整いますが、肝心なことが伝わりません。
- 箱が詰まっているのか、次の箱が遅いのかが分からない
- 人が「移動している」のか「待っている」のかが区別できない
- レジを増やしたときに、列が減って受け取り側に人が溜まるという肝心の変化が見えない
工程図としては正しくても、詰まりの移動が見えないのです。だから一つの店内を斜め上から見た絵に作り直し、人が入口から入って、並んで、レジへ歩いて、受け取り場所で待って、出口から帰るまでを連続して動かすことにしました。
判断1:描画順は「一番手前の角」で決める
斜め見下ろし(アイソメトリック)では、奥のものから順に描けば手前のものが自然に重なります。投影はこれだけです。
export function project(point: Point, z = 0): Point {
return {
x: 440 + 20 * (point.x - point.y),
y: 144 + 10 * (point.x + point.y) - 24 * z,
};
}
奥行きの順番は、床座標の x + y が小さいものから描きます。人物はその足元の座標で決められますが、家具でつまずきました。
カウンターは横に長い1つの箱です。これに1つの奥行きキーを与えると、カウンターの左端にいる客が、右端の家具まで覆ってしまいます。人物は点、家具は面なので、同じ扱いにできません。
解決は、長い家具をワールド座標1マス幅の区画に割って、区画ごとに奥行きキーを持たせることでした。
// 区画の境界はworldの整数に合わせる。端の区画だけ短くなる
for (let x = Math.floor(prism.minX); x < prism.maxX; x += 1) {
const minX = Math.max(x, prism.minX);
const maxX = Math.min(x + 1, prism.maxX);
segments.push({ id: `${prism.id}:${x}`, minX, maxX, /* ... */ });
}
ここで次の問題が出ます。区画に割ると、隣り合う塗りの境界が線として見えるのです。拡大するとカウンターが「立方体の列」になります。ポリゴンの縁がアンチエイリアスで背景と混ざるためでした。
対処は単純で、終端以外の区画だけ、描画時にわずかに右へ伸ばして重ねます。奥行きキーは重ねない値のまま使います。
const maxX = segment.endOfRun ? segment.maxX : segment.maxX + 0.04;
区画の端の面(+x側の面)も、終端の区画だけ描きます。中間でも描くと、やはり立方体の列に見えます。
判断2:計算の時計と、画面の時計を分ける
ここが、この種の「業務を模した画面」でいちばん間違えやすいところだと思っています。
人が歩く演出を入れると、歩いた時間まで待ち時間に足してしまうことがあります。そうなると数字が演出しだいで変わり、比較の道具として使えません。
そこで時計を2つに分けました。
- 計算の時計:1秒刻み。来店・受付・調理・提供だけで進む。演出を知らない
- 表示の時計:画面に出す時計。人が移動している間は止まる
移動中は時計の横に「移動中」と出し、「歩く演出の時間は、待ち時間の計算に含みません」と画面に常時書いています。時計が一時的に止まるのは仕様です、と明示しておかないと不具合に見えます。
計算側には、1秒ごとの途中経過を取り出す口だけを足しました。計算そのものは二重に実装しません。
export function advanceSimulationWithTrace(state: SimulationState, seconds = 1): TraceBatch {
const ticks: TraceTick[] = [];
let next = state;
for (let count = 0; count < seconds; count += 1) {
const events: TraceEvent[] = [];
const at = next.time;
next = processOneSecond(next, events); // 既存の計算にイベント収集先を渡すだけ
ticks.push({ at, events, after: next });
}
return { ticks, state: next };
}
描画側は、この途中経過を順番に消費するだけです。同じ1秒の中で「受付が終わった」「注文が始まった」が同時に起きても、入口→列→レジの順は省略しません。高速再生でも工程が飛ばないのは、この分け方のおかげです。
判断3:動きのテストは「止まっていること」で確かめる
Canvasのアニメーションは、毎フレーム絵が変わります。だから動いている画面のスクリーンショットを丸ごと比較するのは筋が悪い。いつ撮るかで結果が変わり、落ち続けるテストになります。
逆に、止まっているべき瞬間は完全に一致します。ここだけを画像で確かめました。
test("歩行も店員も一時停止で止まり、再開で描画が進む", async ({ page }) => {
await page.goto("/cafe-lab/");
await page.getByRole("button", { name: "お店を開く", exact: true }).click();
const canvas = page.locator("#cafe-scene");
await page.waitForTimeout(250);
await page.getByRole("button", { name: "一時停止", exact: true }).click();
const stopped = await canvas.evaluate((e: HTMLCanvasElement) => e.toDataURL());
await page.waitForTimeout(350);
expect(await canvas.evaluate((e: HTMLCanvasElement) => e.toDataURL())).toBe(stopped);
await page.getByRole("button", { name: "再開", exact: true }).click();
await expect.poll(() => canvas.evaluate((e: HTMLCanvasElement) => e.toDataURL())).not.toBe(stopped);
});
toDataURL() はCanvasの中身をそのまま文字列にして返します。ただし条件があります。CORS の条件を満たさない別ドメインの画像をそのCanvasに描くと、そのCanvasは「汚染された(origin-clean でない)」状態になり、toDataURL() は SecurityError を投げます(MDN: HTMLCanvasElement.toDataURL)。CDNのアイコンを1枚描くだけでも起きます。
これはCSPの設定とは別の仕組みです(CSPは読み込み先を制限するもので、Canvasの読み出し可否を直接決めるものではありません)。ただ、この模型ではそもそも外部の画像を1枚も読み込まない方針にしていました。人型もカップも、自前の文字パターンからその場で描いています。結果として、Canvasが汚染されることがなく、この検証をそのまま使えました。
外部アセットを持たない構成は、CSPを default-src 'self' のまま緩めずに済むという利点と、Canvasの中身をテストから読み出せるという利点が同時に付いてきます。
数値の回帰(4条件の待ち時間)は、実ブラウザではなく偽の requestAnimationFrame を差し込んだユニットテストで回します。実ブラウザは通しで1本だけにしました。全条件を実時間で回すと、それだけで数分かかるからです。
実際につまずいたところ(一次情報)
1. 一瞬だけ出る表示は、ポーリングでは捕まえられない
最後の客が帰るまでの「退店中」の案内は、実測で1秒に満たない表示でした。Playwrightの expect.poll の間隔は既定で [100, 250, 500, 1000] ミリ秒と、だんだん広がります(Playwright: Test assertions)。長く待つほど取りこぼします。
→ 表示の一瞬を追いかけるのはやめ、退店中も再生が続くという仕様は制御フローのユニットテストで検証する形に変えました。
2. アニメーション込みの完走は、思ったより長い
24人・50秒ごとの来店を「速い」再生のまま通しで完走させるテストは、ローカルのChromium(ヘッドレス)で実測 約66秒でした(テスト1本の所要時間)。歩く演出の時間は計算に足していませんが、画面上では工程ごとの移動が積み上がるため、実時間としては伸びます。最初に置いた40秒のタイムアウトでは足りません。だから実ブラウザでの通し確認は1本だけにして、残りは偽の requestAnimationFrame に寄せました。
3. 押したボタンが無効になると、キーボードのフォーカスが飛ぶ
「お店を開く」は押すと自分が無効になります。すると、フォーカスが本文の先頭へ落ちてしまいます。キーボードだけで操作している人は、そこで進めなくなる。実ブラウザの操作テストを書いて初めて気づきました。押した直後に、次に押せるボタンへフォーカスを移して直しています。
いま確かめられること
この模型は架空の条件で流れを比べるものです。実店舗や実業務の改善効果を示すものではありません。そのうえで、次の3つは画面で確かめられます。
- 人を増やす場所を変えると、詰まりが別の場所へ移ること
- 1件ずつ処理するのと、まとめて処理するのと、どちらが速いかは条件で変わること
- 同じ来客条件で2回動かして、結果を並べて比べられること
既定の条件(24人・50秒ごとに来店、受付30秒、1杯90秒)で動かすと、模型はこう返します。
| 店員の置き方 | 作り方 | 平均待ち | 最長待ち |
|---|---|---|---|
| レジ1人・調理2人 | 一杯ずつ | 120秒 | 120秒 |
| レジ2人・調理1人 | 一杯ずつ | 580秒 | 1,040秒 |
| レジ2人・調理1人 | まとめて(最大3杯) | 328.75秒 | 475秒 |
⚠️ これは架空の条件で動かした模型の計算結果です。実店舗や実業務の改善効果ではありません。
なお、レジ1人・調理2人のときは、一杯ずつでもまとめてでも結果は同じでした。調理側が詰まらないので、まとめる意味がないからです。見栄えのために差を作ってはいけないところです。
レジを1人増やして調理を1人減らすと、この条件では平均待ちが約4.8倍になりました。受付は速くなっても、飲み物を作る側が半分になるからです。そのうえで作り方を「まとめて」に変えると、同じ人員配置のまま平均待ちが半分近くまで戻ります。増やす場所と、やり方。効くのは片方だけではないということが、数字と画面の両方で見えます。
レジ担当・調理担当をそれぞれ1〜3人、来客数、来店の間隔、受付1件の時間、1件を作る時間、まとめて作る上限を画面から変えられます。実際の業務で使うときは、その職場で測った時間を入れて比べます。
FAQ
Q: Canvasのアニメーションは、どうやってテストすればいいですか?
A: 動いている画面の全体比較は避け、「一時停止したら同じ絵のままか」「開始したら絵が変わるか」を toDataURL() の一致・不一致で確かめます。数値の回帰は偽の requestAnimationFrame を使ったユニットテストへ寄せます。
Q: toDataURL() が SecurityError になるのはどんなときですか?
A: 別のドメインから読み込んだ画像をそのCanvasに描いたときです。CDNのアイコンを1枚描くだけでも起きます。自前で描画していれば起きません。
Q: 歩く演出の時間は、待ち時間に入りますか?
A: 入りません。移動中は画面の時計を止めています。計算の時計は来店・受付・調理・提供だけで進みます。
Q: スマートフォンでも店内全体が見えますか?
A: 見えます。幅320pxでも床の四隅・入口・出口が画面に収まるようにしています。横スクロールで全体を代用していません。
おわりに
作りながら何度も戻ったのは、「見た目をよくする」ではなく「何を見せたいのか」でした。詰まりの移動を見せたいなら、工程の箱ではなく一つの店内が要る。数字を信じてもらいたいなら、演出の時間を計算に混ぜてはいけない。設計の判断は、だいたいそこから決まりました。
受付・確認・作成・承認といった自社の流れで、どこに待ちが生まれているかを整理したい方へ。
いまの手順、工程ごとの担当人数、1件あたりのおおよその時間をテキストでお知らせいただければ、
どこを自動化すべきか、その前に整えるべき流れは何かを一緒に検討します。
👉 ご相談・お見積り(クラウドワークス):https://crowdworks.jp/public/employees/6942778
関連記事
- ブラウザ操作を記録して手順書を作るChrome拡張──業務の見える化をCSVの一括入力までつなげる
- 「うちの業務もこうなるのか」を、頼む前に触って確かめる──業務自動化の体験デモ
ママゴトラボは、業務の自動化ツールを「何を作るか」を決めるところ(要件定義)から設計してつくる開発ラボです。
属人化した業務の見える化から、自動化ツールの開発・保守まで承っています。
👉 ママゴトラボ|業務自動化の開発ラボ
その業務、自動化できるかもしれません
「毎回手でやっている作業」を、「何を作るか」を決めるところ(要件定義)から見直してツールにします。GAS・Python・VBAでの自動化のご相談を承っています。
ココナラで相談する →