何を作ったか
Obsidianには、ノート同士のつながりを点と線で見せるグラフビューがある。このブログにも似たものを付けたかった。目標はこう立てた。
- ローカルグラフ:全ドキュメントではなく、今見ているドキュメントを中心に、距離1〜3ホップ以内の関連ドキュメントだけを見せる。
- 自由な操作:ノードをドラッグし、ズーム・パン(拡大・移動)し、クリックするとそのドキュメントへ移動する。
- プロジェクトのトーンに合うデザイン:ありがちな「青い点のグラフ」ではなく、このブログの視覚言語(インクパネル + モノラベル)に溶け込んだ「観測盤」の雰囲気。
以下はそれを作った過程を、段階ごとに、概念の説明を添えてまとめた記事だ。各段階は 背景知識 → どんな状況だったか → 中心となる作業 → 学び の順で自己完結的に読めるように書いた。
1. グラフに描くデータを作る(BFS)
背景知識(概念)
- グラフ:点(ノード)とそれをつなぐ線(エッジ / リンク)からなるデータ構造。ここではノードが1つのドキュメント、リンクが2つのドキュメント間の関係だ。
- 関係(relation):このブログのドキュメントは、本文の
[[別のドキュメント]]というwikilinkやfrontmatterのrelatedフィールドで互いを指す。これらのつながりをあらかじめ索引化したものがプロジェクトのRelationIndexだ。 - BFS(幅優先探索):始点から近いものから一層ずつ広がりながら探索する方法。「現在のドキュメントから1、2、3ホップ離れたドキュメント」を求めるのにぴったりだ。この「何ホップ離れているか」を**depth(深さ)**と呼ぶ。
- シリアライズ:サーバーで作ったデータをクライアント(ブラウザ)へ渡すには、関数やクラスではなく純粋なJSON形式でなければならない。渡せる形に変えることをシリアライズという。
どんな状況だったか
ドキュメント間の関係データはすでに RelationIndex に全部入っていた。問題は、グラフビューが必要とするのは「全関係」ではなく**「現在のドキュメントを中心とした小さな部分グラフ」**という点だった。例えばAドキュメントを見ているなら、Aと直接つながるドキュメント(depth 1)、それらとつながるドキュメント(depth 2)……というふうに最大3ホップまで取り出す。
中心となる作業
src/lib/content/graph.ts に getLocalGraph(index, root, maxDepth) 関数を新しく作った。やることはBFSそのものだ。
- 始点ドキュメントをdepth 0でキューに入れる。
- 層(depth)を1つずつ増やしながら、各ドキュメントの隣接を取り出し、まだ見ていなければノードとして追加する。このときそのdepthも一緒に記録する。
maxDepth(デフォルト3)に達したら止める。
隣接を取り出すのは RelationIndex に新しく追加した getNeighbors(doc) が担う。「出ていくリンク(自分が指すドキュメント)」と「入ってくるリンク(自分を指すドキュメント、いわゆるバックリンク)」の両方を隣接とみなす。方向が逆の同じリンクは一度だけ描くよう重複を除き、非公開・下書きのドキュメントはグラフから外した。
結果のデータはこのように純粋なJSONで出る。
ノードごとに depth を載せて送るのが肝だ。サーバーはdepth 3まで一度に計算して送り、「1〜3 depthスライダー」はブラウザでこの値を基準にフィルターだけする。スライダーを動かしてもサーバーへ再要求しないので即座に反応する。
このロジックは目に見えないうえ間違えやすいので、getLocalGraph の単体テストを6つ付けた。depth制限が正しいか、重複リンクが統合されるか、非公開ドキュメントが外れるかなどを検証する。
学び
- クライアントで行う操作(スライダーのフィルター)がサーバーデータの部分集合なら、サーバーが最大範囲を一度に渡し、クライアントはフィルターだけするようにしよう。操作のたびに再要求するよりずっと滑らかだ。
- 画面に見えないデータ変換ロジックほどテストを残すべきだ。グラフが変に描かれたとき、原因がデータか描画かをすぐ切り分けられる。
2. グラフを画面に描く(forceシミュレーション)
背景知識(概念)
- force-directed layout(力ベース配置):ノード同士は互いに反発し、リンクでつながったノードは互いに引き合う「仮想の物理法則」を繰り返し適用して自然な配置を見つける方式。Obsidianのグラフが生きて動くように見える原理だ。
- canvas:ブラウザで絵をピクセル単位で直接描くHTML要素。ノードが数十個ずつ毎フレーム動くグラフはcanvasが適する。
- SSR(サーバーサイドレンダリング):Next.jsはページをサーバーであらかじめHTMLにする。ところがcanvasや
windowはブラウザにしかないので、サーバーで実行するとエラーになる。 - 動的インポート:特定のコンポーネントをサーバーレンダリングから外し、ブラウザでだけ読み込む手法。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(fixed x/y)を与えるとその位置に固定される。
どんな状況だったか
デフォルトの配置はきれいだがdepth情報が見えなかった。 1ホップ離れたドキュメントも3ホップ離れたドキュメントも、ただどこかに置かれた。「現在のドキュメントからどれだけ遠いか」を目で読めるようにしたかった。
中心となる作業
アイデアは単純だ。depthを中心からの距離にする。 depth 1は半径88pxの軌道、depth 2は176px……というふうに。
radialByDepth というカスタム力関数を作った。シミュレーションの各ティックごとに各ノードについて「今の位置の半径」と「そのノードのdepthがあるべき目標半径」の差を求め、その差を縮める方向へ速度を少し足す。現在のドキュメントは fx: 0, fy: 0 で原点に固定し、すべての軌道がこのドキュメントを中心に回るようにした。
この軌道の力がよく効くようデフォルトの力も調整した。リンクの引き(link)とノードの反発(charge)を弱め、自分の軌道の力が配置を主導するようにした。結果としてノードがdepthごとの同心円の近くに収まる。
学び
- 配置(layout)自体が情報を伝えられる。ここでは「中心からの距離=関連性の遠近」を軌道でエンコードした。
- ライブラリのデフォルトの力とカスタム力が争うと、望む絵にならない。自分の力を生かすには、デフォルトの力の強さを下げるチューニングが必要だ。
4. ありがちなグラフではなく「観測盤」に(デザイン)
背景知識(概念)
- デザイントークン:色・間隔・フォントなどの値を
--signal、--borderのように名前を付けたCSS変数で管理すること。テーマを変えてもこの変数だけ替えれば全体が追従する。 - canvasはCSS変数を読めない:HTML要素は
var(--signal)を使えるが、canvasに描く絵はJavaScriptで実際の色文字列を直接渡さなければならない。 - AIスロップ(AI slop):グラデーション・グロー・絵文字をどこにでも付けて「AIが雑に作ったように」見えるデザイン。これを避けるのが目標だった。
どんな状況だったか
デフォルトは黒背景に青い点がいくつかで味気なかった。このブログの視覚言語(1pxのインクボーダー、モノ大文字ラベル、紙の質感)に合わせて「天体観測機器の画面」のような雰囲気を出したかった。
中心となる作業
- パネル:プロジェクトにすでにあった
.section-ink(インク背景)+.paper-grain(紙のノイズ)ユーティリティを再利用した。新しいスタイルを発明せず既存のトーンに乗せた。 - 軌道の描画:
onRenderFramePre(毎フレーム、ノードより先に描くコールバック)でdepthごとの点線の円と十字線をcanvasに直接描いた。3で作った軌道が見えるようになる。 - 現在のドキュメントマーカー:最初は照準器(reticle)のように十字のティックを付けたが、過剰だったので点 + シンプルなリングに整理した。強調色(オレンジ)はこれ一つにだけ使う。
- 色:パネルが常にインク色なので、グラフのパレットはテーマと無関係に固定値にした。ドキュメントタイプ(til/knowledge/blog/portfolio)ごとに色を分ける。
- HUD・凡例:左上に
LOCAL GRAPH、右上に23 NODES · D2のようなモノ大文字の計器ラベルを乗せた。凡例は実際にグラフに存在するタイプだけを見せる。
学び
- 大胆さは一か所にだけ使う。「軌道」というシグネチャー一つに力を入れ、残り(色・ラベル)を静かにすると、派手でなくても意図がはっきりする。
- デザインシステムがあれば、新しいコンポーネントも既存のトークン・ユーティリティから探す。再利用がトーンの一貫性を守る。
5. 背景に星を瞬かせる(CSSアニメーション)
背景知識(概念)
- コンポジタに優しいアニメーション:ブラウザがレイアウトを再計算せずGPUで安く処理できる属性は
transformとopacityだけだ。この2つだけをアニメートすれば低スペックでもカクつかない。 - 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)をまず探して使い、ないものだけ最小限で新しく作る。 それがコードも減らし、結果も一貫させる。