「レジをもう1人増やせば、行列は短くなるはずだ」

そう決めて人を増やしたのに、終わる時刻がほとんど変わらなかった。そんな経験はないでしょうか。

原因は、たいてい「増やした場所」と「詰まっている場所」が違うことです。ただ、それを言葉で説明しても伝わりません。人が並んで、動いて、止まる様子が見えるほうが早い。そう考えて、喫茶店を模した待ち行列シミュレーションを作りました。

🔗 実物はこちらで触れます:喫茶店ラボ(架空の条件で流れを比べる模型です)

作り直す過程で効いた設計判断は、3つでした。

  1. 描画の順番は「一番手前の角」で決め、長い台は1マスずつに割る
  2. 計算の時計と、画面の時計を分ける
  3. 動きのテストは「止まっていること」で確かめる

以下は作り方の話です。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秒ごとの途中経過を取り出す口だけを足しました。計算そのものは二重に実装しません。

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つは画面で確かめられます。

既定の条件(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での自動化のご相談を承っています。

ココナラで相談する →
ブログ一覧に戻る