はじめに
この記事は自分が作ったプロダクトをClaude(Sonnet5, Opus5)との壁打ちを通して理解を深めることを主目的とした記事なので、背景を知らない方からするとこの記事の内容の把握が難しいかもしれません。以下が作品のリンクです。
リンク
サービス URL:https://2939976d.trie-bonsai.pages.dev/
記事:https://qiita.com/feynman_1729/items/57b31d0778694da77009
私が作った3D盆栽アプリ「Trie Bonsai」を半ばバイブコーディングで作成したものなので、作品の完成度と自分の作品への理解度(コードスニペットに対する造詣)に乖離があると考えた結果、Claudeとの壁打ちを通してこのギャップを埋める算段です。質問の中にはあまりにも基礎的すぎて拍子抜けするものもあると思いますが...これも基礎習得を怠った結果の自分自身の恥だと思って受け入れます。
全体の処理フロー
アクティビティ図
1. 文字列入力から3D描画までの処理フロー(呼び出しチェーン)
a. 全体図
UIOverlay.tsx (入力UI)
↓ handleGenerate()
store.ts (generateBonsai action)
↓ buildTreeFromInput()
trieConverter.ts (木構築)
↓ tree.toGraph()
Trie.ts / PatriciaTrie.ts / SuffixTree.ts (BFS→座標計算)
↓ graphToNodes()
trieConverter.ts (座標の再計算・最終レイアウト決定)
↓ setBonsaiData()
store.ts (Zustandストア更新)
↓ 購読コンポーネントが再レンダリング
SceneContent.tsx (BFS→出現順序決定→アニメーション→3D描画)
↓ React Three Fiberのレンダーループ
Canvas.tsx (<Canvas> = WebGLコンテキスト)
b. まとめ図
| 段階 | ファイル | 役割 |
|---|---|---|
| 入力 | UIOverlay.tsx | テキスト入力・トリガー |
| 木構築 | trieConverter.ts → Trie.ts等 | 文字列 → 木構造 |
| グラフ化(未使用の座標) | Trie.toGraph() | 木 → ノード/エッジ(捨てられる座標) |
| 最終座標決定 | trieConverter.graphToNodes() | ランダム性・根の埋没を含む最終レイアウト |
| 状態保存 | store.ts | Zustandストアに bonsaiData セット |
| 出現順序 | SceneContent.getRevealOrder() | 3回目のBFSでアニメーション順序決定 |
| アニメーション | SceneContent.useFrame | フレームごとの進捗計算 |
| 描画 | SceneContent.tsx JSX | Three.jsオブジェクト生成 |
| WebGL出力 | Canvas.tsx | 実際のレンダーループ |
c. Claudeとの壁打ち
UIOverlay.tsx
↓ handleGenerate()
store.ts (generateBonsai action)
↓ buildTreeFromInput()
trieConverter.ts (木構築)
↓ tree.toGraph()
Trie.ts / PatriciaTrie.ts / SuffixTree.ts (BFS→座標計算)
↓ graphToNodes()
trieConverter.ts (座標の再計算・最終レイアウト決定)
上記と同様
↓ setBonsaiData()
store.ts (Zustandストア更新)
上記と同様
↓ 購読コンポーネントが再レンダリング
SceneContent.tsx (BFS→出現順序決定→アニメーション→3D描画)
↓ React Three Fiberのレンダーループ
Canvas.tsx ( = WebGLコンテキスト)
上記と同様
2. その他の処理フロー
第1章で追ったのは「文字列入力 → 3D描画」という縦一本のメインフローだった。しかし実際のアプリには、そこから枝分かれした、あるいは完全に独立した処理フローがいくつも存在する。ここではそれらを、Zustandを経由するか / しないか という軸で整理していく。
a. UIレイアウト系トグル (UIOverlay.tsx)
UIOverlay.tsx は入力UIの入り口であると同時に、ストアを経由しないローカル完結の処理を3つ抱えている。
入力欄の一時保持
フロー
-
<input>のonChange→setCurrentInput()→currentInput(useState) →valueに反映
関連ファイル
-
UIOverlay.tsx:13(state宣言) /24-26(ハンドラ) /208-215(input要素)
ポイント
キーストロークごとに発火するが、更新されるのはコンポーネントローカルの state だけである。この時点ではストアも木構造も一切触っていない。Enterキーか「生成」ボタンで handleGenerate()(32-39)が呼ばれて初めて、第1章のメインフローへ合流する。
オーバーレイ表示/非表示
フロー
- 「全体像」ボタン or 生成完了 →
handleToggleOverlay()→isOverlayHidden反転
→ Tailwindクラス切替(translate/opacity)でフェード
関連ファイル
-
UIOverlay.tsx:18(state) /28-30(トグル関数) /194-199(トップバー) /247-252(右下ボタン群)
ポイント
表示状態を JavaScript で計算してから CSS に渡すのではなく、真偽値をそのまま className の三項演算子に流し込み、アニメーション自体は transition-all duration-500 に任せている。JS側は「クラス名を差し替えるだけ」で、動きの実体はブラウザのCSSトランジションが持つ。
画像ダウンロード
フロー
- 保存ボタン →
document.querySelector('canvas').toDataURL()
→trimCanvas()(ピクセル走査で背景色トリミング) →downloadPreviewImage更新
→ モーダル表示(useEffect+rAFでフェードイン)
→ 確定ボタン →<a download>でPNGファイル出力(外部I/O)
関連ファイル
-
UIOverlay.tsx:58-139(trimCanvas) /141-149(キャプチャ) /151-188(モーダルと保存) -
Canvas.tsx:14—gl={{ preserveDrawingBuffer: true }}
ポイント
Zustandを経由せず DOM/Canvas API を直接叩く点がメインフローと最も異なる。注意すべきは Canvas.tsx:14 への暗黙の依存で、WebGLは既定で描画後にバッファを破棄するため、preserveDrawingBuffer: true が無ければ toDataURL() は空画像を返す。この1行が消えるとダウンロード機能と投稿機能が同時に壊れる。
また trimCanvas() は左上隅のピクセルを背景色とみなし、全ピクセルを走査して差分が閾値10を超える範囲の外接矩形を求めている。素朴な総当たりだが、実行はボタン押下時の1回だけなので実用上の問題にならない。
以下はClaudeとの壁打ちである。
UIOverlay.tsx (保存ボタン)
↓ handleOpenDownloadModal() → captureCanvasScreenshot()
Canvas.tsx (document.querySelector('canvas') で取得される DOM)
↓ preserveDrawingBuffer: true により描画バッファが読み出せる
UIOverlay.tsx (trimCanvas() = 全ピクセル走査による背景トリミング)
上記と同様
↓ trimmedCanvas.toDataURL('image/png')
UIOverlay.tsx (downloadPreviewImage = ローカル useState)
上記と同様
↓ useEffect + requestAnimationFrame でフェードイン
UIOverlay.tsx (ダウンロード確認モーダル)
上記と同様
↓ handleConfirmDownload()
ブラウザ (<a download> クリックで PNG 保存 = 外部I/O)
b. 設定パネル (SideMenu.tsx) — Zustand config操作
3つの設定はどれも setConfig() を呼ぶだけに見えるが、再構築のコストが3段階に分かれている。
フロー
- 木の種類
select→setTreeType()+ (inputValueがあれば)generateBonsai()再実行
→ 木構造から作り直し、出現アニメーションも最初から再生 - グラデーション
select→setConfig({nodeGradientPreset})
→SceneContentのgetColorFromStops()が次フレームで新色適用(木の再構築なし) - 背景ボタン(雪/夜明/単色) →
setConfig({backgroundType})
→SceneContent内で背景コンポーネントを条件付きマウント切替
関連ファイル
-
SideMenu.tsx:97-120(木の種類) /136-162(グラデーション) /188-222(背景) /234-243(リセット) -
store.ts:5-21(BonsaiConfig型) /51(setTreeType) /78-81(setConfig) -
SceneContent.tsx:12-57(グラデーション定義と補間) /126-264(背景コンポーネント) /341-343(条件付きマウント) -
gradients.ts:11-41/HelpModal.tsx(解説パネル)
ポイント
グラデーション変更で木が作り直されないのは、bonsaiData と config がストア上で別スライスに分かれているためである。色は描画時に node.y から都度計算されるので、ノードの座標データには一切触れずに見た目だけが変わる。
なお色定義は2箇所に存在する。gradients.ts の GRADIENT_COLORS は SideMenu の解説リンクの文字色専用で、実際に3Dノードへ適用されるのは SceneContent.tsx:12-37 の NODE_GRADIENT_PRESETS の方である。プリセットを追加するときは両方を直す必要がある。
以下はClaudeとの壁打ちである。
SideMenu.tsx (木の種類 / グラデーション / 背景)
↓ setTreeType() + generateBonsai() … 木構造から再構築
↓ setConfig({ nodeGradientPreset }) … 色のみ差し替え
↓ setConfig({ backgroundType }) … 背景コンポーネントのみ切替
store.ts (treeType / config の更新)
↓ 木の種類変更時のみ buildTreeFromInput() へ
trieConverter.ts → Trie.ts / PatriciaTrie.ts / SuffixTree.ts
↓ setBonsaiData() / config購読による再レンダリング
SceneContent.tsx (getColorFromStops() / 背景の条件付きマウント)
↓ React Three Fiberのレンダーループ
Canvas.tsx ( = WebGLコンテキスト)
上記と同様
c. SceneContent.tsx — カメラ・アニメーション
同じ SceneContent.tsx の中に、Reactの再レンダリングを起こさない処理と毎フレーム起こす処理が同居している。
カメラ操作はReact stateを経由しない
フロー
- マウスドラッグ/ホイール →
OrbitControls内部でThree.jsカメラを直接変更
→ 次フレームでWebGL再描画(Reactの再レンダリングなし)
関連ファイル
-
SceneContent.tsx:345-346(PerspectiveCamera/OrbitControls)
ポイント
OrbitControls はカメラオブジェクトのプロパティを直接ミューテートする。React から見れば何も起きていないので再レンダリングは発生せず、それでも画面は毎フレーム更新される。「Reactの外にある状態」がこのアプリに存在することを示す典型例である。
ノード出現アニメーションはuseFrameによる継続的な状態遷移
フロー
- r3f
useFrame(毎フレーム) → 経過時間計算 →animationTime更新
→ 各ノード/エッジの scale・表示可否を再計算
関連ファイル
-
SceneContent.tsx:59-120(getRevealOrder()= 出現順を決めるBFS) -
SceneContent.tsx:289-300(nodeStartTimeMap: index × 0.12秒 /animationEndTime) -
SceneContent.tsx:302-306(bonsaiData変化で開始時刻をリセット) -
SceneContent.tsx:318-336(useFrame本体)
ポイント
カメラと違い、こちらは意図的に React state (animationTime) を毎フレーム更新している。ただし Math.abs(previous - clamped) < 0.001 で差分が小さければ更新をスキップし、アニメーション終了後は animationEndTime でクランプされるため、再レンダリングは自然に止まる。
補足として、store.ts:39-40,87-89 の resetCameraToDefault は SceneContent.tsx:308-316 で登録され UIOverlay.tsx:54-56 で購読されているが、呼び出し口がどこにも実装されていない。現状は動作に影響しない未使用コードである。
d. 投稿フロー (PostModal.tsx / creating/page.tsx) — 外部API通信
フロー
- 投稿ボタン →
PostModalのuseEffect
→ 背景を一時的にsolidへ(setConfig) →canvas.toDataURL()でプレビュー取得
→ 元の背景に復元 →previewImage表示
→ 送信 →submitBonsai()(Cloudflare Workers APIへfetch POST)
→window.location.hrefで/gallery/へフルページ遷移
関連ファイル
-
PostModal.tsx:25-71(キャプチャ) /73-77(送信) -
creating/page.tsx:32-76(handleSubmit) /115-120(モーダル配置) -
lib/api/bonsai.ts:7-34(getWorkerApiUrl()) /78-115(submitBonsai()) -
workers/api/src/index.ts:20→routes/bonsai.ts:244(POST) /db/schema.ts/middleware/cors.ts -
next.config.ts:5-6(output: "export"/trailingSlash)
ポイント
見どころは「背景を一時的に単色へ差し替えてから撮影し、撮り終えたら元に戻す」処理である。雪や夜明けの背景が入ったまま保存されるのを避けるためだが、Zustandの更新は非同期に描画へ反映されるので、setTimeout(100ms) と requestAnimationFrame を挟んで反映を待ってから toDataURL() を呼んでいる。
遷移に router.push() ではなく window.location.href を使っているのは、output: "export" による静的エクスポート構成のためで、その旨がコード内コメントに明記されている。
以下はClaudeとの壁打ちである。
creating/page.tsx (「投稿」ボタン → setIsPostModalOpen(true))
↓ props isOpen
PostModal.tsx (useEffect → captureScreenshot())
↓ setConfig({ backgroundType: 'solid' })(雪/夜明の場合のみ)
store.ts (config 更新 → 100ms 待機 + rAF で反映待ち)
↓ 単色背景の状態で再描画
Canvas.tsx (preserveDrawingBuffer: true により toDataURL() が成立)
↓ setPreviewImage() → 背景を復元 → 名前入力 → handleSubmit()
creating/page.tsx (bonsaiData / config / treeType / inputText を集約)
上記と同様
↓ submitBonsai()
lib/api/bonsai.ts (ホスト名から送信先を判定 → fetch POST)
↓ HTTP POST /api/bonsai
workers/api/src/routes/bonsai.ts (画像をR2へ、メタデータをD1へ保存)
↓ レスポンス受信 → window.location.href = '/gallery/'
gallery/page.tsx (ギャラリーページを新規ロード)
e. gallery/page.tsx — 外部取得とローカル検索
フロー
- ページマウント →
fetchBonsaiList()(Worker API GET) →bonsais(state)更新 → グリッド描画 - 検索欄
onChange→setSearchQuery()→useMemoでfilteredBonsaisを再計算(Zustand非経由)
関連ファイル
-
gallery/page.tsx:21-54(取得) /56-62(検索フィルタ) /90-96(検索欄) /163-204(詳細モーダル) -
lib/api/bonsai.ts:117-168(fetchBonsaiList()と imageUrl の正規化) -
BonsaiCard.tsx/Header.tsx/Footer.tsx -
workers/api/src/routes/bonsai.ts:133(GET 一覧) /199(GET /object = R2画像プロキシ)
ポイント
このページは3D描画に関わらないため、Zustandを一切使わずローカル state のみで完結している。検索も API を再度叩くのではなく、取得済み配列に対する useMemo のフィルタで処理している。
もう一つの見どころが fetchBonsaiList() 内の画像URL正規化で、旧形式のパスやDNS未設定のR2カスタムドメインを、すべて Worker のプロキシ経由(/api/bonsai/object?key=...)へ書き換えている。過去のデータ形式を壊さずに配信経路だけを差し替えるための互換レイヤーである。
なお、作成ページ下部の InfinitySlider.tsx:15-62 は API を叩かず public/gallery/*.png のハードコード配列を表示しているだけで、このギャラリーとはデータ源が別である。
f. マウント時の自動初期化
フロー
- ページマウント(
page.tsx/creating/page.tsx) →useEffect
→setTreeType("trie")→setInputValue(DEFAULT_TRIE_INPUT)→generateBonsai()自動実行
→ ユーザー操作なしに初期盆栽を描画
関連ファイル
-
page.tsx:16-20/creating/page.tsx:26-30 -
lib/constants/defaultTrieInput.ts(DEFAULT_TRIE_INPUT) -
store.ts:50(既定treeType) /52-66(generateBonsai)
ポイント
第1章のメインフローは「ユーザーの入力」から始まるが、実際に最初に走るのはこの自動初期化であり、ページを開いた瞬間に同じチェーンが一度完走している。つまりユーザーが目にする最初の盆栽は、DEFAULT_TRIE_INPUT を入力した結果に他ならない。
ここで注意したいのは、store.ts:50 のストア既定値が "patricia" であるにもかかわらず、両ページが起動時に "trie" で上書きしている点である。既定値を変更しても表示は変わらないので、初期表示を変えたい場合は各ページの useEffect を直す必要がある。なお、この初期化 useEffect は2つのページで完全に同一のコードが重複している。

