AIマインドマップ 設計メモ

Hub

Mapifyを無料で1回使ったら、その日はもう使えなくなった。
それなら何度でも使えるAIマインドマップの簡易版を自分で作ろうと思ったのが始まりです。
休日にPHPを書いて、AIに枝を作らせて、足りないところは手で直す。
このページは、完成したものの紹介というより、どこで詰まって何を直したかの記録です。

種別 個人開発 / Webアプリ
実装 PHP 8 + JavaScript
更新 2026-10-18
Hub AIマインドマップの画面
実装画面。中心テーマから3階層、色分けしたノードとコネクタで構造を表示する。
01

はじめに

普段は製造現場で働いていて、開発は休日と平日の夜に少しずつ進めています。 設計書を先にがっちり作るより、動く画面を出して触りながら直す方が自分には合っている。 Hubもそうやって育ったので、ところどころは雑です。

このページは、同じように小さなWebアプリを作っている人の参考になればと思って書きました。 きれいな完成像より、詰まった場所と直し方を残しています。

最初に決めたこと

  • 説明を読まなくても、線と色で親子関係が分かること
  • 生成したあとも、手で編集・折りたたみ・深掘りできること
  • APIが詰まっても、画面ごと落ちないこと
  • APIキーと入力内容を外に出さないこと

最初からやらないと決めたこと

  • 複数人でのリアルタイム共同編集
  • サーバー側へのマップ保存とアカウント管理
  • AI出力の内容保証
  • 巨大なマップ向けの表示仮想化
3階層 初回生成の深さ
10〜12 初回生成のノード数
900秒 同じテーマのキャッシュ
1本 同時に走らせる生成
公開していない理由。 Hub本体はAPIの無料枠と運用コストの都合で、パスワードをかけて限定公開にしています。 このページには、APIキー・パスワード・サーバーの設定値は出していません。
02

作った理由

きっかけはMapifyです。無料で使ってみたら、1回でその日の利用が終わってしまった。 「もう一回、別のテーマでも試したい」と思ったときに使えないのが惜しくて、 それなら自分で作った方が早いと思いました。

その後、本格的なマインドマップツールとしてXmindなどの存在も知りましたが、 Hubは「自分が頭の中を整理したいときに、気兼ねなく何度でも使える簡易版」という位置づけで、 個人開発のおもちゃとしてそのまま育てています。

パスワードを付けたのは、公開したあとに勝手に使われると困るからです。 APIの無料枠は自分の用途のために使いたいし、知らないところで消費されると、 作り直す気力ごと削られます。

このページを公開したのは、設計の判断を残しておきたかったから。 あと、作ったものを見てもらう入口がひとつ欲しかった。 それくらいの理由です。

03

できること

画面は1枚だけです。サーバーはAIを呼んでJSONを返す役割に絞って、 マップの状態はブラウザの localStorage に保存しています。 データベースは使っていません。

Hubの機能仕様
項目 仕様
入力 テーマ文字列(最大50文字)とモード(generate / expand)
初回生成 3階層・10〜12ノード程度。主要サブテーマ3個 → 要素1〜2個 → 補足1個
深掘り 選んだノードの子として、1階層・3〜4ノードを追加
ノード操作 その場で編集、折りたたみ / 展開、AIによる深掘り
表示操作 全体表示、リセット、ズーム12%〜200%、Ctrl / ⌘ + ホイール
保存 localStorage の hub_canvas_state に保存
認証 パスワードログイン。セッションは12時間で失効
AI Geminiを優先。OpenAI互換APIも予備として登録できる
状態確認 ?action=ai-status(要ログイン)で候補ごとの利用状況を返す

やり取りするJSON

AIと画面の間で行き来するのは、text と children だけです。 IDや開閉状態はブラウザ側で付けるので、サーバーはツリーの形だけを返します。

{
  "items": [
    {
      "text": "サブテーマ",
      "children": [
        {
          "text": "要素",
          "children": [
            { "text": "補足" }
          ]
        }
      ]
    }
  ]
}

リクエストとレスポンス

リクエスト

POST /(Hubの生成エンドポイント)
Content-Type: application/json

{
  "text": "新しいカフェの開業",
  "mode": "generate"
}

レスポンス

HTTP/1.1 200 OK
Content-Type: application/json

{
  "items": [
    { "text": "コンセプト", "children": [] }
  ]
}

エラーは {"error": "..."} で返します。 入力不正は400、未ログインは401、生成元が違うときは403、本文が大きすぎるときは413、 混雑・上限は429、候補が全部だめなら502です。

04

処理の流れ

生成が始まってからJSONが返るまでの流れです。 外部APIに届く前に落とせるものは、できるだけ手前で落とします。 無料枠は有限なので、明らかに無駄なリクエストで消費したくないからです。

  1. 1
    ブラウザ

    テーマとモードをJSONでPOSTする。描画はそのあと全部ブラウザ側。

  2. 2
    ログイン・CSRF・レート制限

    未ログインのPOSTはここで401。Originのホストとポートも照合する。同じIPから10回/分を超えたら429。

  3. 3
    入力の形をそろえる

    本文4KB、JSONの型、テーマ50文字、モードの種類をチェック。変な値はAIに渡す前に落とす。

  4. 4
    キャッシュを見る

    プロンプト版・モード・テーマのSHA-256で900秒キャッシュを確認。同じテーマの連打ではAPIを叩かない。

  5. 5
    同時実行を1本に絞る

    ファイルロックで待たせる。同時に2本走ると無料枠を一気に食うため。待ち時間は最大15秒。

  6. 6
    候補を順に試す

    使えるプロバイダとモデルを優先度とLRUで並べ、最大5回まで試す。ダメなら次へ。

  7. 7
    出力を検査する

    コードフェンスを外してJSONを取り出し、深さ4・100ノード・50文字の上限をかける。壊れていたら別候補へ。

  8. 8
    描画する

    サブツリーの高さから座標を決めて、SVGの線とDOMのノードを出す。ここはサーバーではなくブラウザの仕事。

05

仕組み

ここからは実装の話です。擬似コードは実際のコードを短くしたもので、 そのまま動くものではありません。

5-1. ツリーの並べ方

最初の版は、子ノードをサブツリーの上端に置いていました。 そのせいで深い階層を作ると親の位置が上にずれ、ノード同士が重なって、 画面の外にはみ出すようになりました。

直したのは、親をサブツリーの中央に置くこと。 子の高さを合計して親の帯の高さを決め、その真ん中に親を置けば、 階層が深くなっても親子が重なりません。

function calculateSubtreeHeights(node):
    if node has no visible children:
        node.height = NODE_HEIGHT
        return node.height

    total = sum(calculateSubtreeHeights(child) + GAP_Y for child in children)
    total -= GAP_Y
    node.height = max(NODE_HEIGHT, total)
    return node.height

function assignCoordinates(node, x, bandTop):
    node.x = x
    node.y = bandTop + (node.height - NODE_HEIGHT) / 2

    currentY = bandTop
    for child in visible children:
        assignCoordinates(child, x + NODE_WIDTH + GAP_X, currentY)
        currentY += child.height + GAP_Y
NODE_WIDTH240px
NODE_HEIGHT52px
GAP_X56px
GAP_Y18px

キャンバスも最初は固定8000pxでしたが、ノード数に合わせて自動で広げるように変えました。 表示中のノードの外接矩形に余白を足し、全体を中央へ寄せます。 全体表示では min(画面幅/内容幅, 画面高/内容高, 1) をズーム倍率にして、 12%〜200%の範囲に収めています。

直したあと。 86ノードの密集したツリーを描いて、重なりが0件になることを確認しました。 確認用のHTMLを作って、実際にブラウザで描画して測っています。

5-2. AIの候補選び

Geminiの無料枠がいつ詰まるか分からないので、プロバイダとモデルを複数持てるようにしました。 設定は「プロバイダ × モデル」の候補に展開して、次の順で選びます。

  1. enabled が真で、APIキーが空でない候補だけ残す
  2. OpenAI互換は https(または localhost)の base_url だけ許可
  3. クールダウン中・無効化中・分/日の上限に達した候補は外す
  4. 設定順の優先度、同じなら最後に使った時刻が古い順(LRU)で並べる
  5. 先頭から最大5回、合計45秒の予算内で試す

50個のキーを先に集めるつもりはありません。 Geminiの無料枠はキー単位ではなくプロジェクト単位で共有されることがあるらしいので、 まず429の中身を見るべきだと思っています。 キーを増やすのは、原因がキー単位だと分かってからで遅くない。

5-3. 失敗したとき

エラーの種類ごとに待ち時間と無効化を決めて、次の候補へ進めます。 1つのキーが原因で全体が止まるのを避けるためです。 429はまだ本番で起きていませんが、起きたときにあたふたしないように先に作っておきました。

エラー別のフォールバック動作
状況 状態 次の動作
429 / レート制限 Retry-After または retryDelay を尊重。既定60秒 クールダウンして次候補
401 / 403 キー不正・権限不足とみなして1時間無効化 次候補
5xx / ネットワーク 30秒クールダウン 次候補
JSON不正・形式違反 30秒クールダウン 次候補
全候補が使用不可 最も早い復帰時刻を計算 429 + Retry-After
全候補が失敗 エラーの詳細はサーバーログだけに残す 502(一般的なメッセージ)

キーごとの分/日カウント、クールダウン、最後のエラーはファイルに保存しています。 ログインした状態で ?action=ai-status を開くと見られます。 状態のJSONにAPIキーそのものは出していません。

5-4. キャッシュと同時実行

同じテーマ・同じモードの再生成は、外部APIを叩かずにキャッシュから返します。 キャッシュキーは SHA-256(プロンプト版 | モード | 正規化テーマ)。 プロンプトを変えたときは版番号を上げるので、古い結果とは混ざりません。

  • TTLは900秒。期限切れは読み取り時に消す
  • 書き込みは一時ファイル + rename で置き換える
  • ロックを取ったあとにもう一度キャッシュを見て、同時実行の重複生成を防ぐ
  • ロックは flock。200ms間隔で最大15秒待つ
  • 7日を超えたキャッシュとログは、低確率のメンテナンスで削除
  • 状態ディレクトリは 0700 で作る

5-5. AI出力の検査

モデルの出力はそのまま信頼しません。 コードフェンスや前後の説明が混ざったらJSONの部分だけ取り出し、 形が違えば別の候補に切り替えます。 同じテーマでも毎回同じ粒度で返ってくるわけではないので、上限だけはサーバー側で決めています。

4最大の深さ
100最大ノード数
501テキストの最大文字数
06

セキュリティ

最初はAPIキーをHub本体に直書きしていました。 500で落ちたときにソースを開いて、これはまずいと気づいたのがきっかけです。 そこからキーを非公開の設定ファイルに移しました。 キーは、漏れた可能性があると思ったらすぐ作り直す前提です。

パスワードは後から付けました。作った当初は自分しか使わないつもりだったけど、 公開したらそうとも限らない。勝手に使われてAPI枠が減るのは嫌なので、 ログインと試行回数の制限を足しました。

あとから、URLにフォルダ名を直接入れられても中身が見えないようにしました。 フォルダの中身を一覧表示できないようにして、設定ファイルとAI用のライブラリは Webから直接開けない設定にしています。

セキュリティ対策一覧
どこ 何をした
秘密情報 APIキーとパスワードハッシュを非公開の設定ファイルに分離し、Webから直接アクセスできないようにする。環境変数があればそちらを優先。キーはURLではなく x-goog-api-key ヘッダーで送る
ログイン bcryptで照合、セッションIDを再生成、12時間で失効、ログイン試行は5回/5分、ログアウト時もCSRFを確認
Cookie HttpOnly、SameSite=Lax、HTTPSのときは Secure、strict mode
CSRF ログインとログアウトにワンタイムトークンを要求し、hash_equals で照合
入力 本文4KB、テーマ50文字、JSONの型、モードの種類、Originのホスト・ポート一致を確認
ブラウザ nonce方式のCSP、nosniff、X-Frame-Options: DENY、Referrer / Permissions Policy、HTTPSのときはHSTS
通信 cURLの証明書検証を有効にして、同梱のCAバンドルを使う。接続10秒・要求20秒で切る
ログ APIキー・パスワード・プロンプト本文・テーマの平文は記録しない。エラーの詳細はサーバーログだけ
表示 AI出力は textContent とinputの値として扱い、HTMLとして解釈しない
いまも気をつけていること。 設定ファイルは公開リポジトリに置かない。 Webサーバー側でも直接アクセスを拒否する。 APIキーは作り直せる単位で発行して、使うAPIを限定する。 当たり前のことですが、最初の自分はやっていませんでした。
07

うまくいかなかったこと

うまくいったことだけ並べても仕方ないので、詰まったところを残します。

500で落ちた

トップページがHTTP 500で真っ白になりました。 原因はPHPの構文エラーで、$apiKey の代入が二重に書かれていた箇所と、 || が \vert{}\vert{} に化けていた箇所でした。 エラーログを見るまで「サーバー側の問題だろう」と思っていて、自分のコードを疑っていませんでした。

直すついでに、切れていたJavaScriptの後半も復元しました。 動いているように見えても、ファイルの末尾が切れていることがあるんだな、というのがその日の学びです。

ノードが重なった

階層を増やすと、ノードが見えなくなる場所と重なる場所が出ました。 最初は「画面が狭いから仕方ない」と思っていたけど、原因は配置の計算でした。 子ノードをサブツリーの上端に置いていたので、深い階層ほど親が上にずれていた。 中央揃えに直したら、86ノードでも重ならなくなりました。

セキュリティは後回しにしていた

最初は動かすことで精一杯で、APIキーの直書きも、TLS証明書の検証を切っていたことも、 後から気づきました。機能を作ったあとにまとめて直すと、確認する範囲が広くて大変です。 次に作るときは、キーの置き場所だけは最初に決めようと思っています。

429はまだ起きていない

レート制限は「そのうち起きるだろう」と思って対策しましたが、実際にはまだ起きていません。 起きていないのにキーを50個集めるのは、たぶん順番が逆です。 まずは利用回数を記録して、429が出たときにどの無料枠が減っているかを見る。 それからキーを増やすか、有料枠を考える。いまはそう考えています。

まだ直していないこと

  • マップはブラウザごとの保存で、端末間の同期はない
  • 同時実行を1本に絞っているので、複数人で使うと待つ
  • ノードが多いときの表示仮想化は未実装
  • ファイルベースの状態管理なので、サーバーを分けると成り立たない
  • AI出力の偏りや内容の正しさは保証できない

試したこと

  • 429 → 次の候補へ移り、クールダウンが記録される
  • 401 / 403 → そのキーを1時間無効にして次へ
  • 5xx・タイムアウト・不正JSON → 次へ進み、全滅時は502
  • 同じテーマの再生成でキャッシュが返り、APIを呼ばない
  • 同時実行が1本に制限され、待ちすぎると429
  • 深さ4・100ノード・50文字の上限がかかる
  • 未ログインの生成と状態確認が401になり、状態のJSONにキーが出ない
  • 実APIで初回11ノード / 3階層、深掘り4ノード / 1階層
  • 86ノードの密集ツリーで重なり0件
08

開発ログ

分かる範囲で、やったことを並べておきます。

  1. セキュリティをまとめて直す

    最初は、PHPに直書きしていたAPIキーを非公開の設定ファイルへ分離、TLS検証の有効化、CSP、レート制限、入力上限を追加。

  2. 見た目と階層を調整

    初回は3階層・10〜12ノード、深掘りは1階層・3〜4ノードに変更。色分けとズームも追加。

  3. 重なりを直す

    サブツリー中央揃えに変更。固定キャンバスをやめて、ノード数に応じて広げるようにした。

  4. ログインを追加

    ログイン画面、セッション、CSRF、ログイン試行の制限。初期パスワードを発行して終了。

  5. AI呼び出しを分離

    AI呼び出し用の別ファイルに候補選択、キャッシュ、同時実行制限、フォールバックを実装。

  6. この設計メモを追加

    仕様と失敗談を公開用のページにまとめた。スクリーンショットもここで撮り直した。

次にやること

まずは利用回数と429の内容を記録すること。 どのモデルがどれくらい使われたか見えるようにして、必要ならキーを増やす。 余裕が出たら、状態管理をSQLiteに移すか、大きいマップの描画を軽くするかを考えます。 全部やるかは分かりません。まずは429が起きたときに困らないところからです。

09

使っているもの

サーバー

PHP 8 strict_types cURL Session password_hash flock

ブラウザ側

JavaScript (ES6) DOM SVG CSS localStorage

外部サービス

Google Gemini API OpenAI互換API

構成

フレームワークなし DBなし ファイルキャッシュ 単一サーバー前提

ファイルの役割

Hubのファイル構成
役割 置き方
画面・認証・API 公開領域のHub本体。Webアプリとして実行できるのはここだけ
AI呼び出し Hub本体から読み込む別ファイル。URLからの直接アクセスは拒否
APIキー・パスワードハッシュ 非公開の設定ファイル。Webから直接開けない場所に置く
HTTPS検証用CA サーバー上の証明書ファイル。直接ダウンロードは拒否

画面と認証は1つのファイルにまとめて、AI呼び出しだけ別ファイルに分けました。 プロバイダを増やすときは設定配列に足すだけで、画面側は触らなくて済みます。 最初からこうしていたわけではなく、キーが増えそうになってから分けた形です。

おわりに

おわりに

大きな設計書を先に作らず、動くものを触りながら決める進め方が多いです。 今回も、エラーを直すところから始まって、気づいたらセキュリティとキャッシュが増えていました。 個人開発の試行錯誤の記録として、今後の参考にしていければと思います。