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

バブルチャート 9種 — 面積に比例する半径から重なりの解消とグループの重力まで

バブルチャートはデータの一行をどうやって円一つに変えるのでしょうか。01番は半径を値の平方根にして面積を値に合わせ、02番は重なった円を何度も押して解き、09番はグループの中のグループへ拡大して入っていきます。円の位置と大きさは計算の関数が決め、キャンバスはその結果だけを塗るデモです。

バブルチャートは点を打つ絵ではなく、横と縦の位置と面積という三つの値を一つの円にまとめる計算です — バブルチャートを作るには、どの値を位置に、どの値を大きさに回すかを決めることから始まります

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

九つの場面が共有する設計はひとつです。泡は { x, y, r, v, g } の五つの欄を持つオブジェクトで、重なりを解く関数が x・y を変え、半径の関数が r を決め、画面はその一覧と場面の時計だけを読んで描きます。

三つの値をどこへ送るかが最初の決定です

横の値と縦の値は目盛りを通って座標になり、大きさの値は半径になります。大きさの値には軸がないので、03番は代表の円三つで凡例を別に描きます。

時計と乱数を場面の中で固定します

場面は Date も Math.random も一度も呼びません。進行は場面の時間表の経過時間で、散らす位置はシードを受け取る seeded 生成器で決めるので、同じ場面は同じ泡の配置を作ります。名前と値は架空の項目です。

バブルチャート 9種 — 面積に比例する半径・重なりをくり返し解く配置・三つの値の目盛り・ポインターで押される泡・値が変わるときのバネの動き・ラベルの収まり・時点の間の移動の尾・グループの重力・グループの中への拡大

並びは大きさから始まり、隣との関係、時間、グループへと進みます。01〜03は半径と配置と目盛り、04・05はポインターと値の更新、06はラベル、07は時点の間の移動、08・09はグループと階層です。

描画で測った九つの場面の面積・フレーム・強さ

場面 累積面積 動いたフレーム 強さの平均
01 面積の半径 11.0% 23/23 44.3
02 重なりの解消 1.7% 20/23 50.6
03 三つの値の目盛り 29.9% 19/23 50.0
04 ポインターの反発 17.1% 23/23 50.7
05 バネの再配置 9.0% 23/23 38.4
06 ラベルの収まり 14.0% 19/23 41.7
07 移動の尾 14.9% 23/23 58.6
08 グループの重力 37.2% 23/23 28.5
09 グループの中の拡大 43.4% 19/23 35.5

累積面積がもっとも広いのは43.4%の09で、もっとも狭いのは1.7%の02です。強さの平均は07の58.6が最大で、08の28.5が最小になり、01・04・05・07・08は23/23なので毎フレーム動き、03・06・09は19/23です。

01面積に比例する半径

「バブルチャート バブルの大きさ」で調べる人が、バブルの大きさを読み取るときに値を取り違えないようにするには、半径ではなく面積が値に従う必要があります。radiusOf は値20で半径18を取り、値の平方根に比例させて R_MIN の6と R_MAX の46のあいだに抑え、右の radiusLinear は半径を値にそのまま比例させた対比版です。この場面の累積面積は11.0%で、23/23フレームで動きが検出されました。

面積に比例する半径
新しいタブで開く

01 · 面積に比例する半径 — 計測 累積面積 11.014% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

R_MINR_MAXradiusOf
    // 半径18は値20から出発する — 二つの版が同じ基準の泡から分かれる
    const R_REF = 18;
    const V_REF = 20;
    const VALS = [36, 30, 25, 20, 16, 13, 11, 9];
    const GROW_I = 3;
    const LAP_S = 9.6;
    const START_S = 1.6;
    const REST_S = 5.2;
    // 面積基準 — 半径は値の平方根に比例する
    function radiusOf(v) { return clamp(R_REF * Math.sqrt(v / V_REF), R_MIN, R_MAX); }
    // 対比版 — 半径が値にそのまま比例する
    function radiusLinear(v) { return clamp(R_REF * v / V_REF, R_MIN, R_MAX); }

02重なりを繰り返し押して解く配置

データの位置が同じでも泡が互いを隠さないように、重なりを一回に PACK_ITER 回ずつくり返して押します。二つの円の中心の距離が半径の和より小さければ、重なった分を相手側の半径の比で分けて押し合うので小さい円のほうが多く動き、ラウンドごとの残りの重なりの合計を右の線に打ちます。累積面積は1.7%でもっとも小さく、20/23フレームで動きが検出されました。

重なりを繰り返し押して解く配置
新しいタブで開く

02 · 重なりを繰り返し押して解く配置 — 計測 累積面積 1.684% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

PACK_ITERoverlapOfrelax
    function doRound() {
        for (let p = 0; p < PACK_ITER; p += 1) {
            relaxPass(bs, 0);
            keepIn(bs, PANEL_L);
        }
        relaxPass(bsSlow, 0);
        keepIn(bsSlow, PANEL_L);
        hist.push(overlapOf(bs));
        histSlow.push(overlapOf(bsSlow));
        round += 1;
    }

03横・縦・大きさの三つの値の目盛り

三つの変数を一枚に載せると、軸の端が中途半端な数のとき目盛りが読めません。niceStep は範囲を TICK_TARGET の5で割った値を、1・2・5に10のべき乗をかけた間隔に選び、大きさの凡例は最大値を超えない最大の1・2・5系の数とその半分と5分の1で円を三つ描きます。累積面積は29.9%で、19/23フレームで動きが検出されました。

横・縦・大きさの三つの値の目盛り
新しいタブで開く

03 · 横・縦・大きさの三つの値の目盛り — 計測 累積面積 29.922% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

niceStepTICK_TARGETlegendSizes
    function niceStep(range, target) {
        const raw = range / target;
        const p = Math.pow(10, Math.floor(Math.log10(raw)));
        const f = raw / p;
        const m = f <= 1.5 ? 1 : (f <= 3.5 ? 2 : (f <= 7.5 ? 5 : 10));
        return m * p;
    }
    function niceAxis(lo, hi) {
        const step = niceStep(hi - lo, TICK_TARGET);
        return { a: Math.floor(lo / step + 1e-9) * step, b: Math.ceil(hi / step - 1e-9) * step, step: step };
    }
    // 大きさの凡例 — 最大値を超えない最大の1・2・5 × 10のべき乗を一番上に置き、その半分と5分の1を足す
    function floorNice(v) {
        const p = Math.pow(10, Math.floor(Math.log10(v)));
        const f = v / p;
        return (f >= 5 ? 5 : (f >= 2 ? 2 : 1)) * p;
    }
    function legendSizes(vmax) {
        const top = floorNice(vmax);
        return [top, top / 2, top / 5];
    }

04ポインターの周りで押しのけられる泡

マウスを載せて泡一つを読ませるホバーでは隣がよける必要があり、この場面は時間表で動く偽のポインターでその様子を代わりに見せます。falloff は距離0で1、REPEL_R の64で0に届く曲線なので境界で泡が跳ねず、押された目標を FOLLOW で追いかけ、DAMP の0.78で速度を落としながら元の位置に戻ります。累積面積は17.1%で、23/23フレームで動きが検出されました。

ポインターの周りで押しのけられる泡
新しいタブで開く

04 · ポインターの周りで押しのけられる泡 — 計測 累積面積 17.131% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

REPEL_RfalloffDAMP
    // 力の強さ — 距離dが0なら1、境界REPEL_Rなら0、その間はなめらかな曲線(端では傾きも0)
    function falloff(d) {
        if (d >= REPEL_R) {
            return 0;
        }
        const u = 1 - d / REPEL_R;
        return u * u * (3 - 2 * u);
    }

05値が変わるとバネで大きさを移す

リアルタイムの数値が更新されるとき、半径が目標へ一気に飛ぶと画面が跳ねます。左は目標の半径との差に SPRING_K の0.08をかけて速度に足し、SPRING_DAMP の0.8をかけて一度行き過ぎてから落ち着き、目標は R_K の4.2に値の平方根をかけたもので、大きくなった泡は一歩ごとに重なりを一回押して隣をよけさせます。値が変わる四回で累積面積は9.0%になり、23/23フレームで動きが検出されました。

値が変わるとバネで大きさを移す
新しいタブで開く

05 · 値が変わるとバネで大きさを移す — 計測 累積面積 9.016% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SPRING_KtargetR_K
    // 固定間隔の一歩 — 左は力(目標との差 × SPRING_K)を速度に足し、SPRING_DAMPをかけてから半径に足す
    function step() {
        for (let i = 0; i < N; i += 1) {
            velR[i] = (velR[i] + (target(i) - ballsL[i].r) * SPRING_K) * SPRING_DAMP;
            ballsL[i].r += velR[i];
            ballsR[i].r = target(i);
        }
        relax(ballsL, PANEL_L, 1, 1.5, 0.012);
        relax(ballsR, PANEL_R, 1, 1.5, 0.012);
    }

06ラベルが円の中に収まるかを測って分ける

小さな泡にも名前を読ませるには、文字の幅と円の内側の幅を比べて置き場所を決める必要があります。chordW は文字の高さの半分だけ上下にずれた弦の長さから左右の LABEL_PAD の3を引いた値で、planOf は大きい文字、小さい文字、頭の数文字だけ、円の外に表示の順に試します。累積面積は14.0%で、19/23フレームで動きが検出されました。

ラベルが円の中に収まるかを測って分ける
新しいタブで開く

06 · ラベルが円の中に収まるかを測って分ける — 計測 累積面積 13.956% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

LABEL_PADglyphWchordW
    // 泡の内側の幅 — 文字の高さの半分だけ上下にずれた弦の長さから左右の余白を引く
    function chordW(r, px) { return 2 * Math.sqrt(Math.max(0, r * r - (px * 0.55) * (px * 0.55))) - 2 * LABEL_PAD; }
    function planOf(name, r) {
        for (const px of [FONT_BIG, FONT_SMALL]) {
            if (glyphW(name, px) <= chordW(r, px)) {
                return { mode: 0, px: px, s: name };
            }
        }
        // 頭の数文字だけ — 収まる最長の接頭辞(2文字以上)
        for (let n = name.length - 1; n >= 2; n -= 1) {
            const s = name.slice(0, n);
            if (glyphW(s, FONT_SMALL) <= chordW(r, FONT_SMALL)) {
                return { mode: 1, px: FONT_SMALL, s: s };
            }
        }
        return { mode: 2, px: FONT_SMALL, s: name };
    }

07時点の間をつなぐ移動の尾

年ごとに与えられた横・縦・大きさを曲線でつなぐと、泡が時点の間をすべります。splineStep は前の点と始点と終点と後ろの点の四つで二つの時点の間を3次曲線でつなぎ、yearAt は0.5秒から7.6秒まで時点の位置をなめらかに一度だけ進め、通ってきた場所は TRAIL_LEN の16個の点で残ります。累積面積は14.9%で、23/23フレームで動きが検出されました。

時点の間をつなぐ移動の尾
新しいタブで開く

07 · 時点の間をつなぐ移動の尾 — 計測 累積面積 14.902% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

TRAIL_LENsplineStepyearAt
    // 四つの点(前・始点・終点・後ろ)で二つの時点の間をつなぐ曲線 — posは0からKEYS - 1までの小数の位置
    function splineStep(a, pos) {
        const i = Math.min(Math.floor(pos), a.length - 2);
        const u = pos - i;
        const p0 = a[Math.max(i - 1, 0)];
        const p1 = a[i];
        const p2 = a[i + 1];
        const p3 = a[Math.min(i + 2, a.length - 1)];
        return 0.5 * ((2 * p1) + (-p0 + p2) * u + (2 * p0 - 5 * p1 + 4 * p2 - p3) * u * u + (-p0 + 3 * p1 - 3 * p2 + p3) * u * u * u);
    }

08グループの中心へ引き寄せる重力

分類ごとにまとめて見せるには、泡ごとに属するグループの中心へ引く力が要ります。step は中心へのベクトルに PULL の0.02をかけて速度に足し、DAMP の0.82をかけて動かしてから重なりを一回押して釣り合わせ、「バブルチャート 色分け」で探されるグループごとの色分けにも向き、グループ同士の距離は setCenters で中心の座標一組だけを変えて広げます。累積面積は37.2%で、23/23フレームで動きが検出されました。

グループの中心へ引き寄せる重力
新しいタブで開く

08 · グループの中心へ引き寄せる重力 — 計測 累積面積 37.159% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

PULLcentersDAMP
    // 固定間隔の一歩 — 速度に(中心へのベクトル × PULL)を足し、DAMPをかけて動かした後、重なりを一回押して釣り合わせる
    function step() {
        for (let i = 0; i < bs.length; i += 1) {
            const c = centers[bs[i].g];
            vel[i][0] = (vel[i][0] + (c[0] - bs[i].x) * PULL) * DAMP;
            vel[i][1] = (vel[i][1] + (c[1] - bs[i].y) * PULL) * DAMP;
            bs[i].x += vel[i][0];
            bs[i].y += vel[i][1];
        }
        relaxPass(bs, 1);
        keepIn(bs, PANEL_ONE);
    }

09グループの中にグループを入れる拡大

大きな分類から細かい項目へ降りるには、外側の円の中に小さな円を同じ重なりの解消で詰め、外側の半径を別に求める必要があります。encloseR は内側の円の中心を一番外へはみ出た円の側へ80回少しずつ動かして、すべての内側の円を包む近似の半径を返し(正確な最小の外接円ではありません)、グループを選ぶと視点の枠が ZOOM_EASE の3.2で近づき、sX が座標を拡大の比率に変えます。累積面積は43.4%でもっとも広く、19/23フレームで動きが検出されました。

グループの中にグループを入れる拡大
新しいタブで開く

09 · グループの中にグループを入れる拡大 — 計測 累積面積 43.374% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ZOOM_EASEencloseRsX
    function update(dt) {
        if (dt <= 0) {
            return;
        }
        const k = 1 - Math.exp(-ZOOM_EASE * dt);
        const tx = zg < 0 ? CX0 : grps[zg].x;
        const ty = zg < 0 ? CY0 : grps[zg].y;
        const ts = zg < 0 ? 1 : 98 / (grps[zg].R + PAD_G);
        vcx += (tx - vcx) * k;
        vcy += (ty - vcy) * k;
        vs += (ts - vs) * k;
    }
    function sX(x) { return (x - vcx) * vs + CX0; }

バブルチャートをコードで描くと、データの一行はどんな値いくつで円一つになるのでしょうか?

泡一つは五つの欄です

一行のデータは位置の二つの値と大きさの値ひとつを受け取って { x, y, r, v, g } になります。次の五つの欄を場面ごとに同じ名前で使うので、重なりを解く関数とラベルの関数は、別の場面でもそのまま動きます。

欄 意味 使う関数
x・y 円の中心の座標 relaxPass・keepIn
r 半径。大きさの値の平方根から出る relaxPass・chordW
v 大きさの値そのもの。半径と凡例の元 radiusOf・legendSizes
g 属するグループの番号 step・setCenters

データは配列で書き、目盛りは計算で作ります

03と07のデータは xs・ys・ss の三つの配列で書き、目盛りの間隔と軸の端は niceAxis が最小値と最大値から計算します。データを替えれば目盛りと円の位置が一度に付いてくるので、軸の数字を手で合わせる必要がありません。

データの一行から位置の二つの値を目盛りに移し、大きさの値を平方根で半径に変え、重なれば押し合って解く — バブルチャートの円一つが描かれる順番

目盛りを先に決めて、半径をそのあとで求めます

niceAxis が横と縦の軸の端を決めると値が座標になり、radiusOf が大きさの値を半径に変えます。この二つが終わると泡の一覧が出そろい、重なりの解消が始まります。

重なりの解消はラウンドごとに減ります

PACK_ITER が4のとき、三ラウンドを過ぎてようやく残りの重なりの合計が0.6まで下がり、十二ラウンド後には0になります。02の点線はラウンドごとに一回しか押さない場合なので、右の線で二つの曲線が分かれていく様子が見えます。

位置が決まったあとで文字を載せます

planOf は位置と半径が終わった泡にだけ名前の置き方を決めます。値が変わって半径が動けば、同じ順序でラベルの形が決め直されます。

バブルチャートの作り方のように実際の画面に付けるとき — ライブラリなしで作る・データを配列で書く方法・値を更新する時点

どこに付けるか

ダッシュボードで項目を比べるとき、ポートフォリオの比率を見せるとき、アンケートの結果を回答数で見せるとき、円の大きさがそのまま話になります。見かけた図を自分で組んでみたい人には、02の重なりの解消と06のラベルの決まりが最初に必要です。

更新は値をひとつ変えるだけです

05は vals[i] ひとつだけを変え、残りは目標の半径の関数とバネが引き継ぎます。実際の画面でも新しい数字が届いたらその泡の値だけを変えればよく、泡の一覧を作り直す必要はありません。

ツリーマップとの違い

ツリーマップの編はセルの面積を四角に分ける配置で、この編は円が押し合って場所を探す配置なので、位置そのものが計算の結果になる点が違います。

ライブラリなしで描く

バブルチャートの描画に必要なのはキャンバス2Dとこの編の関数だけです。九つの場面は外部スクリプトもWebGLも読み込まず、一つのファイルの中で動きます。

バブルチャートが壊れる瞬間 — 半径を値にそのまま比例させて面積が二乗で膨らんで見える落とし穴・重なりの解消が終わらず円が震える場合・長いラベルが小さな円からはみ出ること

半径を値にそのまま比例させると

値が20から40へ二倍になるとき、平方根の版は半径が18から25.5に伸びて面積が二倍ですが、線形の版は半径が18から36になって面積が四倍に読めます。01の右の対比版が、この差を同じ値で並べて見せます。

重なりの解消が終わらないと

PACK_ITER が4のままだと、三ラウンドが過ぎても重なりの合計が0.6残ります。08で PULL を0.08に上げると、グループへ引く力と重なりを押す力がぶつかって、重なりの合計が87.1、平均速度が6.81になり、まとまりが不安定になります。

長いラベルが小さな円からはみ出ると

文字の大きさを固定して幅を測らないと、名前が隣の円の上へあふれます。06は chordW で内側の幅を測って形を決め、それでも収まらない円には線を引いて外に書きます。

九つの場面のソースが入ったzipは下のカードから受け取れます。展開のパスワードはpwwb8ndgです。

解く回数とバネの強さと重力の強さを変えると、感じはどれほど変わるのでしょうか — PACK_ITER・SPRING_K・PULL を変えた違い

名前 変えた値 感じ
解く回数を増やす 02 PACK_ITER 4 → 24 最初のラウンド後に残る重なりの合計が39.1から0.0に減り、最初の一回でほぼ解けます。4回だと三ラウンド後に0.6です
バネを固くする 05 SPRING_K 0.08 → 0.25 変化の90%に届く時間が117msから67msに縮みますが、行き過ぎが変化量の23.8%から47.9%に大きくなります
重力を強くする 08 PULL 0.02 → 0.08 一番大きいグループの半径が61から55に密になりますが、重なりの合計が33.4から87.1、平均速度が2.54から6.81になります

値を変える前に見ること

SPRING_K を上げると届く時間は縮み、行き過ぎは増えます。5%以内に落ち着く時間も467msから383msに縮みますが、隣を押す幅が大きくなるので、行き過ぎが気になるなら SPRING_DAMP も一緒に見ます。

PULL はグループが集まる速さと重なりを押す力の争いです。上の数値はグループの中心を広げてから3.2秒の時点の値なので、時点を変えれば違ってきます。

動きを減らす設定では止まった一枚のバブルチャート — 場面名と一番大きい泡の値を文で読み上げるアクセシビリティ

動きを減らす設定と隠れたタブ

九つのソースは prefers-reduced-motion の設定を読み、オンのときは止まった一枚のバブルチャートを描きます。タブが隠れると止まり、戻ると時計を合わせ直して、隠れていた数秒が一フレームに集中しないようにします。

キーボードと読み上げの文

ステージの枠にフォーカスを置くと、矢印キーで泡を選び、EnterとSpaceで押し、Pは一時停止、Nは一歩進め、Rはやり直しです。キャンバスは場面を説明する aria-label を持つ role="img" で、一回分が終わると隠れた role="status" が一番大きい泡の名前と値を文で知らせます。

状態の行は最初に触れたときに有効になります

状態の行はデモのあいだ aria-live="off" で、最初に触れると polite に変わります。デモを見るだけの人に読み上げが降り注がないようにする仕組みです。

バブルチャートのよくある質問 — ライブラリなしで作る・大きさは半径か面積か・ウェブに載せるときに必要なこと

バブルチャート 使い方にライブラリは必要ですか

キャンバス2Dだけで足り、この編の九つの場面はライブラリもWebGLもCDNも呼びません。重なりの解消・目盛り・ラベルの決まりが数行の純粋な関数なので、必要な部分だけを切って貼っても動きます。

大きさは半径ですか面積ですか

面積が値に従う必要があります。半径を値の平方根に比例させると値が二倍のとき面積も二倍になり、半径を値にそのまま比例させると面積が四倍に読めます。

ウェブに載せるときに何を足せばよいですか

キャンバスに role="img" と説明の文を付け、動きを減らす設定では止まった一枚を描かせ、矢印キーと一時停止のキーを受けることをおすすめします。値を配列で替えれば目盛り・半径・ラベルが付いてくるので、データとのつなぎ込みは最後でかまいません。

参考までに、MDNのrequestAnimationFrameは描画のループを、Page Visibility APIはタブが隠れるときを、KeyboardEventは矢印キーの扱いを、prefers-reduced-motionは動きを減らす設定を扱っています。同じ計算の方式のチャートは円グラフの編と棒グラフの編で、円同士の力で場所を決める図は力指向グラフの編で見られます。

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

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