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

ツリーマップ 9種 — 正方形に近く割る配置から値に合わせて流れる並べ替えまで

ツリーマップはセルをどう割るのでしょうか。01番は行にセルを足すたびに最も細長い比を測って正方形に近く閉じ、02番は値が変わるとセルがばねで新しい位置へ向かい、06番はセルの大きさを測って文字を縮めたり隠したりします。ツリーマップ9種は、配置を計算する関数とその結果だけを描くキャンバスを分けて作ったデモです。

ツリーマップは面積を読む絵ではなく面積を分ける計算です — ツリーマップを作るときは値の一覧を四角ひとつにすき間なく埋めるところから始まります

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

九つの場面が共有する設計はひとつです。セルの位置は描くコードが決めず、squarify と slice が返した四角形の一覧が決め、画面はその一覧と場面の時計だけを読んで描きます。ライブラリ・WebGL・CDNは使わず、乱数はシードを固定した生成器ひとつだけなので、同じシードは同じ盤を作ります。

値の一覧がセルの面積になる方法

ツリーマップとは、値の一覧を四角ひとつにすき間なく埋めて、セルの面積を値に比例させるチャートです。値は BASE に大きい順で24個入っていて、場面ごとに先頭の何個かを切って使い、名前は映像・写真・音楽のような架空の項目なので実在の会社やサービスの名前はありません。

配置の関数が計算し、画面はその結果だけを描く

slice は値の配列と四角形ひとつを受け取り、セルごとに { x, y, w, h } を返す純粋な関数です。squarify も同じ形なので、描くコードはこの四角形を塗って文字を載せるだけで、面積を自分で計算しません。

    // 一方向のスライス — 行に分けず入力の順に縦の帯だけで切る
    function slice(vals, R) {
        let total = 0;
        for (const v of vals) {
            total += Math.max(v, 1e-6);
        }
        let x = R.x;
        return vals.map(function (v) {
            const w = R.w * Math.max(v, 1e-6) / total;
            const r = { x: x, y: R.y, w: w, h: R.h };
            x += w;
            return r;
        });
    }

ツリーマップ 9種 — 正方形に近く割る行の閉じ方・値が変わるとばねで動く並べ替え・セルを押して入るドリルダウン・増減率で塗る発散色・見出しと余白で分ける入れ子・大きさを測って合わせるラベル・順位が入れ替わるときの場所替え・四角と円の比較変形・フォルダ容量のヒーロー

ツリーマップ 見方の要点は、面積が値、色が増減率、入れ子の枠が層という分担です。並びは配置から紹介画面へ進み、01・02は配置と並べ替え、03〜05は層と色、06・07は文字と順位、08・09は変形と紹介画面です。

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

場面 累積面積 動いたフレーム 強さの平均
01 行の閉じ方 44.1% 19/23 73.9
02 ばねの並べ替え 41.6% 21/23 44.0
03 ドリルダウン 48.0% 15/23 45.4
04 発散色 22.4% 23/23 43.0
05 入れ子の余白 39.5% 18/23 19.0
06 ラベル合わせ 38.7% 22/23 53.2
07 順位の入れ替え 44.2% 23/23 61.6
08 円への変形 26.5% 12/23 32.8
09 ディスクのヒーロー 54.6% 12/23 65.7

もっとも広いのは54.6%の09で、もっとも狭いのは22.4%の04です。強さの平均は01の73.9が最大、05の19.0が最小で、04と07は23/23なので毎フレーム動き、08と09は12/23なので半分近いフレームで画面が止まっています。

01細長い帯ではなく正方形に近く割る配置

ディスク容量や予算の内訳をセルで見せるとき、セルが細長くなると小さな値が目に入りません。左は squarify が行にセルを足すたびに worstRow で最も細長いセルの比を測り直し、悪くなったら行を閉じ、右の slice は行を分けず縦の帯だけで切ります。セル12個での最悪の縦横比は左が1.58、右が28.73です。

細長い帯ではなく正方形に近く割る配置
新しいタブで開く

01 · 細長い帯ではなく正方形に近く割る配置 — 計測 累積面積 44.126% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

worstRowsquarifyslice
    function worstRow(areas, side) {
        let s = 0;
        let mx = 0;
        let mn = Infinity;
        for (const a of areas) {
            s += a;
            if (a > mx) {
                mx = a;
            }
            if (a < mn) {
                mn = a;
            }
        }
        const s2 = s * s;
        const d2 = side * side;
        return Math.max(d2 * mx / s2, s2 / (d2 * mn));
    }

02値が変わるとセルがばねで新しい位置へ向かう並べ替え

値がリアルタイムで変わるダッシュボードでセルが瞬間移動すると、どのセルがどこへ行ったか目が追えません。springStep がセルの四つの辺をそれぞれ減衰ばねで目標へ引き、目標は値が変わった瞬間だけ relayout が計算し直し、右の盤は目標をそのまま描くのでセルが跳ねます。文書のセルの値が2.6倍になった一回のあと、すべてのセルが目標の0.5px以内に入って留まる時刻は1.45秒で、最大の行きすぎは24.0pxです。

値が変わるとセルがばねで新しい位置へ向かう並べ替え
新しいタブで開く

02 · 値が変わるとセルがばねで新しい位置へ向かう並べ替え — 計測 累積面積 41.64% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SPRING_KspringSteprelayout
    function springStep(cur, vel, tgt, k, zeta, dt) {
        const c = 2 * zeta * Math.sqrt(k);
        const n = Math.max(1, Math.ceil(dt * 120));
        const h = dt / n;
        for (let s = 0; s < n; s += 1) {
            for (let j = 0; j < cur.length; j += 1) {
                vel[j] += (-k * (cur[j] - tgt[j]) - c * vel[j]) * h;
                cur[j] += vel[j] * h;
            }
        }
    }

03セルを押すと中の子セルへ拡大するドリルダウン

フォルダやカテゴリのように層のあるデータでは、セルを選ぶとそのセルが画面全体へ育つ動きがドリルダウンです。viewRect(p) が見る四角形をグループの四角形まで補間し、toScreen が世界座標をその四角形基準の画面座標へ移し、拡大が終わると kidLayout が同じ squarify で内側のセルを埋めます。最初のグループは横×1.92・縦×1.65、三つ目のグループは横×2.08・縦×2.60に広がり、縦横比の違うグループは均等でない拡大になります。

セルを押すと中の子セルへ拡大するドリルダウン
新しいタブで開く

03 · セルを押すと中の子セルへ拡大するドリルダウン — 計測 累積面積 48.039% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ZOOM_TviewRecttoScreen
    function viewRect(p) {
        if (zg < 0) {
            return PANEL_L;
        }
        const g = rootRects[zg];
        return { x: lerp(PANEL_L.x, g.x, p), y: lerp(PANEL_L.y, g.y, p), w: lerp(PANEL_L.w, g.w, p), h: lerp(PANEL_L.h, g.h, p) };
    }
    function toScreen(r, v) {
        const sx = PANEL_L.w / v.w;
        const sy = PANEL_L.h / v.h;
        return { x: PANEL_L.x + (r.x - v.x) * sx, y: PANEL_L.y + (r.y - v.y) * sy, w: r.w * sx, h: r.h * sy };
    }

04増減率ひとつで塗るパステルの発散色

上がり下がりをひと目で見せる相場やスコアの盤では、面積を値が、色を増減率が受け持つとセル一枚が二つの情報を別々に語ります。divergeColor(d) は絶対値が0.12以下なら中立色のままにし、超えたら ease でバラ色(負)かミント色(正)まで補間し、右の valueColor は値から色を取って同じ情報を二度言います。4セットとも0.12以下のセルが4個ずつで、上がり・下がりのセル数は6・4、5・5、6・4、5・5です。

増減率ひとつで塗るパステルの発散色
新しいタブで開く

04 · 増減率ひとつで塗るパステルの発散色 — 計測 累積面積 22.384% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

GRAY_NEAR_ZEROdivergeColorDELTA_SETS
    function divergeColor(d) {
        const a = clamp(Math.abs(d), 0, 1);
        const k = a <= GRAY_NEAR_ZERO ? 0 : ease((a - GRAY_NEAR_ZERO) / (1 - GRAY_NEAR_ZERO));
        return mix(NEUTRAL, d < 0 ? ROSE : MINT, k);
    }
    // 対比版 — 色を増減率ではなく値から取る(大きい値ほど濃いラベンダー)
    function valueColor(v) { return mix([236, 232, 250], [184, 170, 236], clamp(v / BASE[0], 0, 1)); }
    function signed(d) { const p = Math.round(d * 100); return (p > 0 ? '+' : '') + p + '%'; }

05グループの見出しと内側の余白で層を分ける入れ子のセル

部署・チーム・個人のように層が重なる構成は、見出しと余白がないと層の境目が見えません。layNode が見出しの帯を切り分け、padAt の余白だけ縮めた四角形で子を再び squarify し、左は深さごとに余白へ0.55を掛けて減らし、右はどの層にも同じ余白を与えます。余白8pxでの葉の面積が盤に占める割合は左が40.1%、右が19.1%で、いちばん小さい葉の短い辺は5.2px対0pxです。

グループの見出しと内側の余白で層を分ける入れ子のセル
新しいタブで開く

05 · グループの見出しと内側の余白で層を分ける入れ子のセル — 計測 累積面積 39.463% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

HEADER_HPAD_DECAYlayNode
    function padAt(depth, flat) { return flat ? PAD : PAD * Math.pow(PAD_DECAY, depth); }
    // ノード n を四角形 R に置く。見出し(深さ1・2)を切り分け、内側の余白だけ縮めた四角形で子を配置し直す
    function layNode(n, R, depth, flat) {
        const b = { r: R, inner: R, depth: depth, name: n.name, v: n.v, at: 0, kids: [] };
        seen[depth] += 1;
        b.at = depth === 0 ? 0 : (depth === 1 ? 0.4 + (seen[1] - 1) * 0.15 : (depth === 2 ? 1.5 + (seen[2] - 1) * 0.08 : 2.8 + (seen[3] - 1) * 0.03));
        if (n.kids.length === 0) {
            return b;
        }
        const p = padAt(depth, flat);
        const head = depth === 0 ? 0 : HEADER_H;
        const innerR = { x: R.x + p, y: R.y + head + (depth === 0 ? p : 0), w: Math.max(0, R.w - 2 * p), h: Math.max(0, R.h - head - p - (depth === 0 ? p : 0)) };
        b.inner = innerR;
        const lay = squarify(n.kids.map(function (k) { return k.v; }), innerR);
        b.kids = n.kids.map(function (k, i) { return layNode(k, lay.rects[i], depth + 1, flat); });
        return b;
    }

06セルの大きさを測って文字を縮めたり隠したりするラベル合わせ

小さなセルが多いチャートでは、名前がセルより長くて隣へあふれます。fitPx が estW で文字の幅を見積もり、15pxから2pxずつ下げてセルに収まる最大のサイズを探し、8px未満なら0を返して名前を隠しますが、右は12px固定なのであふれが隣の上にそのまま見えます。日本語の名前で14セルを数えると、盤の幅が1.0倍で隠れる4・あふれる5、0.45倍で6・8、0.25倍で9・12です。

セルの大きさを測って文字を縮めたり隠したりするラベル合わせ
新しいタブで開く

06 · セルの大きさを測って文字を縮めたり隠したりするラベル合わせ — 計測 累積面積 38.724% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

CHAR_WFONT_STEPHIDE_MIN
    function fitPx(s, r) {
        for (let px = FONT_MAX; px >= HIDE_MIN; px -= FONT_STEP) {
            if (estW(s, px) <= r.w - 6 && px * 1.3 <= r.h - 4) {
                return px;
            }
        }
        return 0;
    }

07値が流れて順位が入れ替わるときに場所を替えるセル

リアルタイムの順位がある占有率や訪問数の盤は、値が少し流れるたびに配置を計算し直すとセルが震えます。relayoutL は orderKey が変わるか、値が前回の配置の値から20%を超えてずれたフレームだけ呼ばれ、順位が入れ替わる二つのセルがばねですれ違って場所を替えます。一ラウンド約9.8秒のあいだの再計算は左が31回、右が527回です。

値が流れて順位が入れ替わるときに場所を替えるセル
新しいタブで開く

07 · 値が流れて順位が入れ替わるときに場所を替えるセル — 計測 累積面積 44.235% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

LCG_STEPorderKeyRANK_SPRING_K
        let apart = false;
        for (let i = 0; i < CELL_N; i += 1) {
            if (Math.abs(val[i] - usedVals[i]) > usedVals[i] * 0.2) {
                apart = true;
            }
        }
        if (apart || orderKey(val) !== usedKey) {
            relayoutL();
        }

08同じデータを四角いセルと円で行き来して比べる変形

チャートの種類で大きさの印象が変わることは、同じデータを四角いセルと円で行き来して見せるとすぐ分かります。左の半径は r = √(面積 × FILL / π) でセルの面積を守り、右は直径を値に比例させ、pushApart が重なった円を80回押し離します。円の面積の合計は盤の60.0%対42.4%で、最大と最小の面積の比は左が約6倍、右が約32倍です。

同じデータを四角いセルと円で行き来して比べる変形
新しいタブで開く

08 · 同じデータを四角いセルと円で行き来して比べる変形 — 計測 累積面積 26.478% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FILLpushApartPUSH_ROUNDS
        rectsL = squarify(vals, PANEL_L).rects;
        rectsR = squarify(vals, PANEL_R).rects;
        const radL = rectsL.map(function (r) { return Math.sqrt(r.w * r.h * FILL / Math.PI); });
        // 右は最大セルの直径を左の最大の円より10%大きくし、残りを値に比例させる
        const rBig = radL[0] * 1.1;
        const radR = vals.map(function (v) { return rBig * v / vals[0]; });
        ballsL = pushApart(rectsL, radL, PANEL_L);
        ballsR = pushApart(rectsR, radR, PANEL_R);

09フォルダごとの容量で埋まるディスク使用量のヒーロー

ストレージ・予算・トラフィックの内訳を一枚で見せる紹介画面は、表がセルに変わる瞬間が第一印象です。フォルダ名と容量の表が squarify を一度通ってセルになり、最大のフォルダ(映像220 GB)へ入って戻り、sizeCheck がセルの面積の合計を盤の面積で割って容量の合計に掛けた値が合計と等しいかを検算します。フォルダ8個の合計は730 GBで、盤には Σ 730 = 730 ✓ が出ます。

フォルダごとの容量で埋まるディスク使用量のヒーロー
新しいタブで開く

09 · フォルダごとの容量で埋まるディスク使用量のヒーロー — 計測 累積面積 54.558% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

sizeCheckMORPH_GAPviewRect
    function sizeCheck() {
        // セルの面積(ピクセル)の合計を盤の面積で割って容量の合計に掛けた値が合計と等しいか確かめる
        let area = 0;
        let want = diskTotal;
        if (inside()) {
            for (const r of kidRects()) {
                area += r.w * r.h;
            }
            want = disk[zg].v;
        }
        else {
            for (const r of tgt) {
                area += r.w * r.h;
            }
        }
        const got = area / (R9.w * R9.h) * want;
        return 'Σ ' + want + ' = ' + Math.round(got) + (Math.abs(got - want) < 0.5 ? ' ✓' : ' ✗');
    }

ツリーマップをコードで描くと、セルはいくつの値で決まるのでしょうか?

セル一つは値ひとつと四角形ひとつです

セル一つは、値ひとつ・配置関数が返した四角形ひとつ・その四角形の四つの辺に分かれます。辺を別々に持つと、ばねが左・上・右・下をそれぞれ目標へ引いて、セルが伸び縮みしながら動きます。

値 意味 使う関数
vals セルごとの値。面積がこの値に比例します squarify・slice
四角形 { x, y, w, h } 配置関数がセルに与えた位置 squarify・slice
edgesOf(r) 左・上・右・下の座標四つ springStep
kids セルの中に入っている子セルの一覧 layNode・kidLayout

合計が決まった全体にどう使うか

合計が決まった全体を分けて見せるツリーマップ 例としては、ディスク容量・予算の内訳・ポートフォリオの比率があります。09の sizeCheck はセルの面積の合計が容量の合計と等しいかを毎回確かめるので、値の一覧を替えるだけで検算がついてきます。

ダッシュボードのセルに使うなら、02と07の目標の計算を値が変わった瞬間に限る方法がそのまま合います。層のある探索画面なら、03の拡大の変換と05の見出し・余白をつなげれば足ります。

値を大きい順に並べ、行にセルを足しながら比を測り、行を閉じて四角を分け、セルの中で同じ計算をくり返すまで — ツリーマップが一枚計算される順番です

行にセルを足して比が悪くなったら行を閉じます

squarify は idx.sort で値を大きい順に並べ、セルを一つずつ足して worstRow を測り直します。新しい比 wr が今の行の cur より悪くなったら、そのセルは次の行へ回します。

        while (at < n) {
            const side = Math.max(Math.min(rw, rh), 1e-6);
            const row = [];
            const areas = [];
            let cur = Infinity;
            while (at < n) {
                const a = Math.max(vals[idx[at]], 1e-6) * k;
                const next = areas.slice();
                next.push(a);
                const wr = worstRow(next, side);
                if (areas.length > 0 && wr > cur) {
                    break;
                }
                areas.push(a);
                row.push(idx[at]);
                cur = wr;
                at += 1;
            }

閉じた行は、残りの四角形が横に長ければ左に縦の行として、そうでなければ上に横の行として置かれ、残りの四角形でも同じことをくり返します。

セルの中で同じ計算をくり返す

05の layNode は子を squarify で置いたあと各子で layNode を呼び直し、03の kidLayout は拡大が終わったあと、グループの内側のセルに同じ関数を使います。だから層がいくつあっても配置の関数はひとつです。

ツリーマップが壊れるのは、スライスだけで切って細くなったセル・どの層も同じ余白で消えた内側のセル・大きさを測らず隣へあふれた文字です

スライスだけで切るとセルが細くなる

slice は入力の順に縦の帯だけで切るので、セル12個の最悪の縦横比が28.73、平均も14.76です。同じ値を squarify で割ると1.58と1.24なので、小さなセルも帯ではなくセルとして残ります。この二つの平均は画面には出ず、コンパイルしたJSをノードで動かして確かめた値です。

どの層にも同じ余白を与えると内側のセルが消える

05の右の盤はどの層にも8pxを与えるので、深いセルは余白を引くと残る幅が0pxに近づきます。短い辺が5px以上で見えるセルは葉41個のうち38個で、いちばん小さい葉の短い辺は0pxです。

大きさを測らないと文字が隣へあふれる

06は盤の幅を0.45倍にすると、14セルのうち6セルが名前を隠し、12px固定では8セルがあふれます。数え方は、左の fitPx が0を返したセルを隠す、右は estW(名前, 12) がセルの幅から6を引いた値を超えるか高さが足りないセルをあふれるとして、日本語の名前でノードに数えさせました。CHAR_W = 1 で文字の幅を一文字1emと見積もるので、ほかの言語の名前では個数が変わることがあります。

目標を毎フレーム計算し直すとセルが震える

07の右の盤は update のたびに値へ±2.5%の雑音を載せて配置を計算し直し、一ラウンドに527回も再計算し、左は順位が変わるか値が20%を超えてずれたときだけの31回です。同じ値が流れても、左のセルは落ち着き、右のセルは震え続けます。

ツリーマップ九場面の完成ソースは下のzipに入っていて、展開に使うパスワードは5jqmrgdvです。

セルの数とばねの強さと余白を変えると形と読みやすさはどれだけ変わるのでしょうか — セルの数・ばね・余白を変えて比べます

名前 変えた値 感じ
セルをもっと増やして 01 CELL_N 12 → 24 最悪の比は squarify が1.58から2.08へ、slice が28.73から159.06へ広がり、行は7本から12本になります
ばねをもっと硬く 02 SPRING_K 60 → 140 0.5px以内に入って留まる時刻は1.45秒から0.93秒に縮みますが、行きすぎは24.0pxから23.5pxとほぼそのままです。減衰比が0.5で固定なので、届く時間だけが縮みます
余白をもっと厚く 05 PAD 8 → 12 葉の面積の合計は左が40.1%から28.7%、右が19.1%から5.8%になり、見えるセルは左が41個から38個、右が38個から21個になります

値を変える前に見ること

SPRING_K を上げると目標に届く時間は縮みますが、行きすぎの幅は減衰比 ZETA が決めるので、揺れを小さくしたいなら ZETA を上げます。02は0.5、07は0.6を使っています。

PAD は深さごとに0.55倍へ減る値なので、12pxに上げると最も深い層まで影響が広がります。上の数値は盤222×208の最初のラウンドのデータに結びついていて、盤の大きさを変えると比も面積の割合も変わります。

動きを減らす設定では配置が終わった一枚です — セルの名前と割合を文で読み上げ、キーボードでセルを選ぶアクセシビリティです

矢印キーでセルを選びEnterで押す

ステージの箱(role="application"、tabindex 0)にフォーカスを置くと、← ↑ は前のセル、→ ↓ は次のセルで、Enter・Spaceがそのセルを選びます。Pは一時停止、Nは一歩進める、Rはやり直しで、最初に矢印キーを押すと自動デモが止まって手動に移ります。

状態の行はデモのあいだ静かです

セルを指すと状態の行がそのセルの名前・値・割合を文で読みます。デモ中は aria-live="off" なので読まれず、最初に触れると polite に替わり、キャンバスは role="img" と項目ごとの説明を持ちます。一ラウンドが終わる0.6秒前に、隠れた role="status" が statusText() を一度だけ読みます。

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

動きを減らす設定ではループなしで REST_S 秒の地点の配置が終わった一枚を描き、入力が入ると settleStill() が3秒ぶんの update を回して、トゥイーンなしで最後の配置をすぐ描きます。タブが隠れると止まり、戻ると時計を合わせ直します。

文字のコントラスト

セルのパステルの面の上で濃いインク(INK)は最小5.9:1、見出しの帯の上は5.2:1、補助のインク(INK2)は最小4.9:1です。盤の地の上のINKは11.3:1、ピル形ラベルの文字は14.0:1です。

ツリーマップのよくある質問 — ライブラリなしで作る方法・ツリーマップが円グラフとどう違うか・値が負や0のときの扱い

ツリーマップ 作り方にライブラリは必要ですか

キャンバス2Dがあれば足り、この編の九つの場面はライブラリ・WebGL・CDNを使いません。配置は squarify ひとつの関数で、乱数はシードを固定した生成器ひとつなので、同じ入力は同じ盤を作ります。

ツリーマップは円グラフとどう違いますか

円グラフは角度で割合を見せ、ツリーマップは四角形の面積で見せるので、セルが多いデータや層のあるデータはツリーマップが一枚に収められます。08は同じデータを四角いセルと円で行き来して見せるので、面積で読むときと直径で読むときに大きな値がどれだけ違って見えるかを比べられます。

値が負や0のときはどう扱いますか

squarify と slice は値を Math.max(v, 1e-6) に引き上げて、面積が0のセルが生まれないようにします。負の値は面積で表せないので、04のように増減は色で別に見せるのが合います。

参考として、MDNのmeasureTextの文書は文字の幅を測る方法を、requestAnimationFrameの文書と基本のアニメーションのチュートリアルは描くループを、prefers-reduced-motionの文書は動きを減らす設定を扱っています。同じ計算方式のチャートは棒グラフの編と円グラフの編で、セルを詰める配置はレンガ状レイアウトの編と組織図の編で見られます。

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

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