GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

呼出表示器 実装9パターン — 待ち順と予想時間を計算する順番板

呼出表示器は、呼び出し番号と残り人数、予想待ち時間を数字で見せる待ち順の案内画面です。平均処理時間が変わると残り人数と掛け合わされた値も一緒に変わって予想待ち時間が数値として更新され、リストは空いた分だけ上に詰まって並び替わり、合計人数はその差ぶん繰り上がったり下がったりする。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

呼出表示器 実装9パターン — 待ち順と予想時間を計算で動かす順番板

九つは、画面を見る人が投げる質問の順に並べました。01 は今何番か、02 は何分待つか、03 は全体がどこまで進んだかを数字とリングで答え、04 は自分の前の番号が抜けたときリストがどう詰まるか、05 は呼んでも来ないときいつ呼び直すかを見せます。06 は窓口が複数あるときどこが速いか、07 は全体の人数が増減する幅、08 は優先対象が割り込む位置を計算し、09 はそのうち三つの計算を一画面に重ねます。順番表示を初めて作るなら、01・02・07 の三つを先に貼るだけでも、番号と時間と人数が一画面で噛み合って動きます。

パターン別コード

九項目それぞれ、値を計算する中心の部分だけを抜き出しました。残りのマークアップとスタイルは、デモの下の zip の中にそのまま入っています。番号と備考の文言はすべてデモ用のダミーで、実際のサーバーとは通信しません。

01残り人数を再計算して桁が繰り上がる呼び出し番号

新しい番号を呼ぶと rollDigits が、呼び出し番号の3桁と残り人数の2桁を、桁ごとに DIGIT_STAGGER_MS ずつ遅らせて出発させ、スロットのように順に転がします。残り人数は全体の42人から呼んだ回数を引いて計算し直した値です。病院の診察待ち画面のように、番号が変わる瞬間を目が逃してはいけない場所に使います。01 は面積16.7%・強度平均80.6で、23フレーム中10フレームが動きました。

残り人数を再計算して桁が繰り上がる呼び出し番号
新しいタブで開く

01 · 残り人数を再計算して桁が繰り上がる呼び出し番号 — 計測 累積面積 16.738% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

CALL_NUMBER_ROLL_MSrollDigitsDIGIT_STAGGER_MS
  function rollDigits(digits, num, nowMs) {
    if (reduce) { setDigitsImmediate(digits, num); return; }
    flash(callOdo.parentNode);
    var s = String(num);
    while (s.length < digits.length) s = '0' + s;
    for (var i = 0; i < digits.length; i += 1) {
      var v = Number(s[i]);
      if (v === digits[i].to) continue;
      digits[i].from = digits[i].value;
      digits[i].to = v;
      digits[i].start = nowMs + i * DIGIT_STAGGER_MS;
      digits[i].dur = CALL_NUMBER_ROLL_MS;
    }
  }

02平均処理時間と人数を掛けて再計算する予想待ち時間

処理記録の平均(avgMinutes)に残り人数を掛けた値が予想待ち分で、処理ボタンを押すたびにこの数字が ETA_TWEEN_MS かけて新しい値へ転がり、値が大きく変わるほど背後の光も強く点きます。飲食店のウェイティング登録画面のように、あと何分待つかだけを見る場所に使います。02 は面積12.8%・強度平均70.4で、23フレーム中7フレームが動きました。

平均処理時間と人数を掛けて再計算する予想待ち時間
新しいタブで開く

02 · 平均処理時間と人数を掛けて再計算する予想待ち時間 — 計測 累積面積 12.84% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ETA_TWEEN_MSavgMinutesSERVICE_LOG_MAX
  function avgMinutes() {
    var sum = 0;
    for (var i = 0; i < serviceLog.length; i += 1) sum += serviceLog[i];
    return sum / serviceLog.length;
  }

  function targetEta() {
    return avgMinutes() * remain;
  }

  function paintGlow(ratio) {
    glowEl.style.opacity = (0.5 * ratio).toFixed(3);
    glowEl.style.transform = 'translate(-50%, -50%) scale(' + (0.85 + ratio * 0.5).toFixed(3) + ')';
  }

03完了比率を計算して満ちる待ち行列の進捗リング

処理ボタンを1回押すと1人から3人がまとめて処理されたことにして、処理済み人数を全体の30人で割った比率ぶん paintRing が strokeDashoffset を縮めてリングを満たし、3分の1と3分の2の地点でリングの色を一段ずつ替えます。待ち行列全体がどこまで進んだかを一目で要約する板に使います。03 は面積15.8%・強度平均58.1で、23フレーム中11フレームが動きました。

完了比率を計算して満ちる待ち行列の進捗リング
新しいタブで開く

03 · 完了比率を計算して満ちる待ち行列の進捗リング — 計測 累積面積 15.848% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

RING_FILL_DURATION_MSCIRCUMFERENCEtweenRing
  function stageOf(ratio) {
    if (ratio >= 2 / 3) return 'is-late';
    if (ratio >= 1 / 3) return 'is-mid';
    return 'is-early';
  }

  function paintRing(ratio) {
    var offset = CIRCUMFERENCE * (1 - ratio);
    fillEl.style.strokeDashoffset = offset.toFixed(2);
    fillEl.classList.remove('is-early', 'is-mid', 'is-late');
    fillEl.classList.add(stageOf(ratio));
    pctEl.textContent = Math.round(ratio * 100) + '%';
  }

04移動距離を計算してスプリングで収まる待ちリスト

先頭の行を呼ぶと、その行は220msかけて高さが0へ畳まれ、下の行は畳まれた行の高さに8pxを足した距離(gap)だけ動いてから、linear() の曲線に沿って元の位置へ戻り、揺れながら収まります。新しい先頭の行には is-next が付いて少し大きくなるので、複数人が同じ画面を見る順番リストで次の番を知らせるのに使います。04 は面積32.8%・強度平均82.8で、23フレーム中14フレームが動きました。

移動距離を計算してスプリングで収まる待ちリスト
新しいタブで開く

04 · 移動距離を計算してスプリングで収まる待ちリスト — 計測 累積面積 32.848% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

LIST_SHIFT_SPRING_STIFFNESSshiftDurationSPRING_SETTLE_EASING
      row.animate(
        [{ height: h + 'px', opacity: 1 }, { height: '0px', opacity: 0 }],
        { duration: 220, easing: 'ease-in', fill: 'forwards' }
      ).onfinish = function () { row.style.display = 'none'; };

      var dur = shiftDuration(gap);
      below.forEach(function (el, i) {
        el.animate(
          [
            { transform: 'translateY(0px)' },
            { transform: 'translateY(' + gap + 'px)' },
            { transform: 'translateY(0px)' }
          ],
          { duration: dur, delay: 220, easing: SPRING_SETTLE_EASING, fill: 'both' }
        );
      });

05再呼び出しまでの残り時間をゲージで減らすノーショーカウントダウン

番号が呼ばれた時刻を覚えておき、経過時間を RECALL_COUNTDOWN_SEC(60秒)で割った残り比率ぶんだけゲージの幅を縮めます。残りが34%以下で警告色が点き、0になると期限切れの色に変わって SHAKE_DURATION_MS のあいだ左右に一度震えます。呼んでも返事のない番号を、そろそろ呼び直す時だと知らせる場所に使います。05 は面積6.2%・強度平均125.1ですが、23フレームすべてで動きました。

再呼び出しまでの残り時間をゲージで減らすノーショーカウントダウン
新しいタブで開く

05 · 再呼び出しまでの残り時間をゲージで減らすノーショーカウントダウン — 計測 累積面積 6.165% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

RECALL_COUNTDOWN_SECstepGaugeSHAKE_DURATION_MS
  function stepGauge(nowMs) {
    var elapsedS = (nowMs - callStartMs) / 1000;
    var ratio = Math.max(0, 1 - elapsedS / RECALL_COUNTDOWN_SEC);
    paintGauge(ratio);
    var remainSec = Math.max(0, Math.ceil(RECALL_COUNTDOWN_SEC - elapsedS));
    secEl.textContent = remainSec + '秒残り';
    if (ratio <= 0 && !expired) {
      expired = true;
      badgeEl.textContent = '期限切れ・再呼び出し必要';
      badgeEl.classList.add('is-expired');
      status.textContent = ticketNum() + 'の時間切れ・再呼び出しが必要です。';
      if (!shaken) { shaken = true; shake(); }
    }
  }

06窓口ごとの処理速度を計算して分散表示する複数カウンター状況

窓口ごとにためた処理記録の平均で棒の長さを決めます。最も遅い窓口が30%の幅になり、平均が短いほど長くなって、平均が最も短い窓口は fastestId として選ばれ、ほのかな光が点きます。窓口が並んで複数開いている銀行や役所の状況板に使います。06 は面積2.7%・強度平均97.2で、23フレーム中7フレームが動きました。

窓口ごとの処理速度を計算して分散表示する複数カウンター状況
新しいタブで開く

06 · 窓口ごとの処理速度を計算して分散表示する複数カウンター状況 — 計測 累積面積 2.726% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

BAR_TWEEN_MSfastestIdrecalcAll
  function avgMinutes(c) {
    if (c.log.length === 0) return 0;
    var sum = 0;
    for (var i = 0; i < c.log.length; i += 1) sum += c.log[i];
    return sum / c.log.length;
  }

  function maxAvg() {
    var m = 0.0001;
    COUNTERS.forEach(function (c) { m = Math.max(m, avgMinutes(c)); });
    return m;
  }

  function fastestId() {
    var best = COUNTERS[0];
    COUNTERS.forEach(function (c) { if (avgMinutes(c) < avgMinutes(best)) best = c; });
    return best.id;
  }

07差ぶん繰り上がる合計待ち人数カウンター

人が加わったり抜けたりすると、applyDelta が全体の人数に差を足して0から99の範囲に収め、2桁の数字が COUNT_ROLL_MS かけて新しい値へ転がります。増えるときと減るときでは別の色が FLASH_MS のあいだ点きます。入口や受付の上に掛けておく、全体の待ち人数の表示板に使います。07 は面積12.8%・強度平均63.4で、23フレーム中14フレームが動きました。

差ぶん繰り上がる合計待ち人数カウンター
新しいタブで開く

07 · 差ぶん繰り上がる合計待ち人数カウンター — 計測 累積面積 12.766% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

COUNT_ROLL_MSapplyDeltaFLASH_MS
  function applyDelta(delta, nowMs) {
    if (delta === 0) { paintStatus(0); return; }
    var next = Math.max(0, Math.min(99, TOTAL + delta));
    var actualDelta = next - TOTAL;
    TOTAL = next;
    rollDigits(TOTAL, nowMs);
    board.classList.remove('is-up', 'is-down');
    if (actualDelta > 0) board.classList.add('is-up');
    else if (actualDelta < 0) board.classList.add('is-down');
    flashUntil = nowMs + FLASH_MS;
    paintStatus(actualDelta);
  }

08優先順位の差を計算して割り込む優先呼び出しレーン

優先の受付が入ると、priorityInsertIndex が既存の行の重みを上から順に調べ、新しい重み(10)以上の行が上から続く区間の直後を挿入位置に決めます。その位置に優先バッジ付きの行が現れ、下の行は移動の前後の位置の差だけスプリングで押し下げられます。優先呼び出し対象の表示のように、一般の順番の間へ差し込む行がある待ちリストに使います。08 は面積27.4%・強度平均82.1で、23フレーム中10フレームが動きました。

優先順位の差を計算して割り込む優先呼び出しレーン
新しいタブで開く

08 · 優先順位の差を計算して割り込む優先呼び出しレーン — 計測 累積面積 27.413% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

PRIORITY_SHIFT_SPRING_STIFFNESSpriorityInsertIndexinsertPriority
  function priorityInsertIndex(newWeight) {
    var idx = 0;
    for (var i = 0; i < ORDER.length; i += 1) {
      if (ORDER[i].weight >= newWeight) idx = i + 1; else break;
    }
    return idx;
  }

  function paintStatus(row, idx) {
    status.textContent = row.ticket + ' 優先受付・' + (idx + 1) + '番目の位置に割り込みました。';
  }

09複数の計算を一画面に集めた順番板ヒーロー場面

呼び出しボタン一つが、01 の番号の繰り上がりと、04 の行の畳みとスプリングと、07 の人数カウンターを、一回の呼び出し関数の中でまとめて出発させます。受付ボタンは新しい行を末尾に付けて、人数の数字だけを転がします。店の入口の待ち順番画面のように、番号とリストと人数が同時に動くべき完成場面に使います。09 は面積26.1%・強度平均59.5で、23フレーム中18フレームが動きました。

複数の計算を一画面に集めた順番板ヒーロー場面
新しいタブで開く

09 · 複数の計算を一画面に集めた順番板ヒーロー場面 — 計測 累積面積 26.094% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

HERO_LIST_SPRING_STIFFNESSHERO_CALL_ROLL_MSflashTotal
  function joinQueue(nowMs) {
    var ticket = nextTicket;
    nextTicket += 1;
    var id = 'j' + ticket;
    var li = document.createElement('li');
    li.className = 'whb__item';
    li.id = 'whbRow-' + id;
    fillRow(li, ticket + '番', '待ち受付');
    listEl.appendChild(li);
    rows[id] = li;
    if (!reduce) {
      li.animate(
        [{ opacity: 0, transform: 'translateY(6px)' }, { opacity: 1, transform: 'translateY(0px)' }],
        { duration: 240, easing: 'ease-out' }
      );
    }
    var nextTotal = Math.min(99, total + 1);
    var dir = nextTotal - total;
    total = nextTotal;
    rollDigits(totalDigits, total, nowMs, HERO_COUNT_ROLL_MS);
    flashTotal(dir, nowMs);
    paintStatus();
  }

値が反応を変える場所 — 桁の繰り上がり速度・再呼び出しまでの時間・リスト移動の弾性

番号板がどれだけせわしなく感じられるかは、項目の数ではなく、この表の三つの数値と三つの定数が決めます。

項目 面積 強度平均 動いたフレーム
01 呼び出し番号の繰り上がり 16.7% 80.6 10/23 フレーム
02 予想待ち時間 12.8% 70.4 7/23 フレーム
03 完了比率リング 15.8% 58.1 11/23 フレーム
04 リストのスプリング 32.8% 82.8 14/23 フレーム
05 再呼び出しカウントダウン 6.2% 125.1 23/23 フレーム
06 複数窓口 2.7% 97.2 7/23 フレーム
07 合計人数カウンター 12.8% 63.4 14/23 フレーム
08 優先呼び出しレーン 27.4% 82.1 10/23 フレーム
09 ヒーロー順番板 26.1% 59.5 18/23 フレーム

05 と 06 の面積が10%を下回るのは、コンポーネントの記事ではごく普通のことです。05 は細い棒が一本縮むだけで面積は小さくても、ゲージが毎フレーム縮むので23/23で動き続け、06 は棒が三本、短く伸びて止まる程度なのでフレーム数も少なくなります。01・02・03 には値が変わるとき数字板が一瞬明るくなる is-flash の層が載っています。この層は計算ではなく表示で、そのぶん測定面積を押し上げます。

CALL_NUMBER_ROLL_MS は番号の一桁が転がって到着するまでの時間、RECALL_COUNTDOWN_SEC は呼び出しから再呼び出しまで待つ秒数、LIST_SHIFT_SPRING_STIFFNESS はリストが位置に収まる時間を決めます。04 の移動時間は 60 + 100000 / 弾性 に間隔の項を足して計算するので、弾性が大きいほど短くなります。05 はデモの再生中だけ60秒を40倍に圧縮して、短いキャプチャの中で期限切れまで見せます。ボタンを押すと圧縮が切れ、実際の秒数で動きます。

どこで壊れるか — 人数急増時の桁の重なり・再呼び出しタイマーのずれ・優先呼び出しの衝突

01 の呼び出し番号板は3桁(buildOdometer(callOdo, 3))で作られていて、rollDigits は番号を文字列にして先頭の3文字だけを読みます。番号が4桁になると、1000 の先頭3文字の 100 が板に出ます。07 と 09 の2桁の板は99で値を止めますが、01 の呼び出し番号にはそういう上限がありません。人数が急に増えて桁が増えうる板では、桁数を値から計算して作る必要があります。番号を続けて呼ぶと、まだ回っている桁は from を今見えている値に差し替え、そこから続けて転がるので、前の繰り上がりと重なっても値が飛ぶことはありません。

05 は呼び出し時刻と毎フレームの経過時間の差から残り時間をそのつど計算し直すので、フレームが何回か抜けても時間はずれません。ただ、ブラウザがタブを休ませているあいだ requestAnimationFrame が止まると、戻った最初のフレームで経過時間が一気に大きくなり、ゲージが一息で期限切れの状態へ移ります。コードの構造から出てくる動作です。08 は行を入れるたびに下の行へ新しい animate を重ねますが、前のアニメーションを取り消す cancel() の呼び出しが0件で、行数の上限もありません。優先受付を続けて押すと、前の移動の途中の位置が次の計算に混ざり、リストが伸び続けます。

紙の順番札のように番号と備考を一行に置くこの構成へ、実際の現場の規模の受付をつなぐときは、行数の上限と移動中の取り消しを一緒に入れるほうが安全です。この三つの限界まで含めた九つのソースをそのまま開いて確かめるには、ダウンロードした zip を解凍するときにパスワード auwdqs3v を入力してください。

変形3つとアクセシビリティ

項目 定数 値 変わり方
04 LIST_SHIFT_SPRING_STIFFNESS 260 → 140 値を上げると前の番号が抜けたときリストが速く硬く収まり、値を下げると揺れながらゆっくり収まります
01 CALL_NUMBER_ROLL_MS 480 → 240 値を下げると新しい番号が呼ばれたとき桁がほぼ瞬時に切り替わり、値を上げるとスロットのようにゆっくり繰り上がります
05 RECALL_COUNTDOWN_SEC 60 → 30 値を上げると応答がなくても再呼び出しまで余裕を持って待ち、値を下げるとゲージがすぐ減って再呼び出しの警告が早く出ます

04 の移動時間は、間隔の項を除いた基本の値で、弾性260のとき約445ms、140のとき約774msに延びます。prefers-reduced-motion: reduce の分岐は、matchMedia で九項目すべてに入っています。動きを減らす設定の訪問者には自動再生が始まらず、01・07・09 は requestAnimationFrame のループ自体を回しません(05 はループを続けますが、ゲージの補間をせず paintGauge(1) で即座に塗ります)。ボタンを押すとアニメーションなしで結果がすぐ反映され、aria-live="polite" の状態行も一緒に読み上げます。キーボードの経路も九項目すべてにあり、keydown リスナーは合計20個で、ボタンが三つの 07 と 09 が3個ずつ、残りが2個ずつです。操作は Enter と Space で行います。状態行(aria-live)は項目ごとに一つで九つ、リストのマークアップがある 04・06・08・09 の四か所には role="list" が付いています。

呼出表示器はクリニック 順番表示のように平均処理時間だけで待ち順を予測できるか?

時間は予測できますが、順番は別に決めておく必要があります。02 は直近の処理記録の平均に残り人数を掛ける一つの計算で予想待ち分を出しますが、誰が先に呼ばれるかは、この平均とは関係なく、すでに入った番号の順か、08 のような重みで決まります。平均は直近12件だけを残して古いものから捨てるので、急に遅くなった窓口があれば、数件処理するうちに予想時間がそれに追いついて上がります。待ち時間 表示を作るなら、番号と段階を一行で進める画面ではステップインジケーター デザイン 9パターン — 物理計算で仕上げる進行段階と見比べるとよく、05 のように数字が減って限界で色が変わるゲージが必要ならcss プログレスバー 9選 — ゲーム風に満ちる9つの方式、制限時間のカウントダウンがもっと気になるならqr コード アニメーション css 9選 — 残り時間の計算を続けて読む価値があります。呼び出し表示 の一覧を現場に出す前に、まず自分の窓口数と人数の上限を決めておくと、桁の重なりや行の伸びを先に避けられます。

呼出表示器を現場の画面に載せる前によくある質問

呼出表示灯のような番号発行機の画面にもこの順番板をそのまま貼れますか

表示側なら貼れます。九項目はどれも番号と人数を受け取って描くだけで、発行や印刷はしないので、発行機側が番号を渡す場所に 01 の rollDigits や 07 の applyDelta の呼び出しをつなげば動きます。

予想待ち時間を平均以外の値で計算してもよいですか

できます。02 の targetEta は平均に残り人数を掛ける一行なので、その行だけ別の式に替えても、数字が転がる部分はそのまま再利用できます。

再呼び出しまでの60秒が長すぎるときはどこを直しますか

RECALL_COUNTDOWN_SEC の値を下げます。変形の表の 05 のように30にすると、ゲージの幅の計算も残り秒数の文言も同じ定数から出るので、ほかの場所には手を入れなくて済みます。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。