0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Code に 5 行で頼んだら、4,420 ノードが光る WebGL グラフが「止まったら 1 コマも描かない」設計で返ってきた

0
Last updated at Posted at 2026-11-07

4,420 個の点が 63 のまとまりに分かれて光る、暗い背景のグラフ

結果

4,420 ページある文書アーカイブの、ページどうしのつながりを 1 枚にしたグラフです。点がページ、線がページどうしのつながりで、線は 11,769 本あります。

作ったのは Claude Code(モデルは Claude Opus 5.5)です。私が頼んだのはこれだけです。

「ど迫力ですごいよな? 拡大縮小?ドリルダウン?レベル分け?」

「クールな見た目。光る効果とか? このすごい関連具合を見せたい」

「でも、重くならないようにとか対策はいるよな?」

「塊や重なりもどう解決するか」

「見た目はいくつかの案出してほしい」

返ってきた案は、まとまりごとに星雲のように固めて見せる図を主にして、年表と「一番つながりの多い人物から何歩か」の 2 つの見方に切り替えられるもの。描画は WebGL、配置はビルド時に済ませる、拡大の段階で出す文字を変える、検索と一覧は図の外に置く、という内容でした。私の返事は「いいね。すすめて」だけです。

雑な 5 行から、Louvain 法とフラグメントシェーダーと「描き直さない描画ループ」が出てきました。どれもほかのプロジェクトに持ち帰れる形をしていたので、コードごと紹介します。

項目 値
ノード(ページ) 4,420
エッジ(つながり) 11,769
まとまり 63
ページを開いて図が出るまで 314 ミリ秒
何も操作しない 2 秒の描画コマ数 0
データの JSON 圧縮前 847,934 バイト、Brotli で 168,538 バイト

点と線はビルド時に作る

点は文書のページ、人物のページ、2 通以上のメッセージをまとめたスレッドのページです。線は 4 種類あります。

種類 本数
本文のリンク 3,825
関連ページの指定 632
メッセージからそのスレッドへ 3,184
登場人物 4,128

同じ 2 点を複数の種類の線が結ぶときは、上の表で上にある種類を 1 本だけ残します。

本文のリンクを集めるところで、Markdown のリンクを正規表現で拾うだけ、では済ませていませんでした。このサイトは語句から自動でリンクを張る仕組みを持っていて、原稿に書いていないリンクが画面には出ます。Claude Code は、ページを描くときと同じ処理を通してから行き先を集めていました。原稿だけ見ていたら、画面にある線を落とすところでした。

1,331 本の線を持つ 1 点を、まとまり探しから外す

まとまり探しは Louvain 法です。点を隣のまとまりへ移したときの得点を比べ、上がるなら移します。コードの得点を式にするとこうなります。

$$
\Delta(i \to C) = w_{iC} - \frac{\Sigma_C , k_i}{2m}
$$

$w_{iC}$ は点 $i$ からまとまり $C$ への線の重みの合計、$\Sigma_C$ はまとまりの次数の合計、$k_i$ は点の次数、$m$ は全体の重みの合計です。

      let bestGain = (wTo.get(ci) ?? 0) - (tot[ci] * k[i]) / m2;
      for (const [c, w] of wTo) {
        const gain = w - (tot[c] * k[i]) / m2;
        if (gain > bestGain + 1e-12 || (Math.abs(gain - bestGain) <= 1e-12 && c < best)) {
          best = c;
          bestGain = gain;
        }
      }

得点が同じなら、まとまりの番号の小さいほうを選びます。同点のときの選び方が決まっているので、何度ビルドしても同じまとまりができます。

肝はその前にあります。このサイトには、1 ページで 1,331 本の線を持つ人物のページがあります。2 番目は 361 本。こういう点をそのまま Louvain 法に入れると、別々の話題までその点に吸い寄せられて、まとまりが 1 つの塊になります。Claude Code は、つながりが 150 を超える点を、まとまり探しのときだけ外していました。

const HUB_DEGREE = 150;
const EDGE_WEIGHT = [1, 1, 2, 0.4];
const MIN_COMMUNITY = 12;

function detectCommunities(n: number, edges: [number, number, EdgeKind][], degree: number[]) {
  let nbr: Map<number, number>[] = Array.from({ length: n }, () => new Map());
  for (const [a, b, k] of edges) {
    if (degree[a] > HUB_DEGREE || degree[b] > HUB_DEGREE) continue;
    const w = EDGE_WEIGHT[k];
    nbr[a].set(b, (nbr[a].get(b) ?? 0) + w);
    nbr[b].set(a, (nbr[b].get(a) ?? 0) + w);
  }
// …

外すのはまとまり探しの計算だけで、図からは消しません。150 を超える点は 5 つで、まとまりは 63 個できました。12 点に満たないまとまりは、一番強くつながる大きなまとまりへ入れます。線の重みは本文のリンク 1、関連ページ 1、スレッド 2、登場人物 0.4 です。

ハブを選ぶと、そこから出る線が全部出ます。

1 点から何百本もの線が放射状に伸びる画面

これを普段から描いていたら、図は線の毛玉です。つながりが 300 を超える 2 点の線は、その点を選んだときだけ描きます。

力学モデルは 2 段で、ビルド時に回す

4,420 点を一度に押し合わせず、d3-force を 2 段で回していました。1 段目でまとまりを円として並べ、2 段目でまとまりの中に点を置きます。下が 2 段目です。

const LINK_STRENGTH = [0.5, 0.3, 0.6, 0.08];
// …
  forceSimulation(pts)
    .force('link', forceLink(links).distance((l: any) => (l.kind === 2 ? 8 : l.kind === 3 ? 40 : 22)).strength((l: any) => LINK_STRENGTH[l.kind] * (l.same ? 1 : 0.04)))
    .force('charge', forceManyBody().strength(-9).theta(1.1).distanceMax(160))
    .force('collide', forceCollide(3))
    .force('x', forceX((d: any) => (pulled(d.c, d.i) ? cx[d.c] : 0)).strength((d: any) => (pulled(d.c, d.i) ? 0.12 : 0.01)))
    .force('y', forceY((d: any) => (pulled(d.c, d.i) ? cy[d.c] : 0)).strength((d: any) => (pulled(d.c, d.i) ? 0.12 : 0.01)))
    .stop()
    .tick(320);

まとまりをまたぐ線のばねは、同じまとまりの中の 0.04 倍です。つながりを残したまま、別のまとまりどうしが線に引かれて潰れません。ハブはまとまりの中心へ引かず、原点へ向かう弱い力だけを受けます。1 段目は 400 回、2 段目は 320 回で止めます。

計算した座標は ±1000 に収めて整数に丸め、JSON に入れて配ります。

// 座標を ±1000 の箱に収め、整数にする
function normalize(xy: number[]): number[] {
  let m = 1;
  for (const v of xy) m = Math.max(m, Math.abs(v));
  return xy.map((v) => Math.round((v / m) * 1000));
}

ブラウザーは配置を 1 回も計算しません。読み込んだ座標を置くだけです。星雲・年表・距離の 3 つの見方の座標も、全部ビルド時に作ってあります。

横に年代、縦に資料の種類で並べた年表の見方

中心の 1 点から何歩で届くかで同心円に並べた見方

見方を切り替えると、1.4 秒かけて座標を補間します。

星雲から年表、距離、星雲へと見方を切り替え、点が補間されて動く様子

光はフラグメントシェーダーで足す

点と線は WebGL、文字はその上に重ねた 2D キャンバスです。点の光は、芯とにじみの 2 つの指数関数を足しているだけでした。

precision mediump float; varying vec3 vColor; uniform float uGain;
void main() {
  vec2 d = gl_PointCoord * 2.0 - 1.0; float r = dot(d, d);
  if (r > 1.0) discard;
  float core = exp(-r * 22.0); float halo = exp(-r * 4.5) * 0.45;
  vec3 c = (vColor * (halo + core * 0.9) + vec3(core * 0.5)) * uGain;
  gl_FragColor = vec4(c, max(c.r, max(c.g, c.b)));
}

合成は blendFuncSeparate(g.ONE, g.ONE_MINUS_SRC_COLOR, …) のスクリーン合成で、重なるほど明るくなります。線は 2 次ベジェ曲線を 8 本の線分に分けて描きます。

線の濃さと光の強さは、拡大率 z で変えます。

寄るほど線が濃く、点が大きく光り、引くと元に戻る様子

      // 引いて見ているときは線を薄く、寄るほど濃く
// …
      const edgeAlpha = (f >= 0 ? 0.05 : 0.11) * (mode === 'hops' ? 0.55 : 1) * Math.min(2.6, Math.pow(z, 0.45));
// …
      const px = DPR * 4.2 * Math.min(3.2, Math.max(0.75, Math.pow(z, 0.55)));
// …
      g.uniform1f(PL.uGain, (f >= 0 ? 0.28 : 0.62) * Math.min(1.5, Math.max(0.75, Math.pow(z, 0.25))));

f は選んだ点か、マウスを乗せている点です。そういう点があるときは線を薄くし、光も抑えて、そのまわりを浮かせます。画素の倍率 DPR は、通常の画面で最大 2、指で触る画面で最大 1.5 に抑えていました。

寄った視点では、まとまりの中の点が 1 つずつ見える

拡大すると、まとまりの名前が消えてページの題が出る

引いているときはまとまりの名前、寄るとページの題が出ます。切り替えはこうなっていました。

        // まとまりの名前。寄るほど、名前は消えて題に替わる
        const fade = Math.max(0, Math.min(1, (6 - z) / 3));
        // 引いて見ているときは大きいまとまりの名前だけ。寄るほど小さいまとまりの名前も出す
        const maxComms = Math.round(14 * Math.pow(z, 1.4));
// …
      // 寄ったら、つながりの多い点から題を出す
      if (settled && z > 2.4) {
        const cand: number[] = [];
        for (let i = 0; i < n; i++) {
          const [x, y] = toScreen(nodePos[i * 2], nodePos[i * 2 + 1]);
          if (x > 0 && x < W && y > 0 && y < H) cand.push(i);
        }
        cand.sort((a, b) => D.degree[b] - D.degree[a] || a - b);
        let shown = 0;
        const max = coarse ? 25 : 60;
        for (const i of cand) {
          if (shown >= max) break;
          const [x, y] = toScreen(nodePos[i * 2], nodePos[i * 2 + 1]);
          if (label(clip(D.title[i], nodeLabelLen), x, y - 7 - radius[i], 11, 'rgba(205,214,238,0.78)')) shown++;
        }
      }

画面に入っている点を、つながりの多い順に並べて、置ける題から置きます。label() はそれまでに置いた文字の枠を覚えていて、重なるなら描かずに null を返します。だから題は最大 60 個(指で触る画面は 25 個)で、文字が雪崩れてきません。題を出すのはカメラが止まった(settled)ときだけ。動いている間に題を描いても、どうせ読めませんし。

止まっている間は 1 コマも描かない

ここに一番感心しました。requestAnimationFrame を回しっぱなしにしません。描き直しが要るときだけ次のコマを頼みます。

    let dirty = true;
    // 描き直しが要るときだけ次の描画を頼む(止まっている間は何もしない)
    let raf = 0;
    const invalidate = () => {
      dirty = true;
      if (!raf) raf = requestAnimationFrame(frame);
    };
// …
      if (dirty) {
        if (hiDirty) rebuildHighlight();
        drawGL();
        drawText();
        dirty = false;
      }
      if (morph < 1 || fly) raf = requestAnimationFrame(frame);

見方の切り替え(morph)か、目的地へのカメラの移動(fly。650 ミリ秒、縮尺は対数で補間)の途中だけ次のコマを頼み、終われば止まります。操作があれば invalidate() で起こします。眺めているだけのタブは何も計算しません。

測定の条件と、残りの数です。

  • Apple M1 Max、Metal で動く Chromium、1280×800、画素の倍率 2
  • JSON の取得は 60 ミリ秒
  • 見方の切り替え中の 1 コマの計算時間は、中央値 0.8〜1.6 ミリ秒、95 パーセンタイル 1.7〜1.9 ミリ秒、最大 4.7 ミリ秒(6 回測定)
  • 携帯では測っていません

5 行の注文に「止まったら描かない」とは一言も書いていません。「重くならないように」の一言が、ここまで具体的な実装になって返ってきました。

実物はここで触れます。
https://bitcoin-institute.pages.dev/ja/map/

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?