何を作ったかというと
Obsidian にはノート同士のつながりを点と線で見せるグラフビューがあります。このブログにも似たものを付けたくなりました。
目標はこう置きました。
- ローカルグラフ: 全ドキュメントではなく、今読んでいる記事を中心に 1〜3 ホップ以内の関連ドキュメントだけを見せる
- 自由な操作: ノードをドラッグして、拡大・移動して、クリックしたらその記事へ移動する
- このブログのトーンに合うデザイン: よくある「青い点のグラフ」ではなく、インクパネル + モノラベルに溶かした「観測盤」の雰囲気で
以下は作った過程を段階ごとに、概念の説明を添えてまとめたものです。各段階は 背景知識 → どんな状況だったか → やったこと → 学んだ点 の順なので、必要なところだけ拾って読めます。
1. グラフに描くデータを作る (BFS)
背景知識(概念)
- グラフ(graph): 点(ノード)と、それをつなぐ線(エッジまたはリンク)でできたデータ構造です。ここではノードがドキュメント一つ、リンクが二つのドキュメントの関係です。
- 関係(relation): このブログのドキュメントは本文の
[[別のドキュメント]]という wikilink や frontmatter のrelatedフィールドで互いを指しています。この接続をあらかじめ索引化したものがRelationIndexです。 - BFS(幅優先探索): 開始点から近いものを一層ずつ広げながら探す方法です。「現在のドキュメントから 1 ホップ、2 ホップ、3 ホップ離れたドキュメント」を求めるのにぴったりです。この「何ホップ離れているか」を depth(深さ) と呼びます。
- 直列化(serialization): サーバーで作ったデータをブラウザへ渡すには、関数やクラスではない純粋な JSON である必要があります。その形に変えることを直列化といいます。
どんな状況だったか
ドキュメント間の関係データはすでに RelationIndex に全部ありました。問題は、グラフビューが必要としているのが「全関係」ではなく**「現在のドキュメントを中心にした小さな部分グラフ」**だったことです。
ドキュメント A を読んでいるなら、A と直接つながるドキュメント(depth 1)、それらとつながるドキュメント(depth 2)……という具合に、最大 3 ホップまでを取り出す必要がありました。
やったこと
src/lib/content/graph.ts に getLocalGraph(index, root, maxDepth) を作りました。やっていることは BFS そのものです。
- 開始ドキュメントを depth 0 でキューに入れる
- 一層ずつ広げながら、各ドキュメントの隣接のうちまだ見ていないものをノードとして追加する。このとき depth も一緒に記録する
maxDepth(既定は 3)に達したら止める
隣接を取り出すのは RelationIndex に新しく足した getNeighbors(doc) が担当します。「自分が指しているドキュメント」と「自分を指しているドキュメント(バックリンク)」の両方を隣接とみなします。向きが逆なだけの同じリンクは一度だけ描くよう重複を除き、非公開・下書きのドキュメントはグラフから外しました。
結果はこういう純粋な JSON で出てきます。
{
nodes: [{ id, title, type, href, depth }],
links: [{ source, target, kind }],
}
ノードごとに depth を載せて送るのがポイントです。 サーバーが depth 3 まで一度に計算して渡し、「深さ 1〜3 のスライダー」はブラウザ側でこの値を使ってフィルタするだけです。スライダーを動かしてもサーバーに聞き直さないので、すぐ反応します。
このロジックは目に見えないうえに間違えやすいので、ユニットテストを 6 本付けました。depth 制限が正しいか、重複リンクがまとまるか、非公開ドキュメントが外れるか、といったところです。
学んだ点
- クライアント側の操作(スライダーのフィルタ)がサーバーデータの部分集合なら、サーバーが最大範囲を一度に渡してクライアントはフィルタだけにしましょう。操作のたびに再要求するよりずっと滑らかです。
- 画面に見えないデータ変換ほどテストを残しましょう。グラフが変に描かれたとき、原因がデータなのか描画なのかをすぐ切り分けられます。
2. グラフを画面に描く (force シミュレーション)
背景知識(概念)
- force-directed layout(力ベース配置): ノード同士は反発し、リンクでつながったノードは引き合うという「仮想の物理法則」を繰り返し適用して自然な配置を見つける方式です。Obsidian のグラフが生きているように見える原理ですね。
- canvas: ブラウザで絵をピクセル単位に直接描く HTML 要素です。ノードが数十個、毎フレーム動くグラフには canvas が向いています。
- SSR(サーバーサイドレンダリング): Next.js はページをサーバーであらかじめ HTML にします。ところが canvas や
windowはブラウザにしかないので、サーバーで実行するとエラーになります。 - 動的インポート(dynamic import): 特定のコンポーネントをサーバーレンダリングから外し、ブラウザでだけ読み込む手法です。Next.js では
next/dynamicにssr: falseを渡します。
どんな状況だったか
force シミュレーションを自分で実装するのは割に合いません。すでによくできた react-force-graph-2d があるので、それを使うことにしました。ただし上の SSR 問題を避ける必要がありました。
やったこと
document-graph-view.tsx というクライアントコンポーネントを作り、その中で next/dynamic(..., { ssr: false }) を使ってグラフライブラリを読み込みました。こうするとサーバーはこのコンポーネントに一切触れず、ブラウザでだけ canvas を描きます。重いライブラリが詳細ページでしか読み込まれないので、他のページの性能にも影響しません。
ただ、罠が一つありました。シミュレーションが終わったあとグラフを画面サイズに合わせる zoomToFit を使うには、ライブラリインスタンスへの ref(参照) が必要なのですが、next/dynamic はこの ref をそのまま渡してくれないんです。
そこで force-graph-canvas.tsx という薄いラッパーをもう一枚置きました。その中で ref を直接持ち、onEngineStop(シミュレーションが止まる瞬間)のコールバックから zoomToFit をちょうど一度だけ呼びます。
学んだ点
- canvas や
windowに依存するライブラリは、Next.js ではnext/dynamic(..., { ssr: false })で包みましょう。 - そういうライブラリの imperative API(直接命令する必要がある機能、たとえば
zoomToFit)が必要なら、ref を持つ薄いクライアントラッパーをもう一枚かぶせるのがきれいです。
3. depth を「軌道」にする (カスタム force)
背景知識(概念)
- d3-force:
react-force-graphが内部で使う力の計算エンジンです。リンクの引き合い(link)やノードの反発(charge)などの力を組み合わせます。ここに自作の力を追加できます。 - alpha: シミュレーションの「エネルギー」値です。最初は大きく、だんだん冷めて(0 に近づいて)配置が安定します。カスタムの力はこの alpha に比例して強さを調節します。
- 固定ノード(pinned node): 力を無視して特定の座標に留まるノードです。
fx、fyを与えるとその場に固定されます。
どんな状況だったか
既定の配置はきれいなのですが、depth の情報がまったく見えませんでした。 1 ホップ離れたドキュメントも 3 ホップ離れたドキュメントも、ただ適当な場所に置かれるんです。「現在のドキュメントからどれくらい遠いのか」を目で読めるようにしたくなりました。
やったこと
考え方は単純です。depth を中心からの距離にするんです。depth 1 は半径 88px の軌道、depth 2 は 176px の軌道……という具合に。
radialByDepth というカスタムの力を作りました。毎ティック、各ノードの「今の半径」と「その depth が居るべき目標半径」の差を取り、その差を縮める向きへ速度を少し足します。現在のドキュメントは fx: 0, fy: 0 で原点に固定して、すべての軌道がこの記事を中心に回るようにしました。
この軌道の力がうまく効くように、既定の力も調整しました。リンクの引き合いとノードの反発を弱めて、自分の軌道の力が配置を主導するようにしたんです。結果としてノードが depth ごとの同心円の近くに落ち着きます。
学んだ点
- 配置そのものが情報を伝えられます。 ここでは「中心からの距離 = 関連の遠さ・近さ」を軌道にエンコードしました。
- ライブラリの既定の力とカスタムの力がぶつかると、望んだ絵になりません。自分の力を活かすには既定の力の強さを下げるチューニングが要ります。
4. よくあるグラフではなく「観測盤」に (デザイン)
背景知識(概念)
- デザイントークン(design token): 色・余白・書体といった値を
--signal、--borderのように名前を付けた CSS 変数で管理することです。テーマを変えてもこの変数を差し替えれば全体が付いてきます。 - canvas は CSS 変数を読めません: HTML 要素は
var(--signal)を使えますが、canvas に描く絵は JavaScript から実際の色文字列を直接渡す必要があります。 - AI スロップ(AI slop): グラデーションやグロー、絵文字をあちこちに付けて「AI が適当に作ったように」見えるデザインです。これを避けるのが目標でした。
どんな状況だったか
既定の状態は黒い背景に青い点がいくつか、で少し寂しかったんです。このブログの視覚言語(1px のインクボーダー、モノ大文字のラベル、紙の質感)に合わせて「天体観測装置の画面」のような雰囲気にしたくなりました。
やったこと
- パネル: すでにあった
.section-ink(インク背景)と.paper-grain(紙のノイズ)ユーティリティを再利用しました。新しいスタイルを発明せず、既存のトーンに載せています。 - 軌道の描画:
onRenderFramePre(毎フレーム、ノードより先に描くコールバック)で depth ごとの点線円と十字線を canvas に直接描きました。3 段階目で作った軌道が目に見えるようになります。 - 現在のドキュメントのマーカー: 最初は照準器のような十字ティックを付けたのですが、少し過剰でした。点 + シンプルなリングに整理しています。アクセント色(オレンジ)はここだけに使います。
- 色: パネルが常にインク色なので、グラフのパレットはテーマと無関係に固定値にしました。ドキュメントの種類(til/knowledge/blog/portfolio)ごとに色を分けています。
- HUD・凡例: 左上に
LOCAL GRAPH、右上に23 NODES · D2のようなモノ大文字の計器ラベルを載せました。凡例は実際にグラフに存在する種類だけを表示します。
学んだ点
- 大胆さは一箇所にだけ使いましょう。 「軌道」というシグネチャ一つに力を入れて、残り(色・ラベル)は静かにしておくと、派手でなくても意図がはっきり伝わります。
- デザインシステムがあるなら、新しいコンポーネントでも既存のトークンとユーティリティから探しましょう。 再利用がトーンの一貫性を守ってくれます。
5. 背景に星を瞬かせる (CSS アニメーション)
背景知識(概念)
- コンポジタに優しいアニメーション: ブラウザがレイアウトを再計算せずに GPU で安く処理できるプロパティは
transformとopacityの二つだけです。この二つだけをアニメーションさせれば低スペックでも引っかかりません。 - hydration(ハイドレーション): サーバーが作った HTML に React がイベントを付けて動くようにする過程です。このときサーバーが作った画面とブラウザが作った画面が違うと警告やエラーになります。なので、ランダム値のように毎回変わるものはサーバーレンダリングに入れてはいけません。
どんな状況だったか
観測盤パネルの背景が少し寂しかったんです。「星のように瞬かせて、blur をランダムに入れて遠近感を出そう」というアイデアが出ました。
やったこと
星を 42 個作りましたが、JavaScript で描かず CSS で処理しました。各星は小さな <span> で、位置・大きさ・blur・アニメーション周期・開始遅延を星ごとにランダムでインラインスタイルに埋め込みます。瞬き自体は CSS の @keyframes で opacity だけを 0.1↔0.8 の間で動かします。blur が星ごとに違うので、くっきり見える星とぼやけて見える星ができて奥行きが生まれます。
hydration の問題はこう避けました。星の座標を useMemo でマウント時にちょうど一度だけ作り、画面サイズの計測が終わったあと(ブラウザでのみ)レンダリングします。サーバーは星をまったく描かないので、二つの画面が食い違いようがありません。
そして prefers-reduced-motion(利用者が「動きを減らす」を有効にしている場合)はアニメーションを止め、ほのかな固定の明るさにしています。
学んだ点
- 繰り返す穏やかなモーションは、JavaScript より CSS の
@keyframesのほうが安くて安定します。ただしopacity・transformだけにしましょう。 - ランダム値はサーバーレンダリングに入れないこと。 ブラウザでだけ作らないと hydration が壊れます。
- モーションには必ず
prefers-reduced-motionの対応を入れましょう。
6. 全画面で大きく見る (Fullscreen API)
背景知識(概念)
- Fullscreen API: 特定の要素をブラウザの全画面に広げる標準機能です。
element.requestFullscreen()で有効にし、document.exitFullscreen()で解除します。モーダルを自作する必要がありません。 - useSyncExternalStore: React の外にある状態(ここでは「今、全画面かどうか」)を React が安全に購読できるようにするフックです。
どんな状況だったか
パネルが小さいので、ノードが多いと窮屈でした。大きく見る機能が必要でした。
やったこと
モーダルを新しく作らず、ブラウザのネイティブ Fullscreen API をそのまま使いました。ボタンを押すとグラフのコンテナに requestFullscreen() を呼びます。全画面状態は fullscreenchange イベントを useSyncExternalStore で購読して追い、それに合わせてコンテナの高さを 100vh に切り替えます。サイズが変わるとラッパーが zoomToFit を呼び直して新しいサイズに合わせます。星の背景も軌道も操作も、そのまま付いてきます。
学んだ点
- ブラウザがすでにやってくれることを自作しないこと。 ネイティブの Fullscreen API 一つで、モーダルのコードもオーバーレイもスクロールロックも書かずに済みました。
まとめ
小さなウィジェット一つでしたが、学びは密でした。
データはサーバーが最大範囲を計算し、クライアントはフィルタだけするように分けました。重い canvas ライブラリは動的インポート + ref ラッパーで包みました。配置はカスタムの力で depth を軌道にエンコードし、デザインはシグネチャ一つにだけ力を入れる原則でよくあるグラフを避けました。星は CSS の opacity アニメーションで安く瞬かせ、全画面はネイティブ API でただ同然に手に入れました。
貫く原則は一つです。
すでにあるもの(関係の索引、デザイントークン、ブラウザ API)をまず探して使い、無いものだけを最小限で作る。 それがコードを減らし、結果も一貫させてくれます。