結果
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 です。
ハブを選ぶと、そこから出る線が全部出ます。
これを普段から描いていたら、図は線の毛玉です。つながりが 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.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 に抑えていました。
拡大すると、まとまりの名前が消えてページの題が出る
引いているときはまとまりの名前、寄るとページの題が出ます。切り替えはこうなっていました。
// まとまりの名前。寄るほど、名前は消えて題に替わる
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/






