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

円グラフ アニメーション 9種 — 飛び出し・傾き・変形する扇形

円グラフは描き終わった後の動きで伝わり方が変わります — 02番はマウスを乗せた扇形が中心線に沿ってばねのように飛び出し、03番は円盤を傾けて側面を奥から順に塗り、06番は円がほどけて棒グラフに変わります。

仮の値で描いた円グラフ九枚 — 扇形に直接触れて確かめる

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

円グラフ アニメーション 9種 — 掃き出し・飛び出し・3D傾き・比率の変形・ラベル押し出し・棒への展開・サンバースト・ドーナツの穴・光の質感

並び順は、円が初めて描かれる瞬間(01)から始まり、扇形をひとつ選んで浮かせる手つき(02・03)、数字が変わったりラベルが混み合ったりしたときの処理(04・05)、円が別の形へ移る切り替え(06・07・08)を通って、円盤の表面を画素ごとに塗る場面(09)で終わります。九つとも<canvas>の2Dコンテキスト一枚に描き、スタイルシートが受け持つのは舞台の縦横比480:300とボタン、フォーカスの枠だけです。@keyframesは使っておらず、ボタンの背景色の切り替えひとつを除けば、動きはどれもrequestAnimationFrameが16msの固定ステップで進めています。

値はシードひとつから作った仮の数字

グラフに入る値は実際の統計ではなく、32ビット整数をかき混ぜるmakeRng(seed)が作った仮の数字で、扇形の名前も「項目 A」から「項目 I」までの置き場所にすぎません。既定のシードは467なので、01の七つの値は12・40・10・24・38・12・34、合計170に決まって出てきて、「新しいデータ」を押すとシードが1増えて別の値になります。九つのindex.htmlのどこにもMath.random・Date.now・fetch・localStorageがないため、この記事に書いた比率と時刻は同じシードで動かせばそのまま再現されます。

480×300の窓で2秒間に24枚撮って比べた動き

場面ごとに2秒で24枚を撮り、隣り合う2枚どうしを比べたので、動いたフレームの欄は23が分母になります。

場面 累積面積 強度の平均 動いたフレーム
01 時間差の掃き出し 22.6% 122.5 19/23
02 飛び出すばね 25.6% 84.0 23/23
03 3D傾きと側面 19.7% 85.1 23/23
04 期間切り替えの角度補間 23.7% 84.4 20/23
05 ラベルの押し出し 5.3% 90.6 18/23
06 円から棒へ 26.3% 156.5 19/23
07 サンバーストの展開 26.0% 79.8 18/23
08 ドーナツの穴と合計の数え上げ 22.9% 103.2 22/23
09 ポインターの光と質感 22.0% 26.6 23/23

05だけ累積面積が5.3%と低いのは、円がその場にとどまり、外側のラベルと引き出し線だけが上下に動く場面だからです。01は200msで掃き始めて1,580msに描き終わり、次の回まで止まっているので19枚、05と07も押し出しや展開が済んだ区間で画面が止まって18枚にとどまります。23/23になったのは02・03・09の三つで、09は光が移るにつれて広い面の明るさが少しずつ変わる計算なので、面積のわりに強度の平均が26.6と低く出ます。

円グラフをどの画面に置くか

レポートの表紙やアンケート結果のカードのように比率を一度見せれば済む場所なら01の時間差の掃き出しで足り、ダッシュボードで扇形を指して確かめてもらうなら02の飛び出しが土台になります。発表資料の表紙のように立体感がほしいグラフは03、月や四半期ごとに構成比が変わるカードは04が受け持ちます。項目が多くてラベルが重なるレポートは05、大きさの比較を同じカードで切り替えて見せたいときは06、カテゴリの下にサブカテゴリが付く構成比は07が向いています。08は中央にKPIの合計を大きく置くカード、09はランディングページの指標の場面のように、グラフを飾りとしても使う場所に合います。

数字の並びはどうやって扇形の始まりと終わりの角度になるのか?

円グラフ javascript で組むときも、角度は「値 ÷ 合計 × 2π」ひとつで決まります。下のlayoutは値を前から順に足し込み、その累積和を合計で割った商に2πを掛けて、扇形ごとに始まりの角度a0、終わりの角度a1、比率shareを作ります。前の扇形のa1がそのまま次の扇形のa0になるのですき間や重なりが入り込む余地がなく、最後の扇形は累積和が合計に追いついた瞬間なのでちょうど2πで閉じます。角度の原点TOPは−π/2、つまり12時の位置で時計回りに増え、dirX・dirYがこの角度にTOPを足して画面上のコサインとサインに変えます。

    function layout(values) {
        let total = 0;
        for (let i = 0; i < values.length; i += 1) {
            total += values[i];
        }
        const out = [];
        let acc = 0;
        for (let i = 0; i < values.length; i += 1) {
            const a0 = (acc / total) * TAU;
            acc += values[i];
            out.push({ value: values[i], share: values[i] / total, a0: a0, a1: (acc / total) * TAU });
        }
        return out;
    }
    function dirX(a) { return Math.cos(TOP + a); }
    function dirY(a) { return Math.sin(TOP + a); }

01のシード467の値を入れると、合計170に対して比率は7.1・23.5・5.9・14.1・22.4・7.1・20.0%になります。04・07を除く七つの場面がこの関数で角度を作り、04は項目の順序を固定した累積をanglesOfで別に行い、07は親の範囲を子の比率で分け直すsplitを使います。ドーナツグラフは円グラフとは別物として探されがちですが、このソースではwedgePathに渡す内側の半径ひとつの違いです。内側の半径が0.5px以下なら中心の点まで閉じた扇形を、それより大きければドーナツの一切れを描きます。

扇形が飛び出す向きはなぜ真ん中の角度のコサインとサインなのか

扇形の真ん中の角度midOf(i) = (a0 + a1) ÷ 2は、その扇形を左右に等しく分ける線の向きです。02は扇形全体を(dirX(m)、dirY(m))の向きへxピクセルずらして描きますが、この向きは長さ1の単位ベクトルなのでxがそのまま移動距離になり、扇形は円の中心からまっすぐ外へ離れて、左右の隣と同じ幅のすき間を開けます。真ん中の線から外れた向きに押すと、片方の隣とはすき間が開き、反対側の隣にはのしかかります。

逆に、ポインターがどの扇形の上にあるかは、その点の角度を求めて始まりと終わりの角度のあいだに入るかで判定します。下のangleAtはMath.atan2(y − cy, x − cx)で得た画面の角度からTOPを引いて0〜2πに移し、layoutの角度と基準をそろえます。九つの場面はどれも扇形を選ぶときにこの角度ひとつを使い、03は傾いた円盤の投影を逆にほどいて平面の座標に戻してからこの角度を求め、07は求めた角度を見ている範囲の比率に戻して節を探します。

    // ポインターが指す角度 — atan2 で求めた画面の角度を12時基準の 0 ~ 2π に移す
    function angleAt(x, y, cx, cy) {
        let a = Math.atan2(y - cy, x - cx) - TOP;
        while (a < 0) {
            a += TAU;
        }
        while (a >= TAU) {
            a -= TAU;
        }
        return a;
    }

01扇形ごとに時間差をつけて時計回りに掃くように描かれる円グラフ

中を抜かない円の上で七つの扇形がそれぞれ自分の始まりの角度から80msずつ遅れて伸びるので扇形のすき間が開いてから閉じ、半径も0.9RからRへ一緒に広がって、伸びていく終わりの角度には明るい線がつきます。描き終わるまでの時間は(7 − 1) × 80 + 900 = 1,380msで、easeOutCubicなので各扇形は自分の時間の半分が過ぎた時点ですでに角度の87.5%まで来ており、回ごとにシードが1増えて新しい仮の値で描き直します。レポートの表紙のように比率を一度広げて見せる場所に合い、描画を測った値は累積面積22.6%・強度122.5・19/23フレームです。

扇形ごとに時間差をつけて時計回りに掃くように描かれる円グラフ
新しいタブで開く

01 · 扇形ごとに時間差をつけて時計回りに掃くように描かれる円グラフ — 計測 累積面積 22.602% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SWEEP_MSSLICE_STAGGERDATA_SEED
    function progress(i) {
        if (elapsed < 0) {
            return 0;
        }
        return easeOutCubic((elapsed - i * SLICE_STAGGER) / SWEEP_MS);
    }

02マウスを乗せた扇形が中心線に沿ってばねのように飛び出す円グラフ

ポインターの下の扇形か押して固定した扇形だけが目標の距離16pxを受け取り、ばねの値xひとつを位置と明るさ、影のずれに一緒に使うので、扇形が持ち上がってくるように見えます。SPRING_K 170とSPRING_DAMP 14の減衰比は14 ÷ (2√170) = 0.537で、272msに17.82pxまで11.3%行き過ぎてから400ms以降は±0.5pxの内側にとどまり、出方の小さい扇形から先に塗るので飛び出した扇形が隣に隠れません。ダッシュボードで扇形を指して比率を確かめてもらう場所に向き、この場面の測定は累積面積25.6%・強度84.0・23/23フレームです。

マウスを乗せた扇形が中心線に沿ってばねのように飛び出す円グラフ
新しいタブで開く

02 · マウスを乗せた扇形が中心線に沿ってばねのように飛び出す円グラフ — 計測 累積面積 25.572% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

EXPLODE_DISTSPRING_KSPRING_DAMP
    function springStep(s, k, d, dt) {
        const acc = -k * (s.x - s.to) - d * s.v;
        s.v += acc * dt;
        s.x += s.v * dt;
    }
    // 選んだ扇形が中心角の向き(コサイン・サイン)へ押し出される距離(px)
    const EXPLODE_DIST = 16;

03円盤を傾けて側面の厚みまで描く立体の円グラフ

円盤を55°寝かせて縦をcos 55° = 0.574倍に縮めた楕円へ投影し、厚さ26pxはサインを掛けて画面では高さ21.3pxの側面として見えます。側面は4°の帯に分け、帯ごとに法線と左手前から来る光の内積で明るさを0.55〜1のあいだで決め、真ん中の角度が画面の奥にある扇形から塗る画家のアルゴリズムで手前の扇形が奥の扇形を覆うようにしています。発表の表紙に置く立体のグラフに向き、ポインターの高さで傾きが20〜75°のあいだを行き来して、測定は累積面積19.7%・強度85.1・23/23フレームです。

円盤を傾けて側面の厚みまで描く立体の円グラフ
新しいタブで開く

03 · 円盤を傾けて側面の厚みまで描く立体の円グラフ — 計測 累積面積 19.733% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

TILT_DEGDEPTH_PXSIDE_SHADE
    function proj(px, py, z) {
        return [CX + px, CY + py * Math.cos(tilt) + (DEPTH_PX / 2 - z) * Math.sin(tilt)];
    }
    function quad(p, c) {
        ctx.beginPath();
        ctx.moveTo(p[0][0], p[0][1]);
        for (let k = 1; k < p.length; k += 1) {
            ctx.lineTo(p[k][0], p[k][1]);
        }
        ctx.closePath();
        ctx.fillStyle = rgba(c, 1);
        ctx.fill();
        // 隣り合う帯のあいだの髪の毛ほどのすき間を同じ色の線で埋める
        ctx.strokeStyle = rgba(c, 1);
        ctx.lineWidth = 0.8;
        ctx.stroke();
    }
    // 側面の明るさ — 盤上の法線(nx, ny)と光の向きの内積、光に背を向けるほど SIDE_SHADE の分だけ暗くなる
    function sideColor(i, nx, ny) {
        const f = 1 - SIDE_SHADE * (1 - Math.max(0, nx * LIGHT_X + ny * LIGHT_Y));
        const base = mix(PAL[i], BG, 0.2);
        return [base[0] * f, base[1] * f, base[2] * f];
    }

04期間を切り替えると扇形の角度がつながったまま新しい比率に変わる円グラフ

三つの期間には七つの項目のうち五つずつしか値がないので、切り替えるたびに二つの項目が幅0から育って入り、別の二つの項目が0まで縮んで抜けます。項目の順序を固定したanglesOfの角度をapplyが始まりと終わりの角度それぞれ別にeaseInOutPow(t, 3)で700msかけて補間するため、隣どうしが境目を共有し、モーフィングの途中でも合計はちょうど360°で境目のずれは0です。月別や四半期別の構成比カードに使いやすく、測定は累積面積23.7%・強度84.4・20/23フレームです。

期間を切り替えると扇形の角度がつながったまま新しい比率に変わる円グラフ
新しいタブで開く

04 · 期間を切り替えると扇形の角度がつながったまま新しい比率に変わる円グラフ — 計測 累積面積 23.678% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MORPH_MSDATA_SETSEASE_POW
    function anglesOf(values) {
        let total = 0;
        for (let j = 0; j < ITEM_N; j += 1) {
            total += values[j];
        }
        const a0 = [];
        const a1 = [];
        let acc = 0;
        for (let j = 0; j < ITEM_N; j += 1) {
            a0.push((acc / total) * TAU);
            acc += values[j];
            a1.push((acc / total) * TAU);
        }
        return [a0, a1];
    }
    function apply() {
        const e = easeInOutPow(morphMs / MORPH_MS, EASE_POW);
        for (let j = 0; j < ITEM_N; j += 1) {
            curA0[j] = lerp(fromA0[j], toA0[j], e);
            curA1[j] = lerp(fromA1[j], toA1[j], e);
        }
    }

05外側のラベルが引き出し線を付け、重なると上下に押し出される円グラフ

1.4〜3.3%の小さな扇形六つが12時のすぐ後ろに集まっていて、折れ曲がる点の高さにラベルをそのまま置くと五組が重なるため、relaxPassが左右の群ごとに高さ順に並べ、間隔が16pxより狭い隣どうしを足りない分の半分ずつ上下に押します。40msに一度押して15回目、つまり600msで重なる組が0になり、いちばん遠くまで動いたラベルは29.3px移ります。項目の多いレポートに向き、円は動かずラベルだけが動くので測定は累積面積5.3%・強度90.6・18/23フレームで、九つのうち面積が10%を切るのはこの場面だけです。

外側のラベルが引き出し線を付け、重なると上下に押し出される円グラフ
新しいタブで開く

05 · 外側のラベルが引き出し線を付け、重なると上下に押し出される円グラフ — 計測 累積面積 5.303% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

LABEL_GAPRELAX_ITERSELBOW_LEN
    function relaxPass() {
        let moved = 0;
        for (const side of [-1, 1]) {
            const g = group(side);
            for (let j = 0; j + 1 < g.length; j += 1) {
                const d = g[j + 1].y - g[j].y;
                // 0.25px 以内まで縮まれば押し終えたとみなす — 半分ずつ押すやり方は間隔に限りなく近づくだけなので
                if (d < LABEL_GAP - 0.25) {
                    const push = (LABEL_GAP - d) / 2;
                    g[j].y -= push;
                    g[j + 1].y += push;
                    moved += 1;
                }
            }
            for (let j = 0; j < g.length; j += 1) {
                g[j].y = clamp(g[j].y, lo(side), hi(side));
            }
        }
        return moved;
    }

06円がほどけて同じ扇形が棒グラフに並び替わる切り替え

扇形の輪郭を中心から始まりの角度まで12点、弧に24点、終わりの角度から中心へ12点、合わせて48点で取り、棒の輪郭も左の辺12・上の辺24・右の辺12と同じ数を同じ向きで取ります。点kどうしを直線で補間するので極座標の扇形が直交座標の棒へ移り、扇形ごとに50msずつ遅れて動き出して、一つの扇形は450msかけて移動します。比率と大きさの比較を一枚のカードで切り替えて見せるときに使え、測定は累積面積26.3%・強度156.5・19/23フレームです。

円がほどけて同じ扇形が棒グラフに並び替わる切り替え
新しいタブで開く

06 · 円がほどけて同じ扇形が棒グラフに並び替わる切り替え — 計測 累積面積 26.331% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

UNROLL_MSBAR_GAPOUTLINE_SAMPLES
    function wedgeOutline(s) {
        const n = OUTLINE_SAMPLES;
        const q = Math.floor(n / 4);
        const out = [];
        for (let k = 0; k < n; k += 1) {
            let r = R;
            let a = s.a0;
            if (k < q) {
                r = (R * k) / q;
            }
            else if (k < n - q) {
                a = s.a0 + ((s.a1 - s.a0) * (k - q)) / (n - 2 * q);
            }
            else {
                r = R * (1 - (k - (n - q)) / q);
                a = s.a1;
            }
            out.push([CX + dirX(a) * r, CY + dirY(a) * r]);
        }
        return out;
    }

07内側の円と外側の輪に分かれ、押すとその枝が一周に広がるサンバースト

階層ごとに4・9・22個ある節の範囲を角度ではなく0〜1の比率で持ち、splitが親の範囲を子の値の比率で分け直し、描くときはmapAが見ている範囲[v0, v1]を0〜2πへ引き伸ばして、範囲の外の扇形は両端に幅0で寄せます。輪を押すとv0・v1が650msかけて補間され、自動の実演ではいちばん大きいBの枝(29.0%)の角度が3.446倍に伸びて一周を満たします。カテゴリの下にサブカテゴリが付く構成比に合い、測定は累積面積26.0%・強度79.8・18/23フレームです。

内側の円と外側の輪に分かれ、押すとその枝が一周に広がるサンバースト
新しいタブで開く

07 · 内側の円と外側の輪に分かれ、押すとその枝が一周に広がるサンバースト — 計測 累積面積 25.982% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

RING_WIDTHDRILL_MSLEVELS
        function split(n) {
            let acc = n.f0;
            for (let i = 0; i < n.kids.length; i += 1) {
                const c = n.kids[i];
                c.f0 = acc;
                acc += ((n.f1 - n.f0) * c.value) / n.value;
                c.f1 = acc;
                split(c);
            }
        }
    let v0 = 0;
    let v1 = 1;
    let from0 = 0;
    let from1 = 1;
    let drillMs = DRILL_MS;
    let pick = -1;
    // 比率 → 角度 — 見ている範囲 [v0, v1] を 0 ~ 2π に引き伸ばす。範囲の外は両端に寄って幅 0 になる
    function mapA(f) { return clamp((f - v0) / (v1 - v0), 0, 1) * TAU; }
    function nameOf(n) { return n.depth === 1 ? LABELS[n.top] : n.code; }
    function ring0(d) { return HOLE_R + (d - 1) * RING_WIDTH; }

08真ん中の穴がばねで開いてドーナツグラフになり、合計の数字が増えていく切り替え

「ドーナツにする」を押すと内側の半径の比率が減衰比0.408のばねで0から0.55へ向かい、256msに0.676まで行き過ぎてから656msで落ち着きます。真ん中の合計は穴が開き始めた瞬間から900msかけて0から1,786までeaseOutCubicで数え上げ、扇形を選ぶとその扇形の値で数え直します。KPIの合計を中央に大きく置くカードに合い、測定は累積面積22.9%・強度103.2・22/23フレームです。

真ん中の穴がばねで開いてドーナツグラフになり、合計の数字が増えていく切り替え
新しいタブで開く

08 · 真ん中の穴がばねで開いてドーナツグラフになり、合計の数字が増えていく切り替え — 計測 累積面積 22.868% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

HOLE_RATIOHOLE_SPRINGCOUNT_MS
    function setOpen(on) {
        open = on;
        hole.to = on ? HOLE_RATIO : 0;
        if (on) {
            shown = 0;
            countToward(target());
        }
        if (holeBtn) {
            holeBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
        }
    }

09ポインターを光に見立てて扇形ごとに明るさと光沢が変わる円グラフの場面

平らな色で描いた扇形をgetImageDataで一度だけ読んで地の色を取り、縁からの距離で決まる9pxの丸い段と、中心からの半径で決まり中央へ行くほど14pxふくらむドームから画素ごとの高さと法線を焼いておき、フレームごとに248×248の板の61,504画素を回って、そのうち扇形の上にある42,142画素だけ、ポインターの上120pxの高さに置いた光で拡散と光沢を計算し直します。光沢の指数28では法線とハーフベクトルが12.7°開くとハイライトが半分に落ち、±8の粒のノイズがパステルの面に細かな質感をのせます。ランディングページの指標の場面のようにグラフを飾りとしても使う場所に向き、測定は累積面積22.0%・強度26.6・23/23フレームです。

ポインターを光に見立てて扇形ごとに明るさと光沢が変わる円グラフの場面
新しいタブで開く

09 · ポインターを光に見立てて扇形ごとに明るさと光沢が変わる円グラフの場面 — 計測 累積面積 21.997% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

LIGHT_HEIGHTGLOSS_POWGRAIN_AMT
    function shade() {
        const o = outImg.data;
        const lx = lightX - BX0;
        const ly = lightY - BY0;
        for (let y = 0; y < BS; y += 1) {
            for (let x = 0; x < BS; x += 1) {
                const p = y * BS + x;
                const q = p * 4;
                const al = alpha[p];
                if (al <= 0) {
                    o[q + 3] = 0;
                    continue;
                }
                const nx = normals[p * 3];
                const ny = normals[p * 3 + 1];
                const nz = normals[p * 3 + 2];
                let vx = lx - x;
                let vy = ly - y;
                let vz = LIGHT_HEIGHT - heightMap[p];
                const inv = 1 / Math.sqrt(vx * vx + vy * vy + vz * vz);
                vx *= inv;
                vy *= inv;
                vz *= inv;
                const diff = Math.max(0, nx * vx + ny * vy + nz * vz);
                // ハーフベクトル — 光の向きと見る向き(正面 0, 0, 1)の中間
                const hz = vz + 1;
                const hinv = 1 / Math.sqrt(vx * vx + vy * vy + hz * hz);
                const nh = Math.max(0, (nx * vx + ny * vy + nz * hz) * hinv);
                const spec = Math.pow(nh, GLOSS_POW) * SPEC_K * 255;
                // 拡散の取り分を 1.12 まで開け、光を正面から受ける所は地の色より少し明るくなる
                const lit = (AMBIENT + (1.12 - AMBIENT) * diff) * (pick >= 0 && sliceId[p] === pick ? 1.12 : 1);
                const g = grain[p] * GRAIN_AMT;
                o[q] = baseRGB[p * 3] * lit + spec + g;
                o[q + 1] = baseRGB[p * 3 + 1] * lit + spec + g;
                o[q + 2] = baseRGB[p * 3 + 2] * lit + spec + g;
                o[q + 3] = 255 * al;
            }
        }
        lctx.putImageData(outImg, 0, 0);
        dirty = false;
    }

細すぎる扇形・片側に寄るラベル・0を割り込む穴の半径で、円グラフの計算はどう壊れるのか

ここに書いた内容は、ソースの関数を直接呼び出して測った値か、ソースの式で計算し直した値で、機種ごとのフレーム所要時間は測っていません。

細い扇形の比率の文字は伸び切ってから書く

01が伸びている途中に比率を書くと小さな扇形では文字が扇形の外にはみ出すので、進み具合が1になった6%以上の扇形にだけ文字を描きます。シード467で5.9%の三つ目の扇形に文字がないのはこのためです。時間差にも限りがあり、(扇形の数 − 1) × 80msが900msを超えると最初の扇形が伸び切ってから最後の扇形が動き出すので、広がる流れが途切れて見えますが、いまは480msなので七つの扇形が重なり合いながら育ちます。

飛び出した扇形の外の端でホバーがちらつく

判定の半径を元の半径Rだけにすると、飛び出した扇形の外の端(R + x)を指したときにホバーが外れてはまたかかり、扇形が震えます。02のhitSliceはR + x + 4まで見てこのちらつきを防ぎます。減衰比を1以上に上げると行き過ぎずにゆっくり近づいて跳ね返りが消え、0.2より下げると何度も揺れてグラフ全体がぐらついて見えます。

ラベルが範囲の端に集まると押し出しが遅くなる

上の境界に張り付いたラベルはそれ以上上がれず、下側の隣だけが半分ずつ下がるので、重なりがほどけるまでに時間がかかります。05の最初の試作は小さな扇形を左上に置いていて押し出しを繰り返しても重なる組が残ったので、小さな扇形を右上へ移しました。半分ずつ押すやり方は間隔に限りなく近づくだけなので、ちょうど16を条件にすると上限の40回まで回り切ってしまい、0.25pxの余裕を持たせています。

データの順に塗ると奥の扇形の側面が手前に出てくる

03は扇形を真ん中の角度のサインの順に並べ、画面の奥から塗ります。データの順に塗ると、奥の扇形の側面が手前の扇形の上に描かれてしまいます。ただし一つの扇形が180°を超えると、真ん中の角度ひとつで決めた順序が側面の一部で狂うことがありますが、このデータで最も大きい扇形は26.3%なのでその心配はありません。

同じ位置ではなく同じ項目どうしを組にする

04で期間ごとに残った扇形を配列の順番で組にすると、色の違う項目へ乗り移って比率があらぬ方向へ流れます。始まりの角度と幅を別々に補間しても隣とのあいだにすき間や重なりが出るので、二つの境目を補間して隣どうしが境目を共有するようにしています。切り替えの途中でもう一度押したときに目標の期間の最初の角度から出発すると画面が跳ぶため、goPeriodはいま見えている角度から出発します。

点の数と回る向きが違うと途中の形がねじれる

06の二つの輪郭は同じOUTLINE_SAMPLESで取らないと点どうしの組が合いません。片方だけ逆向きに回ると点がすれ違うように動いて途中の形がねじれ、点を12個ほどに減らすと大きな扇形の弧が角ばった多角形に見えます。いまの48点では、弧の点と点の間隔はいちばん広い所で4.2°です。

引き伸ばした角度を切らないと一周を超える

07で見る範囲を広げるとき、範囲の外の扇形の角度を切らずにおくと2πを超えて一周以上重ねて描かれます。mapAのclampがそうした扇形を両端に幅0で寄せています。輪の数を4に増やすと外側の輪の半径が28 + 4 × 34 = 164pxになり、直径が舞台の高さ300を超えます。

閉じるばねが0を割り込むとarcが例外を投げる

08の穴が閉じるとき、ばねは0より下にも行き過ぎます。負の半径をarcに渡すと例外が出てフレームのループが止まり、最初の試作では実際に止まりました。そこで内側の半径はR × max(0, x)で切り、穴の縁の線は5px以上のときだけ引きます。数え上げは穴が開き始めた時点から900ms、穴は656msで落ち着くので、二つの終わる時刻がずれる点も覚えておきます。

画素を毎フレーム読むとループが遅れる

09がフレームごとにgetImageDataで地の色を読み直すと、ヘッドレスの描画や性能の低い端末でrequestAnimationFrameが遅れます。地の色は焼くときに一度だけ読んで配列に置き、光の計算も倍率2の実際の画素ではなく等倍の板で行って手間を4分の1にしてから引き伸ばして描きます。その代わり倍率2の画面では少しやわらかく見え、粒のノイズを強めた最初の試作はパステルの面がまだらになったので、いまの±8に抑えました。

狭い画面でも押す位置はずれない

舞台の幅はmin(92vw, calc(160vh − 12.8vw))で480:300の比率なので、画面が狭くなるとキャンバスが丸ごと縮みます。内部座標は480×300のまま、getBoundingClientRectの比でポインターを場面の座標に直して判定するので、小さくなった円でも選ぶ扇形がずれません。実際の画素数は端末の倍率を掛けたうえで2で打ち切り、幅340px以下ではボタンと状態表示の文字を10pxに縮めます。

扇形の値やばねの定数を自分のものに差し替えて試すための圧縮ファイルは、パスワードが yukp4mxx です。場面ごとのフォルダにバニラのソースがあり、同じ計算をReactに移した版も添えてあります。

EXPLODE_DIST 30・TILT_DEG 70・HOLE_RATIO 0.75 に書き換えた結果と、扇形を色だけで読ませないアクセシビリティ

名前 値 感じ
飛び出す距離を伸ばす 02 EXPLODE_DIST 16 → 30 半径112pxに対して14.3%から26.8%まで離れ、扇形がはっきり円から抜け出します。行き過ぎ11.3%といちばん遠くへ出る272msは変わらず、行き過ぎる距離だけが1.82pxから3.40pxになります。
傾きの角度を大きくする 03 TILT_DEG 55 → 70 縦の比率が0.574から0.342へ平たくなり、縦の半径は64.2pxから38.3pxに縮みます。見える側面は21.3pxから24.4pxへ少し増えるだけですが、側面と縦の半径の比が0.332から0.638になり、円盤が倍近く厚く見えます。
ドーナツの穴を広げる 08 HOLE_RATIO 0.55 → 0.75 輪の厚みが半径の45%から25%へ、塗られる面積が69.75%から43.75%へ減って、中央の数字の場所が広くなります。行き過ぎは22.9%・256msで同じで、いちばん開いた瞬間は内側が103.2pxまで広がり、輪が8.8pxしか残りません。

扇形の区別は色だけに任せていません。扇形を選ぶと状態表示が「項目 B · 27.7%」のように名前と比率を文字で読み上げ(07は「B-2 · 11.9%」のようにサブの枝のコード)、選んだ扇形は02では飛び出した距離、03では10pxの持ち上がり、08では中央の数字でも見分けられます。01は伸び切った6%以上の扇形に比率を直接書いておきます。

マウスがなくても九つの場面はどれも操作できます。どのキャンバスにもtabindex="0"とaria-labelがあってフォーカスが入り、←/→で扇形を移して選ぶ操作とEscapeで選択を外す操作は九つに共通です(07はEscape・Backspaceで一段上へ戻ります)。本物の<button>は04が期間1・2・3の三つ、ほかは二つずつで合わせて19個あり、04の期間ボタンと02・06・08・09のオン・オフのボタンはaria-pressedで状態を伝えます。03は↑/↓で傾きを5°ずつ変えます。

状態表示は自動の実演のあいだaria-live="off"で黙っていて、人が初めてポインターやキー、ボタンに触れた時点でpoliteに変わり、それ以降の選択だけを読み上げます。prefers-reduced-motionは各場面が読み込まれたときに一度だけ確かめ、オンなら九つともフレームのループを始めずにsettle()で終わりの状態を作ってからdraw()を一度呼びます。設定の変化を見張るリスナーは置いていないので、途中で設定を切り替えたときはページを読み込み直すと反映されます。この状態でも入力は受け付け、押すたびにafter()が結果まで計算して描き直しますが、01と05のEnterだけは終わりの状態へ直接行く動作なので、前と同じ絵が残ります。

円グラフ css を円錐グラデーションだけで描くと、どこで行き詰まるか?

止まった円グラフ一枚ならCSSだけでも描けます。conic-gradientは角度の区間ごとに色を塗り分けるので、比率を累積した角度を色の停止点に入れれば、layoutが作るのと同じ扇形が背景一枚で出てきます。html css 円グラフを手早く置くならこの背景一枚が出発点になりますが、この記事の九つの場面はCSSではなくキャンバスとJavaScriptの計算で作っています。背景一枚からは扇形を一つだけ切り離して動かしにくく、02の飛び出し、03の側面、06の棒への変形はそのまま移せませんし、どの扇形を指したかを知るにもangleAtのようなatan2の計算が別に要ります。05のラベル押し出し、08の数え上げ、09の画素の照明も繰り返しの計算なので、スクリプトの受け持ちです。

同じデータ可視化の流れにあるほかのグラフは、SVGで描くグラフ9種、キャンバスのチャートアニメーション9種、計算で描くデータ可視化チャート9種にまとめています。値ひとつでグラフを描き直す方向ならグラフアニメーションのピッカー、画素の計算で場面を作る方向ならキャンバス特殊効果9種が近い題材です。

自分の数字で円グラフを動かしたい人からよくある三つの問い

javascript 円グラフ 自作にグラフのライブラリは要りますか?

要りません。この記事の九つの場面は、ライブラリやCDN、WebGLを使わず、キャンバス2Dのarcとパスの塗りつぶし、requestAnimationFrameを土台に描いていて、画素に光を当てる09だけがgetImageDataとputImageDataを加えて使います。角度はlayoutの累積の割り算、選択はangleAtのatan2ひとつで済むので、軸や凡例を別に用意しなくてよい円グラフなら、この二つの関数が出発点になります。

自分の数字を入れるにはどこを変えますか?

01・02・03・08・09はseededValuesが作った仮の値をlayoutに渡す一行で扇形を決めているので、seededValuesの代わりに自分の数字の配列を渡せば入れ替わります。扇形の数が変わるときはSLICE_Nも合わせ、9個を超えるなら名前の配列LABELSも増やします。04は期間ごとの配列を作るbuildSets、05はmakeValues、06はsetDataの中のseededValuesの行、07は枝の木を作るbuildTreeがその場所です。

スクリーンリーダーでは扇形の比率がどう読まれますか?

キャンバスにフォーカスを置いて←/→で扇形を移すと、状態表示が選んだ扇形の名前と比率を一行で読み上げます。自動の実演が動いているあいだは読まず、最初にキーを押してから読み始めるので、実演の動きのたびに音声があふれることはありません。

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

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