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

ネットワークグラフ9種 — 力で押し引きし自ら並ぶキャンバス

点と線に反発力と引力を与えるだけで、絡み合った関係がひとりでに読みやすい図に整います。毎フレーム配置を計算し直すキャンバスエンジン — ライブラリなしのコピペです。

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

デモ

各盤は index.html と style.css の2ファイル単独で動き、追加の読み込みは一切ありません。すべてのノードの組について反発力を合算し、エッジのばね力を足し、速度を減衰させて座標を動かす — この3行が9つすべての土台です。24フレームの録画で9つすべてが23フレーム動き続けました(23/23)。02と08はポインター操作ができ、残りの7つは開いた瞬間から自ら配置を解き始めます。この一覧はライブラリなしのネットワークグラフ 可視化を9つの断面に分けたものです。

盤 何が解けるか 累積面積(%) フレーム最大(%) 強度平均 フレーム
01 基本フォース配置 反発+ばねの最小実装 46.931 10.174 53.7 23/23
02 ドラッグ再配置 掴んだ1点から全体が再平衡 39.459 7.694 53.7 23/23
03 クラスタ色分け エッジ0本で色集団が分離 55.851 10.608 42.1 23/23
04 衝突回避 位置補正で重なり0 31.530 5.551 83.3 23/23
05 階層ツリー ルートから放射する組織図 32.872 7.752 53.2 23/23
06 放射軌道 極座標で回るリング配置 35.355 6.037 57.3 23/23
07 関係の強さ 重みが長さと太さを決める 47.947 9.865 55.6 23/23
08 大規模パン&ズーム 140ノードのカメラ漫遊 70.841 21.407 28.3 23/23
09 表紙の関係図 機能名が脈動するヒーロー 36.535 6.272 52.7 23/23

累積面積が最も広いのは08の70.841%です。カメラが1400×900のワールド座標をゆっくり見渡し、140個のノードが毎フレーム別の場所を埋めるため、画面の7割が塗り替わり続けます。逆に強度平均が最も高いのは04の83.3で、これは力ではなく位置そのものを押し返す衝突補正パスが画素をはっきり反転させるためです。面積と明るさは別の指標であることが、この2つの盤の対比から読み取れます。

反発力とばねから極座標まで、グラフ座標を求め直した順序

並び順の基準は派手さではなく力の項がどこへ1つ加わるかです。01は反発とばねだけの原形で、02が「掴んだ1点を固定する」という操作の項を、03はエッジを全部外して反発の非対称という項を足します。04は力の積分の後に位置を直す補正パスを重ね、05・06は平面上の自由な動きを放射・極座標という拘束に置き換え、07はエッジごとの重みというデータ側の項を加えます。08は力とカメラを分離して規模を伸ばし、09がラベルとグロウで第一印象を担う幕引きです。311の反応拡散から続く計算軸の系譜で、ここまでのどの回も「ノードとエッジの関係図を力で並べる」は扱っていませんでした。

項目別コード

ここからは各盤の定数と、動いている関数をそのまま引きます。切り詰めた断片ではなく動いているコードそのもので、コメントだけこの記事の言葉に置き換えてあり、変数名と関数名は zip の中身と同じ英語のままです。コードごとコピペすればそのままノードグラフ 作成の土台になります。

01基本フォース配置 — 反発力とばねだけで散った点が整う

すべてのノードの組 i<j について距離の2乗の逆数に比例する反発力 REPEL/d² を掛け、エッジで繋がった組には (距離−REST)×ATTRACT のばね力を足します。合力を dt で速度に積分し DAMP 0.955 で減衰して座標を動かす — プロパティのトゥイーンではなく力の積分で動く、フォースレイアウトの最小単位です。グラフはスパニングツリーで連結を保証した上に N×0.5 本のランダムエッジを足しています。累積面積46.931%・強度平均53.7は18ノードの密な再配置として妥当な値です。

基本フォースレイアウト — 反発力とバネだけで散った点が整う
新しいタブで開く

01 · 基本フォースレイアウト — 反発力とバネだけで散った点が整う — 計測 累積面積 46.931% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

arclineTorequestAnimationFrameMath.hypot

step — 反発とばねの合算

ペアごとの反発は O(n²) の全走査、ばねはエッジ本数分だけです。d² は最小4にクランプし、ゼロ除算と無限大を同時に防ぎます。

  function step(dt) {
    var fx = new Float32Array(N), fy = new Float32Array(N);
    for (var i = 0; i < N; i++) {
      for (var j = i + 1; j < N; j++) {
        var dx = px[j] - px[i], dy = py[j] - py[i];
        var d2 = dx * dx + dy * dy; if (d2 < 4) d2 = 4;
        var d = Math.sqrt(d2);
        var f = REPEL / d2;
        var ux = dx / d, uy = dy / d;
        fx[i] -= ux * f; fy[i] -= uy * f;
        fx[j] += ux * f; fy[j] += uy * f;
      }
    }
    for (var e = 0; e < edges.length; e++) {
      var a = edges[e][0], b = edges[e][1];
      var dx2 = px[b] - px[a], dy2 = py[b] - py[a];
      var d3 = Math.hypot(dx2, dy2) || 0.001;
      var diff = (d3 - REST) * ATTRACT;
      var ux2 = dx2 / d3, uy2 = dy2 / d3;
      fx[a] += ux2 * diff; fy[a] += uy2 * diff;
      fx[b] -= ux2 * diff; fy[b] -= uy2 * diff;
    }
  }

rotateAll — 収束後も画面を動かす剛体回転

力が収束して座標が静止しても、画面中心を軸に全ノードを同じ角度で回せばノード間の距離と角度は保存されたまま画面は動き続けます。力の計算を歪めないよう、常に step() の後に呼びます。

  function rotateAll(dtheta) {
    var cx = W / 2, cy = H / 2, cs = Math.cos(dtheta), sn = Math.sin(dtheta);
    for (var i = 0; i < N; i++) {
      var rx = px[i] - cx, ry = py[i] - cy;
      px[i] = cx + rx * cs - ry * sn;
      py[i] = cy + rx * sn + ry * cs;
    }
  }

02ドラッグ再配置 — 1つのノードを引くと全体が再びバランスを取る

01と同じ力の計算に、ポインターが掴んだ1ノードを積分から除外して毎フレームその座標へ固定する項を加えます。引く力が隣接するばねを伝って波及し、残りのノードがリアルタイムで席を探し直す — 静的な図を触れる道具に変える1行です。累積面積39.459%・強度平均53.7で、14ノードの再配置が画面の4割を塗り替えます。

ドラッグ再配置 — 1つのノードを引くと全体が再びバランスを取る
新しいタブで開く

02 · ドラッグ再配置 — 1つのノードを引くと全体が再びバランスを取る — 計測 累積面積 39.459% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

pointermovearclineTorequestAnimationFrame

step — 掴んだノードを固定する

pinnedIdx と一致したノードは速度を0にして積分を飛ばします。ほかのノードは普段どおり力を計算するので、固定点が動くたびに釣り合いの位置が全体で組み直されます。

    var cx = W / 2, cy = H / 2;
    for (var n = 0; n < N; n++) {
      if (n === pinnedIdx) { vx[n] = 0; vy[n] = 0; continue; }
      fx[n] += (cx - px[n]) * CENTER_K;
      fy[n] += (cy - py[n]) * CENTER_K;
      fx[n] += (rand() - 0.5) * JITTER;
      fy[n] += (rand() - 0.5) * JITTER;
      vx[n] = (vx[n] + fx[n] * dt) * DAMP;
      vy[n] = (vy[n] + fy[n] * dt) * DAMP;
      px[n] += vx[n] * dt; py[n] += vy[n] * dt;
    }

rotateAll — 固定ノードを回転から外す

掴んでいる間、そのノードだけ指の下に留めるため回転から除外します。exclude の1引数が、ポインター操作と剛体回転が衝突しないための仕切りです。

  function rotateAll(dtheta, exclude) {
    var cx = W / 2, cy = H / 2, cs = Math.cos(dtheta), sn = Math.sin(dtheta);
    for (var i = 0; i < N; i++) {
      if (i === exclude) continue;
      var rx = px[i] - cx, ry = py[i] - cy;
      px[i] = cx + rx * cs - ry * sn;
      py[i] = cy + rx * sn + ry * cs;
    }
  }

03クラスタ色分け — 同グループがより強く引き合い固まる

この盤にはエッジが1本もありません。代わりに同じグループ同士は INTRA_REPEL 0.5倍、違うグループ同士は INTER_REPEL 1.9倍と反発の強さを非対称にし、各グループの重心へ弱く引く CENTROID_K を足します。この2つだけで色ごとの集団が画面の上で自ら分かれる — つながりの情報なしにデータ内のクラスタを力だけで見せる構成です。累積面積55.851%はこの回で2番目に広く、30ノードが分離を続ける間画面の半分超が動きます。

クラスタ色分け — 同グループがより強く引き合い固まる
新しいタブで開く

03 · クラスタ色分け — 同グループがより強く引き合い固まる — 計測 累積面積 55.851% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

arcfillRectrequestAnimationFrameMath.hypot

step — 反発の非対称と重心引力

距離の2乗の逆数という形は01と同じで、係数 mult だけグループ関係で変わります。重心は毎フレーム全ノードから数え直します。

    for (var i = 0; i < N; i++) {
      for (var j = i + 1; j < N; j++) {
        var dx = px[j] - px[i], dy = py[j] - py[i];
        var d = Math.hypot(dx, dy) || 0.001;
        var d2 = d * d; if (d2 < 4) d2 = 4;
        var mult = grp[i] === grp[j] ? INTRA_REPEL : INTER_REPEL;
        var f = (REPEL * mult) / d2;
        var ux = dx / d, uy = dy / d;
        fx[i] -= ux * f; fy[i] -= uy * f;
        fx[j] += ux * f; fy[j] += uy * f;
      }
    }
    var cxg = new Float32Array(GROUPS), cyg = new Float32Array(GROUPS), cnt = new Float32Array(GROUPS);
    for (var n = 0; n < N; n++) { cxg[grp[n]] += px[n]; cyg[grp[n]] += py[n]; cnt[grp[n]]++; }
    for (var g = 0; g < GROUPS; g++) { if (cnt[g] > 0) { cxg[g] /= cnt[g]; cyg[g] /= cnt[g]; } }

重心への引力 — 集団を1点にまとめる項

計算した重心へ CENTROID_K 0.05の強さで引きます。反発の非対称が集団の境界を決め、この項が集団の中身を丸めます。

    var cx = W / 2, cy = H / 2;
    for (var m = 0; m < N; m++) {
      fx[m] += (cxg[grp[m]] - px[m]) * CENTROID_K;
      fy[m] += (cyg[grp[m]] - py[m]) * CENTROID_K;
      fx[m] += (cx - px[m]) * CENTER_K;
      fy[m] += (cy - py[m]) * CENTER_K;
      fx[m] += (rand() - 0.5) * JITTER;
      fy[m] += (rand() - 0.5) * JITTER;
      vx[m] = (vx[m] + fx[m] * dt) * DAMP;
      vy[m] = (vy[m] + fy[m] * dt) * DAMP;
      px[m] += vx[m] * dt; py[m] += vy[m] * dt;
    }

04衝突回避 — ノードは自分の半径分だけ押し合い重ならない

ノードごとに半径 6〜15 を与え、反発とランダムな漂遊力(SPEED 78)で自由に泳がせます。力の積分が終わった後に、中心距離が半径の和より近い組を見つけて重なりの半分ずつをその場で押し返す — 力ではなく位置そのものを直すハード衝突解決です。この補正パスが毎フレーム画素をはっきり反転させるため、累積面積31.530%はこの回で最も狭い部類なのに強度平均83.3は最も明るくなります。

衝突回避 — ノードは自分の半径分だけ押し合い重ならない
新しいタブで開く

04 · 衝突回避 — ノードは自分の半径分だけ押し合い重ならない — 計測 累積面積 31.53% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

arcMath.hypotrequestAnimationFramebeginPath

衝突補正パス — 位置を半分ずつ押し返す

反発力をどれだけ強くしても瞬間的に重なるフレームは残ります。積分の後に位置を直すこの1枚があって初めて、重なりが完全に消えます。

    for (var a = 0; a < N; a++) {
      for (var b = a + 1; b < N; b++) {
        var ddx = px[b] - px[a], ddy = py[b] - py[a];
        var dist = Math.hypot(ddx, ddy) || 0.001;
        var minDist = rad[a] + rad[b];
        if (dist < minDist) {
          var push = (minDist - dist) / 2;
          var nx = ddx / dist, ny = ddy / dist;
          px[a] -= nx * push; py[a] -= ny * push;
          px[b] += nx * push; py[b] += ny * push;
        }
      }
    }

自由漂遊 — 温度としてのランダム力

JITTER の代わりに SPEED 78のランダム力を毎フレーム足し、ノードが止まらない温度にします。04の漂遊はこの温度が主役で、衝突補正がその読みやすさを守ります。

    var cx = W / 2, cy = H / 2;
    for (var n = 0; n < N; n++) {
      fx[n] += (cx - px[n]) * CENTER_K;
      fy[n] += (cy - py[n]) * CENTER_K;
      fx[n] += (rand() - 0.5) * SPEED;
      fy[n] += (rand() - 0.5) * SPEED;
      vx[n] = (vx[n] + fx[n] * dt) * DAMP;
      vy[n] = (vy[n] + fy[n] * dt) * DAMP;
      px[n] += vx[n] * dt; py[n] += vy[n] * dt;
    }

05階層ツリー — ルートから放射するレベル別配置

ルート(0番)の下に parent[] で系譜を記録しながらノードを3レベルに付け、Math.atan2 でルート基準の角度を毎フレーム測り直します。目標半径 level×RSTEP へ引く放射ばね(RADIAL_K)と、同じレベルの兄弟を角度で押し離す接線方向の力(TANGENT_K)を合わせると、方向性のある組織図が整列します。この盤にもエッジ描画はなく、parent[] と level[] の2つの配列だけが構造を運びます。累積面積32.872%・強度平均53.2です。

階層ツリー — ルートから放射するレベル別配置
新しいタブで開く

05 · 階層ツリー — ルートから放射するレベル別配置 — 計測 累積面積 32.872% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

lineToarcrequestAnimationFrameMath.atan2

放射ばね — 角度を測り半径を目標へ引く

角度と半径を毎フレームatan2で読み直し、半径の差分を力に変換します。親への弱いばね PARENT_K が枝のつながりを画面に残します。

  function step(dt) {
    var fx = new Float32Array(N), fy = new Float32Array(N);
    var cx = px[0], cy = py[0];
    var ang = new Float32Array(N), rd = new Float32Array(N);
    for (var i = 1; i < N; i++) {
      var dx = px[i] - cx, dy = py[i] - cy;
      rd[i] = Math.hypot(dx, dy) || 0.001;
      ang[i] = Math.atan2(dy, dx);
      var targetR = level[i] * RSTEP;
      var diff = (rd[i] - targetR) * RADIAL_K;
      var ux = dx / rd[i], uy = dy / rd[i];
      fx[i] -= ux * diff; fy[i] -= uy * diff;
      var pdx = px[parent[i]] - px[i], pdy = py[parent[i]] - py[i];
      fx[i] += pdx * PARENT_K; fy[i] += pdy * PARENT_K;
    }

接線分離 — 兄弟をリングの上に広げる力

放射ばねだけでは同じレベルの兄弟が同じ角度に集まります。角度差に反比例する強さの接線力が、兄弟をリングの上に均等に押し広げます。

    for (var a = 1; a < N; a++) {
      for (var b = a + 1; b < N; b++) {
        if (level[a] !== level[b]) continue;
        var da = ang[a] - ang[b];
        while (da > Math.PI) da -= Math.PI * 2;
        while (da < -Math.PI) da += Math.PI * 2;
        if (Math.abs(da) < 0.001) da = 0.02;
        var strength = TANGENT_K / (Math.abs(da) * rd[a] + 8);
        var sign = da > 0 ? 1 : -1;
        var tx = -Math.sin(ang[a]), ty = Math.cos(ang[a]);
        fx[a] += tx * strength * sign; fy[a] += ty * strength * sign;
        var tx2 = -Math.sin(ang[b]), ty2 = Math.cos(ang[b]);
        fx[b] -= tx2 * strength * sign; fy[b] -= ty2 * strength * sign;
      }
    }

06放射軌道 — 同心円レベルを回りながら位置を定める

この盤は力の積分ではなく極座標で座標を直接計算します。ノードを4つのリングに6個ずつ付け、リングごとに符号が逆の角速度 RING_OMEGA で角度を進め、同じリング内で角度が近づくと SEP_K で押し離し、半径を BREATHE 14の幅で呼吸させます。回転はリング自身の角速度が担うため rotateAll はなく、それでも累積面積35.355%・強度平均57.3と十分に生きて動きます。

放射軌道 — 同心円レベルを回りながら位置を定める
新しいタブで開く

06 · 放射軌道 — 同心円レベルを回りながら位置を定める — 計測 累積面積 35.355% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

arcMath.sinMath.cosrequestAnimationFrame

step — リングごとの角速度と角度の分離

角度空間でのみ衝突を解きます。最小間隔 minGap は円周をPER_RINGで割った7割で、近づきすぎた組だけを押し返します。

  function step(dt, t) {
    for (var i = 0; i < N; i++) {
      angle[i] += RING_OMEGA[ring[i]] * dt;
    }
    for (var a = 0; a < N; a++) {
      for (var b = a + 1; b < N; b++) {
        if (ring[a] !== ring[b]) continue;
        var da = angle[a] - angle[b];
        while (da > Math.PI) da -= Math.PI * 2;
        while (da < -Math.PI) da += Math.PI * 2;
        var minGap = (Math.PI * 2 / PER_RING) * 0.7;
        if (Math.abs(da) < minGap && Math.abs(da) > 0.0001) {
          var push = (minGap - Math.abs(da)) * SEP_K * dt;
          var sign = da > 0 ? 1 : -1;
          angle[a] += push * sign;
          angle[b] -= push * sign;
        }
      }
    }

極座標への書き出し — 呼吸する半径

角度が決まれば座標は cos・sin の1行ずつです。半径に位相のずれた sin を足すことで、リング全体が静的な円ではなく呼吸する輪になります。

    var cx = W / 2, cy = H / 2;
    for (var n = 0; n < N; n++) {
      var baseR = (ring[n] + 1) * RSTEP;
      var rr = baseR + Math.sin(t * 0.7 + n * 0.6) * BREATHE;
      px[n] = cx + Math.cos(angle[n]) * rr;
      py[n] = cy + Math.sin(angle[n]) * rr;
    }
  }

07関係の強さ — 重みがリンクの長さと太さを変える

各エッジに重み w(0.15〜1.0)を付け、目標ばね長を LINK_BASE×(1.5−w) で計算します。重い関係ほど目標距離が短く、ばねの強さにも (0.5+w) が掛かります。描画側でも線幅 0.6+w×3.2 と不透明度 0.2+w×0.65 が w に比例し、強い関係は短く太く、弱い関係は長く細く描かれる — データの値を視覚言語に翻訳する1項です。累積面積47.947%・強度平均55.6。LINK_BASE は60から160まで動かせるのが3番目のバリエーションです。

関係の強さ — 重みがリンクの長さと太さを変える
新しいタブで開く

07 · 関係の強さ — 重みがリンクの長さと太さを変える — 計測 累積面積 47.947% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

lineTostrokeStyleglobalAlpharequestAnimationFrame

重みつきばね — 目標距離を w から作る

01と同じ反発・スパニングツリー+ランダムエッジの土台に、エッジごとの w という1つの項を足すだけです。rest の式が変わればグラフ全体の肌理が変わります。

    for (var e = 0; e < edges.length; e++) {
      var ed = edges[e], a = ed.a, b = ed.b;
      var rest = LINK_BASE * (1.5 - ed.w);
      var dx2 = px[b] - px[a], dy2 = py[b] - py[a];
      var d3 = Math.hypot(dx2, dy2) || 0.001;
      var diff = (d3 - rest) * ATTRACT * (0.5 + ed.w);
      var ux2 = dx2 / d3, uy2 = dy2 / d3;
      fx[a] += ux2 * diff; fy[a] += uy2 * diff;
      fx[b] -= ux2 * diff; fy[b] -= uy2 * diff;
    }

08大規模パン&ズーム — 数百のノードをキャンバス座標で見渡す

140個のノードを1400×900のワールド座標に置き、力の計算はその元の座標系のままで回します。画面に描くときだけ translate・scale のカメラ変換を掛け、人が操作しない間は autoCam が sin・cos でゆっくり見渡します。ホイール(×0.89〜1.12)とドラッグの値はそのままカメラに引き継がれ、物理とカメラが完全に分離します。累積面積70.841%はこの回で最も広く、強度平均28.3が最も低い — 140ノードが画面全体を埋め替える一方、ノード1個は小さく画素あたりの差は浅くなります。この盤にも rotateAll はなく、カメラの移動が画面の運動を担います。

大規模パン&ズーム — 数百のノードをキャンバス座標で見渡す
新しいタブで開く

08 · 大規模パン&ズーム — 数百のノードをキャンバス座標で見渡す — 計測 累積面積 70.841% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

requestAnimationFramearclineToMath.hypot

autoCam — 人が触れない間の自動見渡し

カメラの3つの値 camX・camY・zoom を周期の違う sin・cos で動かします。利用者のドラッグ・ホイールが入れば自動制御は退場し、その値を引き継ぎます。

  function autoCam(t) {
    camX = WORLD_W / 2 + Math.sin(t * 0.3) * WORLD_W * 0.32;
    camY = WORLD_H / 2 + Math.cos(t * 0.24) * WORLD_H * 0.28;
    zoom = 0.62 + 0.4 * Math.sin(t * 0.5);
  }

draw — ワールド座標を画面へ射影する3行

save から restore までの間だけカメラ変換が効きます。線幅を 1/zoom で割るのは、縮小したときに線が消えないためです。

    ctx.save();
    ctx.translate(W / 2, H / 2);
    ctx.scale(zoom, zoom);
    ctx.translate(-camX, -camY);

    ctx.lineWidth = 1 / zoom;
    ctx.strokeStyle = 'rgba(' + C_EDGE.join(',') + ',0.5)';
    ctx.beginPath();
    for (var e = 0; e < edges.length; e++) {
      var a = edges[e][0], b = edges[e][1];
      ctx.moveTo(px[a], py[a]); ctx.lineTo(px[b], py[b]);
    }
    ctx.stroke();
    ctx.restore();

09表紙の関係図 — サービス機能を力で結んで見せるヒーロー

製品機能名(ログイン・決済・通知など10個)をラベルにしたノードへ、01と同じスパニングツリー+ランダムエッジのグラフを力で配置します。各ノードは createRadialGradient の柔らかい光をまとい、位相の違う sin パルスで脈動します。力の計算と OMEGA 0.32の回転が合わさって、初訪問でサービスの構造がひと目で生き生きと動く — 計算シーン1つが第一印象になる組立例です。累積面積36.535%・強度平均52.7。

表紙の関係図 — サービス機能を力で結んで見せるヒーロー
新しいタブで開く

09 · 表紙の関係図 — サービス機能を力で結んで見せるヒーロー — 計測 累積面積 36.535% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

arclineTorequestAnimationFramecreateRadialGradient

buildGraph — スパニングツリーで連結を保証する

a=1..N−1 で必ず過去のノードへ1本ずつ結ぶことでグラフが断片に分れないことを保証し、その上に N×0.4 本のランダムエッジで循環を作ります。シードつき乱数なので毎回同じグラフが再現されます。

  function buildGraph() {
    px = new Float32Array(N); py = new Float32Array(N);
    vx = new Float32Array(N); vy = new Float32Array(N);
    for (var i = 0; i < N; i++) { px[i] = rand() * W; py[i] = rand() * H; }
    edges = [];
    for (var a = 1; a < N; a++) edges.push([a, Math.floor(rand() * a)]);
    var extra = Math.round(N * 0.4);
    for (var k = 0; k < extra; k++) {
      var u = Math.floor(rand() * N), v = Math.floor(rand() * N);
      if (u !== v) edges.push([u, v]);
    }
  }

脈動するグロウ — 位相のずれた sin パルス

光の半径と不透明度を同じパルスで動かし、放射グラデーションの端で0に落とします。ノードごとに位相 i×0.65が違うため、全体がそろって点滅せず立ち上がり続けます。

    for (var i = 0; i < N; i++) {
      var pulse = 0.5 + 0.5 * Math.sin(t * 2 + i * 0.65);
      var glowR = NR + 10 + pulse * 8;
      var grad = ctx.createRadialGradient(px[i], py[i], 0, px[i], py[i], glowR);
      grad.addColorStop(0, 'rgba(' + C_GLOW.join(',') + ',' + (0.5 + 0.3 * pulse).toFixed(3) + ')');
      grad.addColorStop(1, 'rgba(' + C_GLOW.join(',') + ',0)');
      ctx.fillStyle = grad;
      ctx.beginPath(); ctx.arc(px[i], py[i], glowR, 0, Math.PI * 2); ctx.fill();

      ctx.fillStyle = 'rgb(' + C_NODE.join(',') + ')';
      ctx.beginPath(); ctx.arc(px[i], py[i], NR, 0, Math.PI * 2); ctx.fill();

      ctx.fillStyle = 'rgb(' + C_TEXT.join(',') + ')';
      ctx.fillText(LABELS[i], px[i], py[i] - NR - 6);
    }

なぜ数個の力だけで関係図が整うのか — 反発力とばね

力の種類はどの盤でも2つだけです。すべての組に働く距離の2乗に反比例する反発と、エッジだけに働く目標長へのばね。反発は近い組ほど強く、ばねは目標距離から離れるほど強い — この2つの釣り合いの点が、読みやすい図の配置そのものになります。ばねが短くしたい組は隣に置き、反発が重なりを禁じるためラベルが隠れず、結果として「つながったものは近く、独立なものは離れる」配置が物理的に必然として現れます。03が示すように、エッジが1本もなくても反発の強さの差だけで集団は分かれます。つまり力の設計とは、どの組を近くに置きたいかを強さで書くことです。

この2力を毎フレーム足し算し速度に積分するという形は、計算で座標を作るという点で311以降の計算軸の流れを汲みます。群れの規則だけで隊形が決まるボイドの9盤や、濃度の勾配だけで道が伸びる粘菌の9盤が個体の規則から形を作ったのと同じく、ここでは関係の強さというデータだけが形を決めます。spreadsheet の表から同じエンジンで関係図を起こすとき、変えるべきは edges の配列の中身だけで、力の部分は1文字も変わりません。

配布する zip はフォースダイレクテッドグラフ9盤を番号フォルダーごとに分け、各フォルダーに index.html と style.css だけを置いた形です。開くためのパスワードは6bpjfdyjで、この記事で引いた step()・rotateAll・autoCam の各関数もフォルダー内のソースにコメントごと収まっているため、手元で1行ずつ突き合わせて確かめられます。

引力の値ひとつでアクセシビリティ対応まで一緒に変わる3つの変形

  • 引力の強さ: 01 attraction k=0.02 → 0.08。引く力が強くなるほど集団は速くまとまりますが、振動の幅も大きくなります。
  • ノード数: 01 ノード20個 → 80個。個数が増えると安定までのフレームが伸び、反発の計算量は2乗で大きくなります。
  • リンクの目標距離: 07 LINK_BASE 60 → 160。目標距離を伸ばすとグラフ全体が疎に広がり、ラベルの重なりが減ります。

reduced-motion では、@media (prefers-reduced-motion: reduce) の利用者にはループを回さず、step() を200〜260回先に走らせて収束した配置に固定し、キャンバスに .is-still クラスを付けます。残すのは散らばった初期配置ではなく収束済みの結果です。実験盤には「フォース配置ネットワーク図の実験版」などの aria-label と tabindex を与え、実際にポインターで操作する02・08に加えて01にも role="application" が付いており、残りの六つの盤(03〜07・09)は role="img" で区別してあります。画面の下にはノード数・技法・rAF の計測メタを1行で常に表示し、キャンバスの中身を文章でも読めるようにしています。設定画面を持たない軽量なネットワークグラフ ツールとして、この9つはそのままコピペで使えます。

CSSトランジションに置き換えると壊れる落とし穴

座標をCSSのトランジションや@keyframesで動かすと反発とばねが消え、決められたアニメーションに置き換わります。この回の前提は力を計算して座標そのものを更新することで、プロパティのトゥイーンはその対極です。

スパニングツリーなしにランダムエッジだけで作ると、グラフが複数の断片に分かれる確率が高くなります。a=1..N−1 の各ノードが過去の1点へ必ず結ぶことで連結を保証してから、循環を足します。

JITTER(温度)を外すと、力が収束した時点で速度も0に収束し画面が完全に止まります。毎フレームの小さなランダム力が、静止した配置の上でも画面を生かし続けます。

rotateAll を step() の前に呼ぶと、そのフレームの力計算が回転前の座標を基準に歪みます。また1ノードごとに違う角度で回すと剛体でなくなり、ばねの長さが保存されません。常に同じ中心・同じ角度で、step() の後に呼びます。

04の衝突を力だけで解こうとすると、REPELをどれだけ大きくしても瞬間的に重なるフレームが残ります。積分後の位置補正パスが別にあってこそ重なりが完全に消えます。05・06で接線方向の分離力を外すと、同じレベルの兄弟が全部同じ角度に集まります。08で力の計算を画面座標で行うと、ズームのたびに物理のスケールが変わります — 計算は常にワールド座標で、描画のときだけ変換します。

この画面はどこで使う — 組織図とおすすめ関係網

9盤の中で最初に貼る場所として向いているのは、09 表紙の関係図のようなヒーローセクションです。見出しの裏でサービス機能同士が力で結ばれる様子がそのまま第一印象になり、02 ドラッグ再配置を並べれば訪問者が自分でノードを引いて全体が再びバランスを取る様子まで体験できます。

組織図やチームページには05 階層ツリーのほうが向いています。ルートからレベルごとに放射する配置なので上下関係が位置だけで読み取れ、03 クラスタ色分けを添えれば部署ごとの塊が説明文なしでも一目でわかります。

フォース配置グラフを試す前によくある質問

ネットワークグラフ javascriptで自作するとき、まず何を書けばいいですか?

01の step() をそのまま写すのが最短です。全ペアの反発 REPEL/d² とエッジのばね (距離−REST)×ATTRACT を足し、DAMP で減衰して座標を動かす22行が本体で、残りの行はグラフをInitializationする配列の準備です。D3-force も WebGL も要らず、getContext('2d') のキャンバス1枚で動きます。

D3-force などのライブラリとの違いや、ノードが増えたときの計算量はどう変わりますか?

自作では力の1つ1つが自分のコードとして読める点が違います。03の反発の非対称、04の位置補正、07の重みつきばねのような「項を1つ足す」改造が、ライブラリの内部を学ばなくても自分の式に1行足すだけで済みます。この回の9つはどれもその積み重ねで、全ソースを合わせてもライブラリ1個分の理解コストに届きません。計算量では反発が全ペア走査の O(n²) なので、ノードを2倍にすると反発の計算量は4倍になります。08は140ノードで d²>40000 の遠い組を打ち切る工夫を入れています。1,000ノードを超えるくらいから空間分割や Barnes-Hut 式の近似が要りますが、100ノード前後までのグラフなら毎フレーム全走査でも十分に追いつきます。

収束した後も画面が動き続けるのはなぜですか?

2つの仕掛けの合わせ技です。毎フレームの小さなランダム力 JITTER(温度)が速度を完全には0にさせず、力の計算結果には触れず画面全体だけを回す rotateAll が収束済みの配置を剛体として回し続けます。06と08は回転の代わりにリング自身の角速度とカメラの見渡しが同じ役割を担います。

もっと読む

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

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