1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Trie Bonsai 備忘録

1
Last updated at Posted at 2026-08-15

はじめに

この記事は自分が作ったプロダクトをClaude(Sonnet5, Opus5)との壁打ちを通して理解を深めることを主目的とした記事なので、背景を知らない方からするとこの記事の内容の把握が難しいかもしれません。以下が作品のリンクです。

リンク
サービス URL:https://2939976d.trie-bonsai.pages.dev/
記事:https://qiita.com/feynman_1729/items/57b31d0778694da77009

私が作った3D盆栽アプリ「Trie Bonsai」を半ばバイブコーディングで作成したものなので、作品の完成度と自分の作品への理解度(コードスニペットに対する造詣)に乖離があると考えた結果、Claudeとの壁打ちを通してこのギャップを埋める算段です。質問の中にはあまりにも基礎的すぎて拍子抜けするものもあると思いますが...これも基礎習得を怠った結果の自分自身の恥だと思って受け入れます。

全体の処理フロー

image.png

アクティビティ図

image.png

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>onChangesetCurrentInput()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:14gl={{ 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段階に分かれている

フロー

  • 木の種類 selectsetTreeType() + (inputValueがあれば)generateBonsai() 再実行
    → 木構造から作り直し、出現アニメーションも最初から再生
  • グラデーション selectsetConfig({nodeGradientPreset})
    SceneContentgetColorFromStops() が次フレームで新色適用(木の再構築なし)
  • 背景ボタン(雪/夜明/単色) → 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(解説パネル)

ポイント

グラデーション変更で木が作り直されないのは、bonsaiDataconfig がストア上で別スライスに分かれているためである。色は描画時に node.y から都度計算されるので、ノードの座標データには一切触れずに見た目だけが変わる。

なお色定義は2箇所に存在する。gradients.tsGRADIENT_COLORS は SideMenu の解説リンクの文字色専用で、実際に3Dノードへ適用されるのは SceneContent.tsx:12-37NODE_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-89resetCameraToDefaultSceneContent.tsx:308-316 で登録され UIOverlay.tsx:54-56 で購読されているが、呼び出し口がどこにも実装されていない。現状は動作に影響しない未使用コードである。


d. 投稿フロー (PostModal.tsx / creating/page.tsx) — 外部API通信

フロー

  • 投稿ボタン → PostModaluseEffect
    → 背景を一時的に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:20routes/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 (useEffectcaptureScreenshot())

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)更新 → グリッド描画
  • 検索欄 onChangesetSearchQuery()useMemofilteredBonsais を再計算(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つのページで完全に同一のコードが重複している。

1
0
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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?