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

棒グラフ アニメーション 9種 — 伸びる・積み上がる・競う棒

データが変わったのに棒が瞬間移動すると、何が増えたのか目が追えません。そこで01番は棒をばねで伸ばして目標を一度越えてから戻し、02番は順位が入れ替わるときに棒が列を移りながら走り、07番は柱の側面の明暗を光の向きから計算します。

架空の売上の数字で、九つの棒を伸ばして競わせてみる

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

棒の高さに使った数字の出どころ

棒の高さはすべて、32ビット整数をかき混ぜるmakeRng(seed)から取り出した架空の値です。シードを470と4702〜4709に固定しているので、ページを開き直しても同じ棒が同じ高さまで伸び、名前も「項目 A」〜「項目 J」、06の#1〜#60、07のA1〜D4、08の区間番号のように置き場所を示すだけのものです。乱数関数や現在時刻を読むコードは九つのファイルのどこにもないため、この記事に書いたミリ秒とピクセルの数値は、同じ順にボタンを押せばそのまま再現できます。

レポートとダッシュボードで九つの盤が受け持つ場所

売上ダッシュボードを開いた直後に数字が立ち上がるグラフには01、月ごとの順位が入れ替わる流れを見せるなら02が合います。チャネル別の流入のように一本の棒の中に取り分がいくつもある数字なら、積み上げ 棒グラフ アニメーションの要点は03のように下の層の合計を上の層の床へ渡すことで、アンケートの項目名が長くて縦に立てにくいときは04の横棒が場所を取りません。

値がリアルタイムで増える訪問者数は05の目盛りの取り直し、日ごとの記録が詰まった年間グラフは06の魚眼の拡大が受け止めます。トップ画面で地域別の数値を立体で見せる07、応答時間がどこに集まるかを説明する08、前月比の上がり下がりを一枚にまとめる09までが、この記事で想定した使いどころです。

棒グラフ アニメーション 9種 — ばねで伸びる・順位の競走・積み上げの層・横棒のラベル・目盛りの取り直し・魚眼の拡大・等角の柱・粒のヒストグラム・増減の基準線

並べ順は、棒が初めて立ち上がる瞬間(01)から始まり、値が時間とともに変わって積み重なる場面(02・03)、文字と目盛りが棒に合わせて自分で位置を選ぶ判断(04・05)、ポインターと光でグラフをのぞき込む場面(06・07)を通って、棒の形そのものが分布と増減を語る盤(08・09)で終わります。絵はどの盤もキャンバス一枚の上の2D描画呼び出しで作られ、動きは16msの固定ステップで回るrequestAnimationFrameのループが担当します。style.cssの@keyframesは九つのファイルとも0個で、CSS側の遷移はボタンの背景色ひとつだけです。

2秒の録画24枚で測った動き

録画は盤ごとに480×300の大きさで2秒、24枚です。すぐ隣の2枚どうしを23回比べて画面が変わった回数を数えたので、フレームの欄は23を分母にしています。

盤 変わった画面の広さ(累積面積) 強度の平均 動いたフレーム
01 ばねで伸びる棒 34.1% 101.8 23/23
02 順位の競走 42.2% 131.1 23/23
03 積み上げの層 18.4% 145.5 22/23
04 横棒のラベル 16.5% 104.5 23/23
05 目盛りの取り直し 35.7% 140.5 23/23
06 魚眼の拡大 40.1% 72.0 23/23
07 等角の柱 16.8% 61.2 23/23
08 粒のヒストグラム 24.9% 105.2 23/23
09 増減の基準線 36.4% 100.7 23/23

累積面積が10%を下回った盤はありません。04と07が16%台でいちばん狭いのは、04は棒の先と数字の文字だけ、07は柱の面の明るさと影だけが変わり、格子そのものは動かないからと考えられます。07は強度の平均も61.2と九つのうちでいちばん低く、広い面の明るさが少しずつしか移らない場面だからと考えられます。

01目標の高さを一度越えてから戻る、ばねで伸びる棒

棒ごとに高さと速度を別々に持ち、16msのステップごとに目標へ引く力(SPRING_STIFFNESS 170)と速度を消す力(SPRING_DAMPING 12)を足して先に速度を直してから高さを動かし、出発は棒ごとに70msずつずれて8本目の棒は490msになってやっと動き出します。減衰比が12 ÷ (2√170) = 0.46なので、目標60の棒は256msに70.6まで伸びて目標を17.7%越え、640ms以降は目標±0.5の内側に留まり、速く動いている棒ほど明るく塗って目標線より上にはみ出た部分にもう一枚重ね塗りするため、行き過ぎが目に留まります。棒グラフ 伸びる アニメーションを売上ダッシュボードの最初のグラフに置きたいときに向いた盤で、2秒の録画では23回の比較のすべてで画面が変わりました。

目標の高さを一度越えてから戻る、ばねで伸びる棒
新しいタブで開く

01 · 目標の高さを一度越えてから戻る、ばねで伸びる棒 — 計測 累積面積 34.125% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SPRING_STIFFNESSSPRING_DAMPINGGROW_STAGGER_MS
    function springStep(s, k, d, dt) {
        const a = -k * (s.x - s.to) - d * s.v;
        s.v += a * dt;
        s.x += s.v * dt;
    }
    // 減衰ばね — 引く力(1/s²)と消す力(1/s)。減衰比 = SPRING_DAMPING / (2√SPRING_STIFFNESS) が1より小さいので目標を一度越えてから戻る
    const SPRING_STIFFNESS = 170;
    const SPRING_DAMPING = 12;
    // 棒の出発の時間差 — 左の棒からこの分ずつ遅れて引き始める
    const GROW_STAGGER_MS = 70;

02順位が入れ替わると棒が列を移って走る棒グラフの競走

九つの項目の架空の値をキーフレーム8個のあいだで420msごとに線形につなぎ、16msのステップごとに大きい順に並べ直して上の6本(TOP_N)だけを残したうえで、棒の縦の位置を新しい順位へ残りの距離の12%ずつ寄せて列の入れ替わりを滑らせます。一列ぶんの差が10%に縮まるまで19ステップ304msかかって次のキーフレームの前に終わり、順位が上がった棒は一瞬光ってからステップごとに0.95倍で冷め、軸の長さは遅れて追いかけても今の最高値より下には下がらないように支えているので、先頭の棒が盤の右端を越えません。月ごとの順位の移り変わりを見せる紹介画面向けで、九つの盤のうち累積面積が42.2%でもっとも広くなりました。

順位が入れ替わると棒が列を移って走る棒グラフの競走
新しいタブで開く

02 · 順位が入れ替わると棒が列を移って走る棒グラフの競走 — 計測 累積面積 42.181% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

RACE_FRAME_MSRANK_LERPTOP_N
    // 毎ステップの並べ替え — 値の大きい順に順位を付け直し、順位が上がった棒は一瞬光る
    function reRank() {
        const order = [];
        for (let i = 0; i < ENTRY_N; i += 1) {
            order.push(i);
        }
        order.sort(function (a, b) { return cur[b] - cur[a]; });
        for (let r = 0; r < ENTRY_N; r += 1) {
            const i = order[r];
            if (r < rank[i] && r < TOP_N) {
                glow[i] = 1;
            }
            rank[i] = r;
        }
    }

03下の層から順に積み上がる積み上げ棒グラフ

六本の棒ごとに四つの系列の値を下から足していった累積和で層の始まりの高さを決め、層の出発は220ms・棒の出発は40msずつ遅らせて、520msのあいだ3乗のイージング 1 − (1 − t)³ で満たし、層のあいだに2pxのすき間を置きます。イージングが前半に寄っているので520msのうち279msで90%が埋まって最後の棒のいちばん上の層まで1,380msで積み終わり、「系列 3 を隠す」を押すとその層の重みが0へ縮みながら上の層も一緒に沈み、てっぺんの合計も同じ累積和で数え直します。チャネル別の流入合計を見せるレポートに付ける盤で、動いたフレームが22/23と一枚足りないのは、積み終わってから系列を隠すまでのあいだ少し止まっている間があるためと考えられます。

下の層から順に積み上がる積み上げ棒グラフ
新しいタブで開く

03 · 下の層から順に積み上がる積み上げ棒グラフ — 計測 累積面積 18.397% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SEGMENT_DELAY_MSSEGMENT_EASE_POWSTACK_GAP_PX
    // 層 k の満ち具合(0〜1) — 出発は層ごとに SEGMENT_DELAY_MS、棒ごとに COL_STAGGER_MS ずつ遅い
    function fill(c, k) {
        const t = clamp((buildMs - k * SEGMENT_DELAY_MS - c * COL_STAGGER_MS) / FILL_MS, 0, 1);
        return 1 - Math.pow(1 - t, SEGMENT_EASE_POW);
    }
    // 今見えている値 — もとの値 × 満ち具合 × 系列表示の重み
    function shown(c, k) { return values[c][k] * fill(c, k) * weight[k]; }
    // 累積和 — 層 k の始まりの高さは下の層の見えている値を足したもの
    function baseOf(c, k) {
        let s = 0;
        for (let j = 0; j < k; j += 1) {
            s += shown(c, j);
        }
        return s;
    }
    function totalOf(c) { return baseOf(c, SERIES_N); }

04ラベルが棒の内か外かを自分で決める横棒グラフ

棒の先の数字の幅をmeasureTextで測って左右8pxの余白まで棒の長さに収まるかを見て、棒の色の相対輝度から明るい文字と濃い文字のコントラストを両方計算し、高いほうがCONTRAST_MIN 4.5以上のときだけ棒の内側にその色で書きます。灰紫の項目 Dの棒(128, 120, 150)は明るい文字3.86・濃い文字3.95でどちらも4.5に届かないため、最初のデータで棒が234pxと十分長いのに「15,940」が棒の外側に付きます。項目名の長いアンケート結果のように棒の長さがまちまちなグラフに使う盤で、累積面積は16.5%と九つのうちいちばん小さい値でした。

ラベルが棒の内か外かを自分で決める横棒グラフ
新しいタブで開く

04 · ラベルが棒の内か外かを自分で決める横棒グラフ — 計測 累積面積 16.487% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

LABEL_PAD_PXCONTRAST_MINBAR_THICKNESS_PX
    // 相対輝度 — sRGB のチャンネルを線形に戻して 0.2126・0.7152・0.0722 で混ぜる
    function channel(v) {
        const c = v / 255;
        return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
    }
    function luminance(c) { return 0.2126 * channel(c[0]) + 0.7152 * channel(c[1]) + 0.0722 * channel(c[2]); }
    function contrast(a, b) {
        const la = luminance(a);
        const lb = luminance(b);
        return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
    }
    // 位置決め — 文字幅 + 左右の余白が棒に収まり、二つの文字色のうち高いほうが CONTRAST_MIN を超えれば内側
    function placeLabel(text, len, bar) {
        ctx.font = '700 12px sans-serif';
        const width = ctx.measureText(text).width;
        const light = contrast(bar, LIGHT_TEXT);
        const dark = contrast(bar, DARK_TEXT);
        const best = light >= dark ? LIGHT_TEXT : DARK_TEXT;
        const ratio = Math.max(light, dark);
        const fits = width + LABEL_PAD_PX * 2 <= len;
        return { inside: fits && ratio >= CONTRAST_MIN, text: best, ratio: ratio, width: width };
    }

05値が大きくなると目盛りが1・2・5の刻みで取り直され、棒と一緒に縮む軸

棒の最大値を5区間(TICK_TARGET_COUNT)で割ったおおよその間隔を10のべき乗で正規化し、1・2・5・10のうちそれ以上で最初の値へ切り上げ、軸の端が変わるときだけ600msのあいだに古い目盛りは薄れて新しい目盛りが入り、棒の高さも同じ最大値で割って一緒に縮みます。「値を増やす」を押すと最大値が46 → 118 → 405 → 922と大きくなるあいだに、間隔は10 → 50 → 100 → 200、軸の端は50 → 150 → 500 → 1,000と取り直されます。値がリアルタイムで増える訪問者グラフに合う盤で、強度の平均140.5は03の次に高い値です。

値が大きくなると目盛りが1・2・5の刻みで取り直され、棒と一緒に縮む軸
新しいタブで開く

05 · 値が大きくなると目盛りが1・2・5の刻みで取り直され、棒と一緒に縮む軸 — 計測 累積面積 35.748% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

TICK_TARGET_COUNTNICE_STEPSRESCALE_MS
    // 1・2・5 の階段 — おおよその間隔を10のべき乗で正規化し、NICE_STEPS のうちそれ以上で最初の値へ切り上げる
    function niceStep(maxValue) {
        const raw = maxValue / TICK_TARGET_COUNT;
        const mag = Math.pow(10, Math.floor(Math.log10(raw)));
        const norm = raw / mag;
        for (let i = 0; i < NICE_STEPS.length; i += 1) {
            if (norm <= NICE_STEPS[i] + 1e-9) {
                return NICE_STEPS[i] * mag;
            }
        }
        return 10 * mag;
    }
    function niceMax(maxValue) {
        const s = niceStep(maxValue);
        return Math.ceil(maxValue / s - 1e-9) * s;
    }

06何十本もの棒のうち、ポインターの近くだけ魚眼レンズのように広がる棒グラフ

60本(BAR_COUNT)の棒の境界ごとに、ポインターまでの距離を半径80pxで割ったuを g(u) = (P+1)u ÷ (Pu+1) で押し広げ、半径の外の境界は動かさないので盤全体の幅432pxは変わりません。もとは7.2pxだった棒が広がる倍率は曲線の始まりの傾き P+1 = 4 を超えず、焦点が盤の真ん中にあるとき8本が広がって(いちばん広い棒は22.7pxで約3.2倍)16本が半径の縁で押しつぶされます。日ごとの記録が詰まった年間グラフで一区間だけをのぞき込むときに使い、焦点が2秒に一度盤を往復する実演のあいだ、23回の比較でずっと動き続けました。

何十本もの棒のうち、ポインターの近くだけ魚眼レンズのように広がる棒グラフ
新しいタブで開く

06 · 何十本もの棒のうち、ポインターの近くだけ魚眼レンズのように広がる棒グラフ — 計測 累積面積 40.077% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

BAR_COUNTFISHEYE_RADIUS_PXFISHEYE_POWER
    // 魚眼関数 — ポインターから x までの距離を半径で割った u を g(u) で押し広げる。半径の外はそのままなので外側の位置がつながる
    function fisheye(x, fx, power) {
        const d = x - fx;
        const ad = Math.abs(d);
        if (ad >= FISHEYE_RADIUS_PX || power <= 0) {
            return x;
        }
        const u = ad / FISHEYE_RADIUS_PX;
        const g = ((power + 1) * u) / (power * u + 1);
        return fx + Math.sign(d) * FISHEYE_RADIUS_PX * g;
    }
    function edge(i) { return fisheye(PLOT_L + i * barW0, focusX, FISHEYE_POWER * lens); }

07光の向きで上面と側面の明暗が変わる等角3Dの柱

4×4の格子のマスごとに柱を30°の等角投影で立てて上面と二つの側面を求め、面の法線を方位LIGHT_AZIMUTH_DEG・高度42°の光のベクトルとの内積に残りの0.7を掛けて環境光0.3の上に足し、面ごとの明るさを決め、gx + gyの小さい奥のマスから塗ります。始めの方位135°では+xの側面が光に背を向けて環境光0.3だけを受け、+yの側面は0.668、上面は方位に関係なく0.768で、床の影は柱の高さ1pxあたり1.111pxの長さで光の反対側に伸びます。地域別の数値を立体で見せるトップ画面向けで、光が2秒で一周するため画面の広さは16.8%しか変わらなくても23/23枚が動きました。

光の向きで上面と側面の明暗が変わる等角3Dの柱
新しいタブで開く

07 · 光の向きで上面と側面の明暗が変わる等角3Dの柱 — 計測 累積面積 16.805% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ISO_ANGLE_DEGLIGHT_AZIMUTH_DEGDEPTH_PX
    // 等角投影 — 床 (wx, wy) と高さ wz を画面の点に
    function project(wx, wy, wz) {
        return [OX + (wx - wy) * cosA, OY + (wx + wy) * sinA - wz];
    }
    // 光のベクトル — 方位角と高度から単位ベクトル (Lx, Ly, Lz)
    function lightVec() {
        const az = azimuth * DEG;
        const el = LIGHT_ELEV_DEG * DEG;
        return [Math.cos(el) * Math.cos(az), Math.cos(el) * Math.sin(az), Math.sin(el)];
    }
    // 面の明るさ — 環境光 AMBIENT に法線・光の内積(負なら0)を足す
    function shade(nx, ny, nz, L) {
        return AMBIENT + (1 - AMBIENT) * Math.max(0, nx * L[0] + ny * L[1] + nz * L[2]);
    }

08落ちてきた粒がマスごとに積もってヒストグラムになる棒

一様乱数4個を平均した値で15マスのうちひとつを選んで360個の粒を上から落とし、重力1,400px/s²で速度を積み上げ、あらかじめ予約しておいた自分の位置の高さに当たると速度に0.35を掛けて跳ね返り、秒速70px以下なら止まって積もります。平均を取ると真ん中のマスの取り分が17.4%と厚くなり(「一様乱数ひとつで」を押すとマスごとに約6.7%)、シードの実行ではいちばん高いマスが360個のうち60個を受け取り、234.5px落ちる粒ひとつは自分の位置に三度当たって1,136msに止まります。応答時間の分布を説明する技術ブログに付けやすい盤で、累積面積24.9%・強度105.2が出ました。

落ちてきた粒がマスごとに積もってヒストグラムになる棒
新しいタブで開く

08 · 落ちてきた粒がマスごとに積もってヒストグラムになる棒 — 計測 累積面積 24.856% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

DROP_GRAVITYBIN_COUNTBOUNCE_LOSS
        for (let i = 0; i < dots.length; i += 1) {
            const d = dots[i];
            if (d.rest) {
                continue;
            }
            d.v += DROP_GRAVITY * dt;
            d.y += d.v * dt;
            // 自分の位置(積もった高さ)に当たったら跳ねるか、十分遅ければ止まって積もる
            if (d.y >= d.restY) {
                d.y = d.restY;
                if (d.v > REST_SPEED) {
                    d.v = -d.v * BOUNCE_LOSS;
                }
                else {
                    d.v = 0;
                    d.rest = true;
                    landed[d.bin] += 1;
                }
            }
        }

09増加は上へ・減少は下へ分かれ、ゼロの基準線も一緒に動く増減の棒

値の最小値・最大値を0と比べて軸の範囲が0を外さないように取ってから上下に範囲の12%を足し、ゼロの基準線は軸の上端から0までが範囲全体に占める割合で高さを決めます。データが変わると軸の両端をステップごとに残りの距離の10%ずつ動かすので、1番から2番のデータへ移るとき基準線がy 235.3から81.4へ上がって368msで90%を過ぎ、0のまわり±8の内側の小さな値は減少色と増加色が混ざって灰色寄りに移ります。前月比の増減を見せる要約カードに合い、累積面積が36.4%になったのは、基準線とその上下の背景の帯が一緒に上下するぶんが大きいためと考えられます。

増加は上へ・減少は下へ分かれ、ゼロの基準線も一緒に動く増減の棒
新しいタブで開く

09 · 増加は上へ・減少は下へ分かれ、ゼロの基準線も一緒に動く増減の棒 — 計測 累積面積 36.38% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

BASELINE_LERPZERO_CROSS_FADEDIVERGE_PAD_PCT
    // 軸の範囲 — 0を内側に含めて(min(0,·)・max(0,·))、値の範囲の DIVERGE_PAD_PCT ぶん上下に広げる
    function rangeFor() {
        let lo = 0;
        let hi = 0;
        for (let i = 0; i < BAR_N; i += 1) {
            lo = Math.min(lo, goalOf(i));
            hi = Math.max(hi, goalOf(i));
        }
        const pad = (hi - lo) * DIVERGE_PAD_PCT / 100;
        return { lo: lo - pad, hi: hi + pad };
    }
    const axis = rangeFor();
    // ゼロの基準線の高さ — 軸の上端から0までが範囲全体に占める割合
    function zeroY() { return PLOT_T + (axis.hi / (axis.hi - axis.lo)) * (PLOT_B - PLOT_T); }
    function yOf(v) { return PLOT_T + ((axis.hi - v) / (axis.hi - axis.lo)) * (PLOT_B - PLOT_T); }

値ひとつは、どうやって画面上の何ピクセルの棒になるのか?

棒の高さは、値を軸の端の値で割った比に、グラフ盤の高さを掛けた長さです。01は盤がy 64から262までの198pxで軸が0〜100なので値1が1.98pxになり、目標60の棒は床から118.8px立ち上がります。棒の幅は盤の幅418pxを8つに分けた52.25pxから、あいだの間隔14pxを引いた38.25pxです。

軸の端が変わると同じ値でも別の高さになる

この比例式で動くのは値だけではありません。05は割る数である軸の端の値を600msかけて50から150へ動かし、100ms刻みで見ると59.2 → 81.4 → 109 → 134 → 148.7と真ん中が速い曲線を描きます。値がそのままなら、同じ棒の高さが3分の1に縮む計算です。棒だけを別に縮めずに軸と同じ数で割っているので、目盛り線と棒の先がずれません。

横棒は同じ式をx軸へ回したもの

横 棒グラフ アニメーションでも比例式は同じで、長さを測る向きが変わるだけです。04は左の始点x 70から右端452までを0〜26,000に合わせて値1を0.01469pxに換えるので、15,940は234pxの棒になります。

ゼロが盤の真ん中に来る増減の棒

09のように負の値が混ざると、床は盤の下端ではなく0の位置へ移ります。基準線のyは 58 + hi ÷ (hi − lo) × 214 で、最初のデータは軸が−10.78〜52.08なので0が上から82.8%の地点のy 235.3に置かれ、二つ目のデータは−49.03〜6.03なので10.9%の地点のy 81.4まで上がります。

画面が大きくなっても座標が480×300のままの理由

キャンバスの中の座標は九つの盤とも480×300に固定で、実際の画素だけをMath.min(window.devicePixelRatio || 1, 2)倍に増やします。ポインターの位置もgetBoundingClientRectの比率でこの座標に戻して計算するので、舞台がスマートフォンの幅に縮んでも、上に書いたピクセル数は同じ式で読めます。

目盛りを1・2・5の刻みで選ぶ計算と、棒が目標の高さを一度越えてから戻る理由

軸の目盛りが23.6や184.4のような間隔で出てくると、見る人は棒の高さを頭の中で割り算できません。05はまずおおよその間隔を求め、その数を10のべき乗で割って1から10のあいだの数にしたうえで、1・2・5・10のうちそれ以上で最初の値を選び、もう一度べき乗を掛けます。

05の四つの段階で選ばれた間隔

段階 棒の最大値 おおよその間隔 選んだ間隔 軸の端
最初の値 46 9.2 10 50
1回目の増加 118 23.6 50 150
2回目の増加 405 81 100 500
3回目の増加 922 184.4 200 1,000

118は23.6を10で割った2.36が2を超えるので5へ上がって間隔50になり、922は1.844が2へ上がって間隔200になります。同じ関数に別の値を入れると、7は間隔2・軸8、1,234は間隔500・軸1,500、4,321は間隔1,000・軸5,000になります。軸の端は最大値を間隔で割って切り上げたマスの数に間隔を掛けた値なので、922の軸が1,000で止まるように、いちばん高い棒のすぐ上の目盛りで盤が終わります。

減衰比が1より小さいと目標を一度通り過ぎる

01の加速度は −剛性 × (高さ − 目標) − 減衰 × 速度 のひとつだけです。減衰 ÷ (2√剛性) で求める減衰比が1より小さいと、棒は目標に届いた瞬間にも上向きの速度が残っていて、そのぶん通り過ぎてから引き戻されて帰ってきます。170と12の減衰比0.46では、その行き過ぎが17.7%です。目標±0.5の帯に初めて入るのは416msですが、一度外へ出てからもう一度入り、640ms以降はそのまま留まります。

速度を先に直す順番が安定する理由

springStepは加速度で先に速度を変え、その新しい速度で高さを動かします。古い速度で先に高さを動かす順番より16msの固定ステップで跳ねにくいためこの順番を選んでおり、減衰12では揺れが少しずつ小さくなって、640ms以降は目標±0.5の内側に留まります。

棒が数百本・値がゼロの近く・ラベルが棒より長い — 棒グラフが壊れるところ

棒が数百本になると魚眼の縁が1px未満に消える

06は幅の合計が432pxに縛られているので、真ん中を広げたぶん半径の縁が押しつぶされ、60本でもいちばん細い棒は2.0pxまで縮んで棒のあいだのすき間は0.4pxしか残りません。432pxを棒の本数で割ったもとの幅は200本でも2.2pxあるので、棒を数百本に増やすと押された棒のほうが1px未満に縮み、アンチエイリアスのにじみに埋もれると考えられます。02のようにステップごとに全部を並べ替える盤も棒の数だけ並べ替えの負担が増えるため、その場合はキーフレームが変わるときだけ並べ替えるようにして減らします。

同じ値が二つあると競走の順番は並べ替えの実装が決める

02の比較関数はcur[b] - cur[a]だけなので、値が同じなら0を返し、その二つの順番はブラウザの並べ替えに任されます。シードのデータでも最初のキーフレームに34が三つあり、出だしの列の順番がこれに頼っているので、自分のデータに同点が多いなら名前順のような二つ目の基準を比較に足します。

最大値が0のとき、値がすべて0のとき

05は最大値が0だとMath.log10(0)が負の無限大になって間隔がNaNに崩れ、09は値がすべて0だとhi − loが0になって基準線の式の割り算が成り立ちません。デモの値は05が12以上なので壊れませんが、実際のレポートには記録のない月が出てくるので、0を別に除いておく必要があります。09は±8の内側の小さな増減が灰色寄りに混ざって色だけでは符号がぼやけるため、ラベルに+/−を付けて色以外でも符号を読めるようにしました。

数字の文字が棒より長いとき

04でコンマまで数えて5文字の「3,297」は31.0px、6文字の「15,940」は37.9pxと測られ、左右の余白を足すと47.0px・53.9pxなければ棒の中に入りません。「棒を短くする」で値を0.3倍に下げると七本とも外側に出て、いちばん短い棒は6pxです。文字幅はmeasureTextがその瞬間に使っているフォントで測るので、Webフォントを使う場合はフォントが読み込まれるまでのあいだ判定が変わることがあります(このデモはsans-serifのままです)。

薄くした行ももとの色でコントラストを測る

04で行をひとつ選ぶと残りの行が薄くなりますが、文字色の判定は薄くする前の棒の色で行います。そのため薄い行の文字の実際のコントラストは計算値より低くなり、半透明の棒を使うなら背景と混ざったあとの色で輝度を測り直す必要があります。

負の値と薄い層は積み上げ棒をずらす

03の累積和は正の値だけを前提にしているので、負の値を入れると層の始まりの高さが下へ折れて層どうしが重なります。系列が多くて層が2pxのすき間より薄くなるとすき間だけが見えてしまうため、この盤は層の高さが2pxより小さいときはすき間をその高さのぶんだけ開けます。

塗る順番ひとつで隠れ方を合わせる限界

07はgx + gyの小さいマスから塗る順番だけで前後を合わせているので、マスの大きさが違う柱や互いに重なる柱では隠れ方が狂います。影の長さは高さに (Lx, Ly) ÷ Lz を掛けた値なので、光の高度を0°へ下げるとLzが0に近づいて影が際限なく伸び、影は床にだけ落ちてほかの柱の胴体には乗りません。

減衰を上げると行き過ぎが、剛性を上げると安定が消える

01は減衰を26ほどに上げると減衰比が1に届いて行き過ぎがなくなり、この盤の名前の動きそのものが消えます。剛性を大きく上げると16msの固定ステップで数値が跳ねることがあり、揺れて0より下に入った高さはMath.max(0, b.x)で切って、棒が床を突き抜けないようにしました。

粒どうしはぶつからない

08は粒が出た瞬間にそのマスで座る位置を先に予約するので、遅く出た粒がまだ落ちている粒をすり抜けて自分の位置へ降ります。粒を数万個に増やすと、止まった粒は計算を飛ばしても描画は毎回全部やり直すので、描く側の負担が先に大きくなります。

盤ごとにindex.html・style.scss・style.cssを入れたvanillaのフォルダーと、同じ計算を移したreactのフォルダーをまとめた圧縮ファイルは、パスワード jm3zs7sw を入力すると開きます。上で見た壊れ方を自分のデータで再現してみるときに使ってください。

ばねの減衰・順位に追いつく速さ・魚眼の半径を動かす変形と、キーボードと状態の行で値を伝えるアクセシビリティ

名前 変えた値 見え方
ばねの減衰を強める 01 SPRING_DAMPING 12 → 26 減衰比が0.46から0.997へ上がって1にほぼ届き、目標60の棒が70.6まで伸びていた行き過ぎがなくなって、棒はまっすぐ上がって592ms以降は目標±0.5の内側に留まります(12のときは640ms)。
順位に追いつく速さを上げる 02 RANK_LERP 0.12 → 0.3 一列ぶんの差を10%に縮めるのに19ステップ304msかかっていたのが7ステップ112msに縮み、新しい列にほぼすぐ収まるかわりに、重なって追い越す瞬間は見えにくくなります。どちらもキーフレーム間隔420msの内側です。
魚眼の半径を広げる 06 FISHEYE_RADIUS_PX 80 → 160 広がる棒が8本から14本、押される棒が16本から32本に増え、いちばん広い棒は22.7pxから25.4pxになって一区間がまるごと開きます。幅が変わらない棒は36本から14本に減ります。

マウスなしで棒を選ぶキー

九つのキャンバスはどれもtabindex="0"と日本語のaria-labelを持ち、Tabキーでフォーカスを移せます。←/→は棒を移り(02は今の順位の順、07はマスの番号順)、EnterとSpaceはその棒を固定して同じ棒ならもう一度押すと外し、Escapeは固定を外します。三つとも既定の動作を止めたあと、右上の状態の行が「項目 C · 87」、04なら「項目 D · 15,940 · 外側 4.0:1」のように名前と値を書きます。

押した状態を伝える切り替えボタン

aria-pressed="false"から始まるトグルは七つの盤にあり、02の「一時停止」、03の「系列 3 を隠す」、04の「棒を短くする」、06の「レンズを切る」、07の「光を止める」、08の「一様乱数ひとつで」、09の「すべて反転」です。01と05は、押すたびに同じ動作をするボタンが二つずつ並んでいます。

指した棒と固定した棒の枠線

ポインターを乗せただけの棒は[4, 3]間隔の点線、押して固定した棒は実線の枠で囲むので、色を見分けられなくても状態が分かれます。キャンバスとボタンのキーボードフォーカスの枠はstyle.cssの:focus-visibleが描きます。06はpointermoveのリスナーを二つ持ち、ひとつは九つの盤に共通する棒の指し示し、もうひとつはレンズの焦点合わせで、pointerleaveでは固定がないまま盤の外へ出るとlensGoalが0、選択が−1に戻ってレンズが閉じます。

動きを減らす設定にした画面

九つの盤はどれもmatchMedia('(prefers-reduced-motion: reduce)')にchangeのリスナーを付けているので、ページを開いたままprefers-reduced-motionの設定を変えてもすぐに従います。減らす側ならアニメーションのループを止めて目標の状態へそのまま移して一度だけ描き、その状態でもキー・ボタン・クリックは結果の画面をすぐに変えます。ただし02はこのとき最初の画面が最後のキーフレームなので、一時停止ボタンだけは画面をそのままにして押された状態を切り替え、style.cssの同じメディアクエリはボタンの背景色の遷移まで止めます。

積み上げ 棒グラフ アニメーションはCSSだけでどこまでできて、どこからキャンバスが要るのか?

決まった高さまで棒を数本伸ばすくらいなら、CSSの高さの遷移だけでも足ります。ただ、この記事の九つの盤はCSSではなくキャンバスとスクリプトで作っていて、その理由は盤ごとに値を読んで次の画面を決める計算が入るからです。02は16msごとに値を並べ替えて順位を付け直し、04は文字幅と輝度を測ってラベルの位置を選び、05は最大値から目盛りの間隔を求めます。03の層も、隠した系列の重みを0へ縮めるあいだ上の層の床を累積和で毎ステップ計算し直すので、高さを固定値で書いておくCSSの遷移では追いつきません。06の境界の配り直し、07の面の法線と光の内積、08の重力の積分も毎ステップ数を計算し直す必要があり、スクリプトの受け持ちになります。棒はfillRectとパスの塗りで、数字の幅はmeasureTextで、時間はrequestAnimationFrameで扱いました。

同じデータ可視化の流れで扇形を扱う記事は円グラフ アニメーション 9種、線と棒をSVGで引く記事はSVGグラフ 9種です。キャンバスのチャート全般はキャンバスのチャートアニメーション 9種とデータ可視化のチャート 9種、画素の計算で場面を埋める方向はキャンバスの特殊効果 9種へつながります。

棒グラフをレポート画面に載せる前のよくある質問 — ばね・自分の数字・読み上げ

横 棒グラフ アニメーションにも01のばねをそのまま使えますか?

使えます。springStepは高さ・速度・目標の三つの数しか扱わず向きを知らないので、04の棒の長さに付けても同じ行き過ぎが起きます。04は今の長さを目標へステップごとに12%ずつ寄せていて行き過ぎがなく、ステップごとにその瞬間の長さlen[i]でラベルの内外を判定し直しています。ばねに替えると長さが揺れるあいだ判定もぶれることがあるので、判定にだけ目標の長さを渡すほうが落ち着きます(状態の行を書くreport()は、もともと目標の長さで判定しています)。

自分の数字に替えるにはどこを直せばいいですか?

01はseededValues(seed, BAR_N, 30, 94)で作った目標の配列を、自分の値8個に替えれば動きます。01の軸は0〜100に固定なので、100を超える値ならAXIS_MAXも一緒に直すか、05のniceStep・niceMaxを持ってきて軸の端を値から求めます。09は軸と基準線を値から計算するため、setsの配列を替えるだけで0の位置がついてきます。

スクリーンリーダーの利用者には棒の値がどう伝わりますか?

キャンバスの中の棒はひとつずつ読み上げられる要素ではないので、このデモはキャンバスにaria-labelを一行付け、選んだ棒の値は状態の行で伝えます。状態の行のaria-liveは自動の実演中はoffにして実演が切り替わるたびに読まれないようにし、人がキー・ボタン・ポインターを初めて操作した瞬間にpoliteへ変わります。レポートのように数字全体を一度に確かめる画面なら、グラフの横か下に同じ値を入れたtableを一緒に置くほうがよく、このデモにはその表は入っていません。

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

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