Todoの検索ツール「Visible Grid」


【更新履歴】

・2026/2/11 1.0公開。
・2026/2/12 1.2日本語版を公開。(AI連携など追加)
・2026/2/12 1.3日本語版を公開。(表示モードなど追加)


画像

Visible Grid 取扱説明書

  1. 概要

・Visible Grid は、アイデア、知識、データ構造を
 3次元空間で可視化・管理するためのナレッジグラフツールです。

・ノード(点)とリンク(線)を用いて情報を構造化し、
 物理演算による動的なレイアウトや、
 AIを活用した自動設計機能を提供します。

  1. 画面構成

メニューバー (上部):
・ファイル操作、カメラ設定、AI機能へのアクセス。

左パネル:
・ノード一覧およびグループ一覧の管理。

中央パネル:
・3Dメインビュー。視覚化されたグラフが表示されます。

右パネル:
・選択したノードの詳細編集、タグ設定、全体表示設定。

ビュー・ドック (中央下部):
・4つの表示モードを切り替えるボタン群。

  1. 基本操作

カメラ(視点)の操作

・マウスとキーボードを使って、3D空間を自由に移動できます。

回転:  左クリック + ドラッグ

移動 (パン):  右クリック + ドラッグ

ズーム:  マウスホイール または PageUp / PageDown キー

視点リセット:  Home キー(初期位置に戻ります)

注視:  ノードを選択した状態で、
    メニュー「カメラ」→「注視して旋回」を選ぶと、
    そのノードを中心にカメラが回るようになります。

※ メニューの「カメラ」から、左ドラッグの挙動を
「回転」「移動」「ズーム」に変更することも可能です。

ノードの操作

選択:  ノード(球体またはアイコン)をクリックします。

追加:  メニューバーの「+ ノード」ボタンをクリックします。

削除:  右クリックメニューの「削除」
    または、右パネルの「削除」ボタン。

移動:  ノードは自動レイアウトされますが、
    表示モードによっては物理演算に従って漂います。

  1. ノードの編集(右パネル)

・ノードを選択すると、
 右パネルの「プロパティ」タブで
 詳細を編集できます。

名前:  ノードの名称。
    常に白色で視認性高く表示されます。

グループ:  ノードの所属グループ。
     左パネルでグループ単位の折りたたみが可能です。

色設定:  ノードの発光色を変更できます。

アイコンURL:  画像のURLを入力すると、
        球体の代わりにアイコンが表示されます。

タグ:  カンマ区切りで入力します(例: 企画, 重要)。
    同じタグを持つノード同士は、
    自動的に色付きの線で緩やかに結ばれます。

接続 (手動リンク):  ドロップダウンから別のノードを選び、
          明示的に接続線を引くことができます。

メモ:  Markdown形式で詳細なメモを記述できます。

  1. 表示モードの切り替え

・画面下部のドックから、
 用途に合わせて4つのレイアウトを切り替えられます。

🕸️ 3D Graph (標準)

・物理演算を用いた力学モデルレイアウト。
・ノード同士が反発し合い、リンクで引き合います。
・全体像の把握に最適です。

🌳 Tree View (階層)

・選択中のノードを頂点とし、
 接続関係を階層構造(ツリー状)に並べ替えます。
・順序関係の整理に向いています。

🎯 Radial (放射状)

・選択中のノードを画面中央 (0,0,0) に固定し、
 接続ノードをその周囲に円形に配置します。
・特定の要素を中心とした関係性を見るのに適しています。

📝 List (2Dリスト)

・3D表示をオフにし、
 通常のリストボックス形式でデータを一覧表示します。
・名前やグループの確認、高速な編集作業に特化したモードです。

  1. AI 設計機能 (AI Architect)

・生成AI(ChatGPT, Gemini, Claude等)と連携して、
 グラフを自動生成できます。

・メニューバーの 「✨ AI 設計」 をクリックします。

Step 1:
・「Web開発のロードマップを作って」などの要望を入力し、
 「プロンプト生成」を押します。

Step 2: 
・生成された命令文を「コピー」し、
 ご自身のAIチャットツールに貼り付けて送信します。

Step 3:
・AIが生成したJSONコード([...] の部分)をコピーし、
 ツールの入力欄に貼り付け、「反映」を押します。

  1. 設定・カスタマイズ

・右パネルの「設定 (Global)」タブで、
 見た目をリアルタイムに調整できます。

接続線長:  ノード間の距離を調整します。

アイコンサイズ:  ノードの球体やアイコンの大きさを変更します。

文字サイズ:  ラベル文字の大きさを変更します。

可視範囲 (Depth):  選択ノードから「何ステップ先」までを表示するか
         設定します(探索用)。

  1. ファイルの保存と読み込み

・作成したグラフはJSON形式で保存できます。

新規作成:  現在のキャンバスをクリアし、新しい作業を開始します。

開く:  PC内のJSONファイルを読み込みます。

上書き保存:  編集中のファイルに上書きします(対応ブラウザのみ)。

別名で保存:  新しいファイルとしてダウンロードします。

キャプチャ:  現在の画面を高解像度画像(PNG)として保存します。

  1. ヒント

グループの活用:

・左パネルの「グループ」タブでグループ名をクリックすると、
 そのグループに属するノードを「箱」に
 まとめて折りたたむことができます。
・大規模なグラフの整理に便利です。

コンテキストメニュー:

・ノードを右クリックすると、
 コピー・貼り付け・接続などの
 ショートカットメニューが表示されます。

・ダウンロードされる方はこちら。↓

・ソースコードはこちら。↓

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Visible Grid 1.3 日本語版</title>
    <style>
        /* --- Base & Layout --- */
        :root { --accent: #00e5ff; --bg-dark: #0b0c10; --panel-bg: rgba(20, 24, 30, 0.98); --text-main: #ffffff; --menu-bg: #1a1c23; --menu-hover: #2979ff; }
        body, html { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background-color: var(--bg-dark); color: var(--text-main); font-family: 'Inter', 'Yu Gothic UI', sans-serif; user-select: none; }
        
        /* Menu Bar (Dropdown System) */
        #menubar { height: 40px; background: #15171e; display: flex; align-items: center; padding: 0 10px; border-bottom: 1px solid #333; z-index: 100; position: relative; }
        .brand { font-weight: 800; color: var(--accent); margin-right: 20px; font-size: 1.1em; letter-spacing: 1px; padding: 0 10px; cursor: default; }
        
        .menu-item { position: relative; padding: 0 15px; height: 100%; display: flex; align-items: center; cursor: pointer; font-size: 0.9em; transition: background 0.2s; color: #ccc; }
        .menu-item:hover { background: #2a2d35; color: #fff; }
        .menu-item:hover .dropdown { display: block; }
        
        .dropdown { display: none; position: absolute; top: 100%; left: 0; background: var(--menu-bg); min-width: 180px; box-shadow: 0 4px 12px rgba(0,0,0,0.5); border: 1px solid #333; border-top: none; z-index: 101; }
        .dd-item { padding: 10px 15px; font-size: 0.9em; color: #ddd; cursor: pointer; display: flex; justify-content: space-between; border-bottom: 1px solid rgba(255,255,255,0.05); }
        .dd-item:hover { background: var(--menu-hover); color: #fff; }
        .dd-item.disabled { color: #555; cursor: default; }
        .dd-item.disabled:hover { background: none; }
        .dd-sep { height: 1px; background: #333; margin: 2px 0; }
        .shortcut { font-size: 0.8em; color: #888; margin-left: 10px; }

        .menu-spacer { flex: 1; }
        .status-msg { font-size: 0.85em; color: var(--accent); margin-right: 15px; font-family: monospace; }
        
        .ai-btn { background: linear-gradient(45deg, #7b1fa2, #4a148c); border: 1px solid #ba68c8; color: #fff; padding: 4px 12px; border-radius: 4px; font-size: 0.85em; cursor: pointer; margin-left: 10px; }
        .ai-btn:hover { box-shadow: 0 0 8px rgba(186,104,200, 0.6); }

        /* Main Container */
        #container { display: flex; height: calc(100% - 40px); }

        /* Panels */
        .panel { width: 320px; background: var(--panel-bg); border-right: 1px solid #333; display: flex; flex-direction: column; z-index: 5; backdrop-filter: blur(5px); }
        #right-panel { border-left: 1px solid #333; border-right: none; }
        
        .panel-header { padding: 10px 15px; background: rgba(255,255,255,0.05); font-weight: bold; border-bottom: 1px solid #333; font-size: 0.75em; text-transform: uppercase; letter-spacing: 1.2px; color: var(--accent); display:flex; justify-content:space-between; align-items:center; }
        
        /* Tabs */
        .tab-bar { display: flex; background: #15171e; }
        .tab { flex: 1; text-align: center; padding: 10px; cursor: pointer; font-size: 0.8em; color: #888; border-bottom: 2px solid transparent; transition: 0.2s; }
        .tab:hover { color: #fff; background: #222; }
        .tab.active { color: var(--accent); border-bottom-color: var(--accent); background: #1e222b; color: #fff; }

        /* List Box Style */
        #node-list, #group-list, #tag-list, #main-list-view { flex: 1; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #333 transparent; background: #121418; }
        
        .list-box-item { padding: 8px 12px; cursor: pointer; border-bottom: 1px solid #222; display: flex; align-items: center; gap: 12px; transition: background 0.1s; }
        .list-box-item:hover { background: #1f232a; }
        .list-box-item.active { background: #2a303b; border-left: 3px solid var(--accent); padding-left: 9px; }
        
        .node-icon-visual {
            width: 28px; height: 28px; flex-shrink: 0; border-radius: 50%;
            background-size: cover; background-position: center;
            box-shadow: 0 0 5px rgba(255,255,255,0.2);
            display: flex; align-items: center; justify-content: center; background-color: #000;
        }
        .node-ball {
            width: 100%; height: 100%; border-radius: 50%;
            background: radial-gradient(circle at 30% 30%, rgba(255,255,255,1), rgba(255,255,255,0) 30%), 
                        radial-gradient(circle at 50% 50%, currentColor, #222);
        }
        .node-info { flex: 1; overflow: hidden; }
        .node-name { font-size: 0.95em; font-weight: bold; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
        .node-meta { font-size: 0.75em; color: #aaa; margin-top: 2px; }

        .group-item { background: #1a1c23; margin-bottom: 1px; }
        .group-header { padding: 8px 15px; display: flex; align-items: center; justify-content: space-between; font-weight: bold; font-size: 0.85em; cursor: pointer; color: #ddd; }
        .group-header:hover { background: #252a35; }
        .group-toggle { color: var(--accent); margin-right: 8px; font-family: monospace; }
        
        .tag-row { padding: 8px 15px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid #222; }
        .color-preview { width: 16px; height: 16px; border-radius: 3px; border: 1px solid #555; cursor: pointer; }

        /* Canvas */
        #center-panel { flex: 1; position: relative; background: #000; overflow: hidden; display: flex; flex-direction: column; }
        #canvas-container { width: 100%; flex: 1; outline: none; }
        #main-list-view { display: none; width: 100%; height: 100%; background: #0b0c10; padding: 20px; box-sizing: border-box; }
        #main-list-view .list-box-item { background: #15181e; margin-bottom: 5px; border-radius: 4px; border: 1px solid #222; padding: 15px; }
        
        #view-dock { height: 40px; background: rgba(0,0,0,0.8); border-top: 1px solid #333; display: flex; align-items: center; justify-content: center; gap: 15px; z-index: 10; }
        .view-btn { background: transparent; border: 1px solid #444; color: #888; padding: 4px 12px; border-radius: 15px; cursor: pointer; font-size: 0.8em; display: flex; align-items: center; gap: 5px; }
        .view-btn:hover { color: #fff; background: rgba(255,255,255,0.1); }
        .view-btn.active { color: #000; background: var(--accent); border-color: var(--accent); font-weight: bold; }

        /* Forms */
        .prop-group { padding: 15px; border-bottom: 1px solid #333; }
        .prop-label { display: block; margin-bottom: 5px; font-size: 0.7em; color: #aaa; text-transform: uppercase; letter-spacing: 0.5px; }
        input, textarea, select { width: 100%; background: #1f232a; border: 1px solid #444; color: #fff; padding: 8px; margin-bottom: 10px; box-sizing: border-box; border-radius: 4px; font-family: inherit; font-size: 0.9em; }
        input:focus, textarea:focus, select:focus { border-color: var(--accent); outline: none; }
        textarea { height: 80px; resize: vertical; }
        input[type="range"] { width: 100%; cursor: pointer; }
        
        button.action-btn { width: 100%; padding: 8px; background: #2b3540; border: 1px solid #333; color: #ddd; cursor: pointer; margin-top: 5px; border-radius: 4px; transition: 0.2s; font-size: 0.85em; }
        button.action-btn:hover { background: #3a4755; border-color: #555; }
        button.btn-primary { background: linear-gradient(45deg, #1e3c72, #2a5298); border: none; color: white; }
        button.btn-danger { background: #3a1c1c; border-color: #522; color: #ff8888; }

        .hidden { display: none !important; }
        
        /* Context Menu */
        #context-menu { position: absolute; display: none; background: rgba(30,30,30,0.98); border: 1px solid #444; box-shadow: 0 10px 30px rgba(0,0,0,0.8); z-index: 2000; min-width: 160px; border-radius: 6px; }
        .ctx-item { padding: 10px 15px; cursor: pointer; color: #ddd; font-size: 0.9em; border-bottom: 1px solid rgba(255,255,255,0.05); }
        .ctx-item:hover { background: var(--menu-hover); color: #fff; }

        /* AI Modal */
        #ai-modal { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 9000; display: none; align-items: center; justify-content: center; backdrop-filter: blur(5px); }
        #ai-modal-content { width: 600px; max-width: 90%; background: #1a1c23; border: 1px solid #444; border-radius: 8px; box-shadow: 0 0 30px rgba(0,0,0,0.8); display: flex; flex-direction: column; overflow: hidden; }
        .ai-header { padding: 15px 20px; background: linear-gradient(90deg, #4a148c, #1a1c23); color: #fff; font-weight: bold; display: flex; justify-content: space-between; align-items: center; }
        .ai-body { padding: 20px; max-height: 80vh; overflow-y: auto; }
        .ai-step { margin-bottom: 25px; border-left: 2px solid #555; padding-left: 15px; }
        textarea.code-area { font-family: 'Consolas', monospace; font-size: 0.85em; color: #a5d6a7; background: #0f1115; }
        .close-btn { background: none; border: none; color: #aaa; cursor: pointer; font-size: 1.5em; }

        /* Camera Mode Indicator */
        #cam-mode-indicator { position: absolute; top: 10px; right: 10px; background: rgba(0,0,0,0.5); padding: 5px 10px; border-radius: 4px; color: var(--accent); font-size: 0.8em; pointer-events: none; }
    </style>
    
    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
</head>
<body>

    <div id="menubar">
        <div class="brand">VISIBLE GRID</div>
        
        <div class="menu-item">
            ファイル
            <div class="dropdown">
                <div class="dd-item" onclick="app.newFile()">新規作成 <span class="shortcut">New</span></div>
                <div class="dd-item" onclick="app.openFile()">開く... <span class="shortcut">Open</span></div>
                <div class="dd-sep"></div>
                <div class="dd-item" onclick="app.saveFile()">上書き保存 <span class="shortcut">Save</span></div>
                <div class="dd-item" onclick="app.saveAsFile()">別名で保存... <span class="shortcut">Save As</span></div>
            </div>
        </div>

        <div class="menu-item">
            カメラ
            <div class="dropdown">
                <div class="dd-item" onclick="app.visualizer.setCamMode('rotate')">操作: 回転 (Rotate)</div>
                <div class="dd-item" onclick="app.visualizer.setCamMode('pan')">操作: 移動 (Pan)</div>
                <div class="dd-item" onclick="app.visualizer.setCamMode('zoom')">操作: ズーム (Zoom)</div>
                <div class="dd-sep"></div>
                <div class="dd-item" onclick="app.visualizer.focusSelection()">注視して旋回 (Orbit Focus)</div>
                <div class="dd-item" onclick="app.visualizer.resetCamera()">リセット (Home)</div>
            </div>
        </div>

        <button class="ai-btn" onclick="aiManager.open()">✨ AI 設計</button>

        <div class="menu-spacer"></div>
        <div id="status-bar" class="status-msg">Ready</div>
    </div>

    <div id="ai-modal">
        <div id="ai-modal-content">
            <div class="ai-header">
                <span>AI Architect</span>
                <button class="close-btn" onclick="aiManager.close()">×</button>
            </div>
            <div class="ai-body">
                <div class="ai-step">
                    <div style="font-weight:bold; color:#ccc; margin-bottom:5px;">Step 1: 生成したいグラフの内容</div>
                    <textarea id="ai-input" placeholder="例: Web開発のロードマップを作って。Frontend, Backend, DevOpsのグループに分けて、色も変えて。"></textarea>
                    <button class="action-btn btn-primary" onclick="aiManager.generatePrompt()">プロンプト生成</button>
                </div>
                <div class="ai-step">
                    <div style="font-weight:bold; color:#ccc; margin-bottom:5px;">Step 2: AIへの指示 (コピー & 送信)</div>
                    <textarea id="ai-prompt-output" class="code-area" readonly style="height:100px;"></textarea>
                    <button class="action-btn" onclick="aiManager.copyPrompt()">コピー</button>
                </div>
                <div class="ai-step">
                    <div style="font-weight:bold; color:#ccc; margin-bottom:5px;">Step 3: 結果のJSONを貼り付け</div>
                    <textarea id="ai-json-input" class="code-area" placeholder='[ { "name": "...", ... } ]' style="height:100px; color:#fff;"></textarea>
                    <button class="action-btn btn-primary" onclick="aiManager.apply()">グラフを生成して反映</button>
                </div>
            </div>
        </div>
    </div>

    <div id="container">
        <div id="left-panel" class="panel">
            <div class="tab-bar">
                <div class="tab active" onclick="ui.switchLeftTab('nodes')">ノード</div>
                <div class="tab" onclick="ui.switchLeftTab('groups')">グループ</div>
            </div>
            <div id="tab-nodes" style="display:flex; flex-direction:column; flex:1; overflow:hidden;">
                <div style="padding:10px; border-bottom:1px solid #333;">
                    <input type="text" id="search-input" placeholder="検索..." style="margin-bottom:0;">
                </div>
                <div id="node-list"></div>
            </div>
            <div id="tab-groups" class="hidden" style="flex:1; overflow:hidden; display:flex; flex-direction:column;">
                <div id="group-list"></div>
            </div>
        </div>

        <div id="center-panel">
            <div id="canvas-container"></div>
            <div id="cam-mode-indicator">Mode: Rotate</div>
            <div id="main-list-view"></div>
            <div id="view-dock">
                <button class="view-btn active" onclick="app.setMode('graph')" id="btn-mode-graph">🕸️ 3D Graph</button>
                <button class="view-btn" onclick="app.setMode('tree')" id="btn-mode-tree">🌳 Tree</button>
                <button class="view-btn" onclick="app.setMode('radial')" id="btn-mode-radial">🎯 Radial</button>
                <button class="view-btn" onclick="app.setMode('list')" id="btn-mode-list">📝 List</button>
                <button class="view-btn" onclick="app.captureGraph()">📷 Capture</button>
            </div>
        </div>

        <div id="right-panel" class="panel">
            <div class="tab-bar">
                <div class="tab active" onclick="ui.switchRightTab('prop')">プロパティ</div>
                <div class="tab" onclick="ui.switchRightTab('tags')">タグ</div>
                <div class="tab" onclick="ui.switchRightTab('global')">設定</div>
            </div>

            <div id="tab-prop" style="flex:1; overflow-y:auto;">
                <div id="prop-node" class="hidden">
                    <div class="panel-header">選択中のノード</div>
                    <div class="prop-group">
                        <label class="prop-label">名前</label>
                        <input type="text" id="node-name">
                        <label class="prop-label">グループ</label>
                        <input type="text" id="node-group" list="group-suggestions" placeholder="Group Name">
                        <datalist id="group-suggestions"></datalist>
                        <label class="prop-label">色設定</label>
                        <div style="display:flex; gap:5px;">
                            <input type="color" id="node-color" style="flex:1">
                            <button class="action-btn" style="margin-top:0; width:auto;" onclick="document.getElementById('node-color').value='#00e5ff'; app.updateNodeProp();">Reset</button>
                        </div>
                        <label class="prop-label">アイコンURL</label>
                        <input type="text" id="node-icon" placeholder="https://...">
                        <label class="prop-label">タグ</label>
                        <input type="text" id="node-tags" placeholder="カンマ区切り">
                        <label class="prop-label">接続</label>
                        <select id="link-select" onchange="app.addManualLink(this.value)">
                            <option value="">+ 接続先...</option>
                        </select>
                        <div id="manual-links-list" style="margin-top:5px;"></div>
                        <label class="prop-label" style="margin-top:10px;">メモ</label>
                        <textarea id="node-body"></textarea>
                    </div>
                    <div class="prop-group">
                        <button class="action-btn" onclick="app.setPathTarget()">経路探索ゴールに設定</button>
                        <button class="action-btn btn-danger" onclick="app.deleteNode(app.selectedNodeId)">削除</button>
                    </div>
                </div>
                <div id="prop-empty" style="padding:20px; text-align:center; color:#555;">ノードを選択してください</div>
            </div>

            <div id="tab-tags" class="hidden" style="flex:1; overflow-y:auto;">
                <div class="panel-header">タグ配色設定</div>
                <div id="tag-list"></div>
            </div>

            <div id="tab-global" class="hidden" style="flex:1; overflow-y:auto;">
                <div class="panel-header">表示カスタマイズ</div>
                <div class="prop-group">
                    <label class="prop-label">接続線長: <span id="conf-link-val">12</span></label>
                    <input type="range" id="conf-link" min="5" max="30" value="12" oninput="app.updateConfig('linkLength', this.value)">
                    <label class="prop-label">アイコンサイズ: <span id="conf-size-val">1.5</span></label>
                    <input type="range" id="conf-size" min="0.5" max="4.0" step="0.1" value="1.5" oninput="app.updateConfig('nodeSize', this.value)">
                    <label class="prop-label">文字サイズ: <span id="conf-text-val">1.2</span></label>
                    <input type="range" id="conf-text" min="0.5" max="3.0" step="0.1" value="1.2" oninput="app.updateConfig('textSize', this.value)">
                </div>
                <div class="panel-header">ビュー設定</div>
                <div class="prop-group">
                    <label class="prop-label">可視範囲 (Depth): <span id="range-val" style="color:var(--accent)">2</span></label>
                    <input type="range" id="range-slider" min="1" max="10" value="2">
                </div>
                <div class="prop-group">
                    <button class="action-btn btn-primary" onclick="app.visualizer.resetCamera()">カメラリセット (Home)</button>
                    <button class="action-btn" onclick="app.explode()">レイアウト拡散</button>
                </div>
            </div>
        </div>
    </div>

    <div id="context-menu">
        <div class="ctx-item" id="ctx-connect">ここに接続</div>
        <div class="ctx-item" id="ctx-copy">コピー</div>
        <div class="ctx-item" id="ctx-paste">貼り付け</div>
        <div class="ctx-item" id="ctx-delete" style="color:#ff6b6b;">削除</div>
    </div>
    <input type="file" id="file-input" style="display:none" accept=".json">

    <script>
        /**
         * Logic 7.0 Ultimate
         */
        class App {
            constructor() {
                this.nodes = [];
                this.nextId = 1;
                this.selectedNodeId = null;
                this.targetNodeId = null;
                this.fileHandle = null; // for File System Access API
                
                this.visibleRange = 2;
                this.searchQuery = "";
                this.physicsParams = { repulsion: 800, spring: 0.03, damping: 0.92 };
                
                this.config = { linkLength: 12, nodeSize: 1.5, textSize: 1.2 };

                this.visibleNodeIds = new Set();
                this.collapsedGroups = new Set();
                this.groupCenters = {}; 

                this.pathLinks = new Set();
                this.tagColors = {};

                this.mode = 'graph'; 
                this.layoutTargets = {}; 

                this.ui = new UIManager(this);
                this.visualizer = new Visualizer(this);

                this.visualizer.init();
                // No auto load - start fresh
                this.refresh();
                this.physicsLoop();
            }

            // --- File Operations ---
            newFile() {
                if(this.nodes.length > 0 && !confirm("現在の内容は消去されます。よろしいですか?")) return;
                this.nodes = [];
                this.nextId = 1;
                this.selectedNodeId = null;
                this.tagColors = {};
                this.fileHandle = null;
                this.ui.setStatus("New File Created");
                this.refresh();
            }

            async openFile() {
                // Try File System Access API
                if(window.showOpenFilePicker) {
                    try {
                        const [handle] = await window.showOpenFilePicker({ types: [{ description: 'JSON Files', accept: {'application/json': ['.json']} }] });
                        const file = await handle.getFile();
                        const text = await file.text();
                        this.loadJsonData(text);
                        this.fileHandle = handle;
                        this.ui.setStatus("File Opened: " + file.name);
                    } catch(e) { console.log(e); }
                } else {
                    // Fallback
                    document.getElementById('file-input').click();
                }
            }

            async saveFile() {
                if(this.fileHandle) {
                    try {
                        const writable = await this.fileHandle.createWritable();
                        await writable.write(this.getJsonString());
                        await writable.close();
                        this.ui.setStatus("Saved.");
                    } catch(e) { alert("Save failed: " + e); }
                } else {
                    this.saveAsFile();
                }
            }

            async saveAsFile() {
                if(window.showSaveFilePicker) {
                    try {
                        const handle = await window.showSaveFilePicker({ types: [{ description: 'JSON Files', accept: {'application/json': ['.json']} }] });
                        const writable = await handle.createWritable();
                        await writable.write(this.getJsonString());
                        await writable.close();
                        this.fileHandle = handle;
                        this.ui.setStatus("Saved As...");
                    } catch(e) { console.log(e); }
                } else {
                    const blob = new Blob([this.getJsonString()], {type:"application/json"});
                    const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download="visible_grid_data.json"; a.click();
                }
            }

            getJsonString() {
                return JSON.stringify({ nodes: this.nodes, nextId: this.nextId, tagColors: this.tagColors, config: this.config }, null, 2);
            }

            loadJsonData(jsonStr) {
                try {
                    const data = JSON.parse(jsonStr);
                    this.nodes = data.nodes || [];
                    this.nextId = data.nextId || 1;
                    this.tagColors = data.tagColors || {};
                    if(data.config) {
                        this.config = {...this.config, ...data.config};
                        this.ui.updateConfigUI();
                    }
                    this.nodes.forEach(n => {
                        if(!n.group) n.group = "Default";
                        if(!n.color) n.color = "#00e5ff";
                        if(!n.links) n.links = [];
                    });
                    this.refresh();
                } catch(e) { alert("Invalid JSON"); }
            }

            // --- Logic ---
            updateConfig(key, val) {
                this.config[key] = parseFloat(val);
                this.ui.updateConfigUI(); // Update label text
                this.refresh();
            }

            addNode() {
                const id = this.nextId++;
                this.nodes.push({
                    id: id, name: "Node " + id, group: "Default", tags: [], links: [], body: "",
                    x: (Math.random()-0.5)*10, y: (Math.random()-0.5)*10, z: (Math.random()-0.5)*10,
                    vx:0, vy:0, vz:0, color: "#00e5ff", icon: ""
                });
                this.selectNode(id);
                if(this.mode !== 'graph' && this.mode !== 'list') this.calcLayout();
            }

            deleteNode(id) {
                this.nodes.forEach(n => { n.links = n.links.filter(lid => lid != id); });
                this.nodes = this.nodes.filter(n => n.id !== id);
                if(this.selectedNodeId === id) this.selectedNodeId = null;
                this.refresh();
                if(this.mode !== 'graph' && this.mode !== 'list') this.calcLayout();
            }

            setMode(m) {
                this.mode = m;
                document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active'));
                document.getElementById('btn-mode-'+m).classList.add('active');
                
                const isList = (m === 'list');
                document.getElementById('canvas-container').style.display = isList ? 'none' : 'block';
                document.getElementById('main-list-view').style.display = isList ? 'block' : 'none';
                document.getElementById('cam-mode-indicator').style.display = isList ? 'none' : 'block';

                if(m === 'graph') this.explode();
                else if(!isList) this.calcLayout();
                
                this.refresh();
            }

            calcLayout() {
                let rootId = this.selectedNodeId;
                if(!rootId && this.nodes.length > 0) rootId = this.nodes[0].id;
                this.layoutTargets = {};
                
                if(!rootId) return;

                if(this.mode === 'radial') {
                    // Selected Node at (0,0,0)
                    const rootNode = this.nodes.find(n => n.id === rootId);
                    this.layoutTargets[rootId] = {x:0, y:0, z:0};
                    
                    const neighbors = [];
                    this.nodes.forEach(n => {
                        if(n.id !== rootId && (rootNode.links.includes(n.id) || n.links.includes(rootId))) {
                            neighbors.push(n.id);
                        }
                    });

                    const r1 = 20;
                    neighbors.forEach((nid, i) => {
                        const angle = (Math.PI * 2 / neighbors.length) * i;
                        this.layoutTargets[nid] = { x: Math.cos(angle) * r1, y: Math.sin(angle) * r1, z: 0 };
                    });

                    // Others loose
                     this.nodes.forEach(n => {
                        if(n.id !== rootId && !neighbors.includes(n.id)) {
                             this.layoutTargets[n.id] = {x: (Math.random()-0.5)*100, y: (Math.random()-0.5)*100, z: -50};
                        }
                    });

                } else if(this.mode === 'tree') {
                    const queue = [{id: rootId, level: 0}];
                    const visited = new Set([rootId]);
                    this.layoutTargets[rootId] = {x:0, y:0, z:0};

                    while(queue.length > 0) {
                        const curr = queue.shift();
                        const n = this.nodes.find(x=>x.id===curr.id);
                        const children = [];
                        this.nodes.forEach(other => {
                            if((n.links.includes(other.id) || other.links.includes(n.id)) && !visited.has(other.id)) {
                                children.push(other.id); visited.add(other.id);
                            }
                        });
                        const width = 10; const height = 10;
                        children.forEach((cid, i) => {
                            const offset = (i - (children.length-1)/2) * width;
                            const parentPos = this.layoutTargets[curr.id];
                            this.layoutTargets[cid] = { x: parentPos.x + offset, y: -(curr.level + 1) * height, z: 0 };
                            queue.push({id: cid, level: curr.level + 1});
                        });
                    }
                    this.nodes.forEach(n => { if(!visited.has(n.id)) this.layoutTargets[n.id] = {x:0, y:-100, z:0}; });
                }
            }

            physicsLoop() {
                requestAnimationFrame(() => this.physicsLoop());
                const activeNodes = this.nodes.filter(n => this.visibleNodeIds.has(n.id));
                const dt = 0.05;

                if (this.mode === 'graph') {
                    for(let i=0; i<activeNodes.length; i++) {
                        for(let j=i+1; j<activeNodes.length; j++) {
                            const n1 = activeNodes[i], n2 = activeNodes[j];
                            let dx = n1.x - n2.x, dy = n1.y - n2.y, dz = n1.z - n2.z;
                            let dSq = dx*dx + dy*dy + dz*dz || 0.1;
                            let f = this.physicsParams.repulsion / dSq;
                            let d = Math.sqrt(dSq);
                            n1.vx += (dx/d)*f*dt; n1.vy += (dy/d)*f*dt; n1.vz += (dz/d)*f*dt;
                            n2.vx -= (dx/d)*f*dt; n2.vy -= (dy/d)*f*dt; n2.vz -= (dz/d)*f*dt;
                        }
                    }
                    activeNodes.forEach(n => {
                        if(this.groupCenters[n.group] && this.groupCenters[n.group].count > 1) {
                            const c = this.groupCenters[n.group];
                            n.vx -= (n.x - c.x)*0.02*dt; n.vy -= (n.y - c.y)*0.02*dt; n.vz -= (n.z - c.z)*0.02*dt;
                        }
                    });
                    activeNodes.forEach(n1 => {
                        n1.links.forEach(tid => {
                            const n2 = activeNodes.find(n => n.id === tid);
                            if(n2) {
                                let dx = n2.x - n1.x, dy = n2.y - n1.y, dz = n2.z - n1.z;
                                let d = Math.sqrt(dx*dx + dy*dy + dz*dz) || 0.1;
                                let f = (d - this.config.linkLength) * 0.1; 
                                n1.vx += (dx/d)*f*dt; n1.vy += (dy/d)*f*dt; n1.vz += (dz/d)*f*dt;
                                n2.vx -= (dx/d)*f*dt; n2.vy -= (dy/d)*f*dt; n2.vz -= (dz/d)*f*dt;
                            }
                        });
                    });
                } else if (this.mode !== 'list') {
                    activeNodes.forEach(n => {
                        const t = this.layoutTargets[n.id];
                        if(t) {
                            n.vx += (t.x - n.x) * 0.1; n.vy += (t.y - n.y) * 0.1; n.vz += (t.z - n.z) * 0.1;
                        }
                    });
                }

                activeNodes.forEach(n => {
                    n.vx *= this.physicsParams.damping; n.vy *= this.physicsParams.damping; n.vz *= this.physicsParams.damping;
                    n.x += n.vx; n.y += n.vy; n.z += n.vz;
                });

                if(this.mode !== 'list') this.visualizer.sync();
            }

            captureGraph() {
                const w = prompt("Width (px)", "1920"); const h = prompt("Height (px)", "1080");
                if(w && h) this.visualizer.capture(parseInt(w), parseInt(h));
            }

            updateNodeProp() {
                if(!this.selectedNodeId) return;
                const n = this.nodes.find(x => x.id === this.selectedNodeId);
                if(n) {
                    n.name = document.getElementById('node-name').value;
                    n.group = document.getElementById('node-group').value || "Default";
                    n.color = document.getElementById('node-color').value;
                    n.icon = document.getElementById('node-icon').value;
                    n.body = document.getElementById('node-body').value;
                    const tagVal = document.getElementById('node-tags').value;
                    n.tags = tagVal.split(',').map(t => t.trim()).filter(t => t);
                    n.tags.forEach(t => { if(!this.tagColors[t]) this.tagColors[t] = this.getRandomColor(); });
                    this.refresh();
                }
            }
            addManualLink(tid) {
                if(!this.selectedNodeId || !tid) return;
                const n = this.nodes.find(x => x.id === this.selectedNodeId);
                const t = parseInt(tid);
                if(n && t !== n.id && !n.links.includes(t)) { n.links.push(t); this.refresh(); this.ui.updateSelection(); }
            }
            removeManualLink(tid) {
                const n = this.nodes.find(x => x.id === this.selectedNodeId);
                if(n) { n.links = n.links.filter(id => id !== tid); this.refresh(); this.ui.updateSelection(); }
            }
            selectNode(id) {
                this.selectedNodeId = id;
                this.pathLinks.clear();
                if(this.targetNodeId && id) this.calculatePath(id, this.targetNodeId);
                if(this.mode === 'radial' || this.mode === 'tree') this.calcLayout();
                this.refresh();
                this.ui.updateSelection();
            }
            setPathTarget() { if(this.selectedNodeId) { this.targetNodeId=this.selectedNodeId; this.refresh(); } }
            clearPath() { this.targetNodeId=null; this.pathLinks.clear(); this.refresh(); }
            calculatePath(s,e) { 
                const q=[[s]], v=new Set([s]);
                while(q.length){
                    const p=q.shift(), c=p[p.length-1];
                    if(c===e){ p.forEach((id,i)=>{ if(i<p.length-1){ this.pathLinks.add(id+'-'+p[i+1]); this.pathLinks.add(p[i+1]+'-'+id); }}); return; }
                    const n=this.nodes.find(x=>x.id===c);
                    this.nodes.forEach(o=>{
                        if(!v.has(o.id) && (n.links.includes(o.id)||o.links.includes(n.id)||n.tags.some(t=>o.tags.includes(t)))) { v.add(o.id); q.push([...p,o.id]); }
                    });
                }
            }
            toggleGroup(g) { if(this.collapsedGroups.has(g)) this.collapsedGroups.delete(g); else this.collapsedGroups.add(g); this.refresh(); }
            
            updateVisibility() {
                this.visibleNodeIds.clear(); this.groupCenters = {};
                const sums = {};
                this.nodes.forEach(n => {
                    if(!sums[n.group]) sums[n.group] = {x:0, y:0, z:0, count:0};
                    sums[n.group].x += n.x; sums[n.group].y += n.y; sums[n.group].z += n.z; sums[n.group].count++;
                });
                for(let g in sums) if(sums[g].count>0) this.groupCenters[g] = {x:sums[g].x/sums[g].count, y:sums[g].y/sums[g].count, z:sums[g].z/sums[g].count, count:sums[g].count};

                const sv = this.ui.iSearch.value.toLowerCase();
                this.nodes.forEach(n => {
                    let vis = false;
                    if(sv) {
                        if(n.name.toLowerCase().includes(sv) || n.tags.some(t=>t.toLowerCase().includes(sv))) vis = true;
                    } else if (this.selectedNodeId === null) {
                        vis = true;
                    } else {
                         const cn = this.nodes.find(x=>x.id===this.selectedNodeId);
                         if(cn) {
                             if(n.id===cn.id) vis=true;
                             else if(cn.links.includes(n.id)||n.links.includes(cn.id)||cn.tags.some(t=>n.tags.includes(t))) vis=true;
                             if(this.visibleRange > 1) vis = true; 
                         } else vis = true;
                    }
                    if(vis && !this.collapsedGroups.has(n.group)) this.visibleNodeIds.add(n.id);
                });
            }
            refresh() { 
                this.updateVisibility(); 
                this.ui.updateLists(); 
                if(this.mode !== 'list') this.visualizer.updateScene(); 
                // saveData not called every frame, but on logical changes, which refresh covers.
            }
            explode() { this.physicsParams.repulsion = 3000; setTimeout(()=>this.physicsParams.repulsion=800, 500); }
            getRandomColor() { return '#' + Math.floor(Math.random()*16777215).toString(16).padStart(6,'0'); }
            getTagColor(t) { return this.tagColors[t] || '#888'; }
        }

        class Visualizer {
            constructor(app) {
                this.app = app;
                this.container = document.getElementById('canvas-container');
                this.scene = null; 
                this.meshMap = new Map();
                this.groupMeshMap = new Map();
                this.linkObjects = [];
                this.raycaster = new THREE.Raycaster();
                this.mouse = new THREE.Vector2();
                this.texLoader = new THREE.TextureLoader();
                this.textureCache = {};
            }

            init() {
                this.scene = new THREE.Scene();
                this.scene.fog = new THREE.FogExp2(0x0b0c10, 0.005);
                
                const w = this.container.clientWidth; const h = this.container.clientHeight;
                this.camera = new THREE.PerspectiveCamera(60, w/h, 0.1, 5000);
                this.camera.position.set(0, 30, 60);

                this.renderer = new THREE.WebGLRenderer({antialias: true, alpha: true, preserveDrawingBuffer: true});
                this.renderer.setSize(w, h);
                this.container.appendChild(this.renderer.domElement);
                this.controls = new THREE.OrbitControls(this.camera, this.renderer.domElement);

                // High brightness lighting
                this.scene.add(new THREE.AmbientLight(0xffffff, 1.2)); // Bright Ambient
                const dl = new THREE.DirectionalLight(0xffffff, 0.8); dl.position.set(20,50,20); this.scene.add(dl);
                
                const grid = new THREE.GridHelper(500, 100, 0x333333, 0x111111);
                grid.position.y = -50; grid.material.opacity = 0.3; grid.material.transparent=true;
                this.scene.add(grid);

                this.selectionRing = new THREE.Mesh(new THREE.RingGeometry(2.4, 2.6, 32), new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.DoubleSide, transparent:true }));
                this.scene.add(this.selectionRing);

                window.addEventListener('resize', () => {
                    this.camera.aspect = this.container.clientWidth/this.container.clientHeight;
                    this.camera.updateProjectionMatrix();
                    this.renderer.setSize(this.container.clientWidth, this.container.clientHeight);
                });
                this.renderer.domElement.addEventListener('pointerdown', (e) => this.onClick(e));
                
                document.addEventListener('keydown', (e) => {
                    if(e.code === 'PageUp') this.zoom(1);
                    if(e.code === 'PageDown') this.zoom(-1);
                    if(e.code === 'Home') this.resetCamera();
                });

                this.animate();
            }

            setCamMode(m) {
                const el = document.getElementById('cam-mode-indicator');
                if(m==='rotate') { this.controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.PAN }; el.innerText = "Mode: Rotate"; }
                if(m==='pan') { this.controls.mouseButtons = { LEFT: THREE.MOUSE.PAN, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.ROTATE }; el.innerText = "Mode: Pan"; }
                if(m==='zoom') { this.controls.mouseButtons = { LEFT: THREE.MOUSE.DOLLY, MIDDLE: THREE.MOUSE.ROTATE, RIGHT: THREE.MOUSE.PAN }; el.innerText = "Mode: Zoom"; }
            }
            
            focusSelection() {
                if(this.app.selectedNodeId) {
                    const n = this.app.nodes.find(x=>x.id===this.app.selectedNodeId);
                    if(n) {
                        // Smoothly move target
                        this.controls.target.set(n.x, n.y, n.z);
                        this.controls.update();
                    }
                }
            }

            zoom(dir) {
                const target = this.controls.target; const camPos = this.camera.position;
                const vec = new THREE.Vector3().subVectors(camPos, target);
                const len = vec.length();
                let newLen = dir > 0 ? Math.max(5, len * 0.8) : Math.min(1000, len * 1.2);
                vec.setLength(newLen); this.camera.position.copy(target).add(vec); this.controls.update();
            }
            capture(targetW, targetH) {
                const originalSize = new THREE.Vector2(); this.renderer.getSize(originalSize);
                const originalAspect = this.camera.aspect;
                this.renderer.setSize(targetW, targetH); this.camera.aspect = targetW / targetH; this.camera.updateProjectionMatrix();
                if(this.app.selectedNodeId) { const n = this.app.nodes.find(x=>x.id===this.app.selectedNodeId); if(n) this.controls.target.set(n.x, n.y, n.z); }
                this.controls.update(); this.renderer.render(this.scene, this.camera);
                const link = document.createElement('a'); link.download = `graph_capture_${Date.now()}.png`; link.href = this.renderer.domElement.toDataURL('image/png'); link.click();
                this.renderer.setSize(originalSize.x, originalSize.y); this.camera.aspect = originalAspect; this.camera.updateProjectionMatrix(); this.controls.update();
            }

            updateScene() {
                const visibleSet = this.app.visibleNodeIds;
                this.meshMap.forEach((obj, id) => { if(!visibleSet.has(id)) { this.scene.remove(obj.mesh); this.scene.remove(obj.label); this.meshMap.delete(id); } });

                const geo = new THREE.SphereGeometry(1, 32, 32);
                visibleSet.forEach(id => {
                    const n = this.app.nodes.find(x=>x.id===id);
                    const size = this.app.config.nodeSize;
                    
                    if(!this.meshMap.has(id)) {
                        const mat = new THREE.MeshPhongMaterial({ 
                            color: n.color, 
                            emissive: n.color, 
                            emissiveIntensity: 0.5,
                            shininess: 30
                        });
                        if(n.icon) {
                            if(!this.textureCache[n.icon]) this.textureCache[n.icon] = this.texLoader.load(n.icon);
                            mat.map = this.textureCache[n.icon];
                            mat.color.setHex(0xffffff); // White base for texture
                            mat.emissive.setHex(0x333333);
                        }
                        const mesh = new THREE.Mesh(geo, mat);
                        mesh.userData = { id: id, type: 'node' };
                        mesh.scale.setScalar(size);
                        this.scene.add(mesh);
                        
                        const label = this.createLabel(n.name, this.app.config.textSize, "#ffffff");
                        this.scene.add(label);
                        this.meshMap.set(id, {mesh, label});
                    } else {
                        const obj = this.meshMap.get(id);
                        if(n.icon) {
                            obj.mesh.material.color.setHex(0xffffff);
                            // handle tex update if needed... simplified
                        } else {
                            obj.mesh.material.color.set(n.color);
                            obj.mesh.material.emissive.set(n.color);
                        }
                        obj.mesh.scale.setScalar(size);
                    }
                });

                this.groupMeshMap.forEach((mesh, gName) => { if(!this.app.collapsedGroups.has(gName) || !this.app.groupCenters[gName]) { this.scene.remove(mesh); this.groupMeshMap.delete(gName); } });
                this.app.collapsedGroups.forEach(gName => {
                    if(!this.groupMeshMap.has(gName)) {
                        const mesh = new THREE.Mesh(new THREE.BoxGeometry(4,4,4), new THREE.MeshPhongMaterial({color:0xffaa00, wireframe:true, emissive:0xffaa00}));
                        mesh.userData = { group: gName, type: 'group' };
                        const l = this.createLabel(`[G] ${gName}`, this.app.config.textSize*1.2, '#ffcc00'); l.position.y=3; mesh.add(l);
                        this.scene.add(mesh); this.groupMeshMap.set(gName, mesh);
                    }
                });

                this.linkObjects.forEach(l => { this.scene.remove(l.obj); if(l.sprite) this.scene.remove(l.sprite); });
                this.linkObjects = [];

                const visArr = Array.from(visibleSet);
                const getPos = (nid) => { const n = this.app.nodes.find(x=>x.id===nid); return this.app.collapsedGroups.has(n.group) ? this.app.groupCenters[n.group] : n; };
                const selId = this.app.selectedNodeId;
                
                visArr.forEach(nid => {
                    const n1 = this.app.nodes.find(x=>x.id===nid); const p1 = getPos(nid);
                    n1.links.forEach(tid => {
                        const n2 = this.app.nodes.find(x=>x.id===tid);
                        if(!n2 || (!visibleSet.has(tid) && !this.app.collapsedGroups.has(n2.group))) return;
                        const p2 = getPos(tid);
                        if(p1 !== p2) {
                            const isSel = (n1.id === selId || n2.id === selId);
                            this.createLink(p1, p2, 0xffffff, isSel ? 1.0 : 0.4, isSel, null);
                        }
                    });
                });
                
                for(let i=0; i<visArr.length; i++) {
                    for(let j=i+1; j<visArr.length; j++) {
                        const n1 = this.app.nodes.find(x=>x.id===visArr[i]); const n2 = this.app.nodes.find(x=>x.id===visArr[j]);
                        const shared = n1.tags.filter(t => n2.tags.includes(t));
                        if(shared.length > 0) {
                            const p1 = getPos(n1.id); const p2 = getPos(n2.id);
                            if(p1!==p2) {
                                const isSel = (n1.id === selId || n2.id === selId);
                                this.createLink(p1, p2, this.app.getTagColor(shared[0]), isSel?0.9:0.5, isSel, shared[0]);
                            }
                        }
                    }
                }
            }

            createLink(p1, p2, colorHex, opacity, isLaser, labelText) {
                let obj;
                if (isLaser) {
                    const dist = new THREE.Vector3(p1.x,p1.y,p1.z).distanceTo(new THREE.Vector3(p2.x,p2.y,p2.z));
                    const geo = new THREE.CylinderGeometry(0.3, 0.3, dist, 8, 1, true); geo.rotateX(Math.PI / 2);
                    const mat = new THREE.MeshBasicMaterial({ color: colorHex, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending });
                    obj = new THREE.Mesh(geo, mat);
                } else {
                    const pts = [new THREE.Vector3(p1.x,p1.y,p1.z), new THREE.Vector3(p2.x,p2.y,p2.z)];
                    const geo = new THREE.BufferGeometry().setFromPoints(pts);
                    obj = new THREE.Line(geo, new THREE.LineBasicMaterial({ color: colorHex, transparent: true, opacity: opacity }));
                }
                this.scene.add(obj);
                let sprite = null;
                if(labelText) {
                    sprite = this.createLabel(labelText, this.app.config.textSize*0.8, colorHex);
                    sprite.visible = false;
                    this.scene.add(sprite);
                }
                this.linkObjects.push({ obj, sprite, p1, p2, isLaser, labelText });
            }

            createLabel(text, scaleMod, color="#ffffff") {
                const cvs = document.createElement('canvas'); const ctx = cvs.getContext('2d');
                cvs.width=512; cvs.height=128; ctx.font="Bold 70px Arial";
                ctx.shadowColor="black"; ctx.shadowBlur=6; ctx.shadowOffsetX=3; ctx.shadowOffsetY=3;
                ctx.fillStyle=color; ctx.textAlign="center"; ctx.fillText(text, 256, 90);
                const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map:new THREE.CanvasTexture(cvs), transparent:true, depthTest:false }));
                sp.scale.set(8 * scaleMod, 2 * scaleMod, 1);
                sp.renderOrder = 999;
                return sp;
            }

            sync() {
                if(!this.scene) return;
                const size = this.app.config.nodeSize;
                this.meshMap.forEach((obj, id) => {
                    const n = this.app.nodes.find(x => x.id === id);
                    if(n) { obj.mesh.position.set(n.x, n.y, n.z); obj.label.position.set(n.x, n.y + size + 1.5, n.z); }
                });
                this.groupMeshMap.forEach((mesh, gName) => { const c = this.app.groupCenters[gName]; if(c) mesh.position.set(c.x, c.y, c.z); });
                if(this.app.selectedNodeId) {
                    const n = this.app.nodes.find(x=>x.id===this.app.selectedNodeId);
                    if(n && this.meshMap.has(n.id)) { 
                        this.selectionRing.visible = true; 
                        this.selectionRing.position.set(n.x, n.y, n.z); 
                        this.selectionRing.lookAt(this.camera.position); 
                        this.selectionRing.scale.setScalar(size);
                    } else this.selectionRing.visible = false;
                } else this.selectionRing.visible = false;

                this.linkObjects.forEach(l => {
                    const v1 = new THREE.Vector3(l.p1.x, l.p1.y, l.p1.z); const v2 = new THREE.Vector3(l.p2.x, l.p2.y, l.p2.z);
                    const mid = v1.clone().add(v2).multiplyScalar(0.5);
                    if(l.isLaser) { l.obj.position.copy(mid); l.obj.lookAt(v2); const dist = v1.distanceTo(v2); l.obj.scale.set(1, 1, dist); }
                    else { const pos = l.obj.geometry.attributes.position.array; pos[0]=v1.x; pos[1]=v1.y; pos[2]=v1.z; pos[3]=v2.x; pos[4]=v2.y; pos[5]=v2.z; l.obj.geometry.attributes.position.needsUpdate = true; }
                    if(l.sprite) l.sprite.position.copy(mid);
                });
                this.updateHighlights();
            }
            updateHighlights() {
                const selId = this.app.selectedNodeId; const selNode = selId ? this.app.nodes.find(x=>x.id===selId) : null; const baseText = this.app.config.textSize;
                this.meshMap.forEach((obj, id) => { if(id === selId) obj.label.scale.set(10*baseText, 2.5*baseText, 1); else obj.label.scale.set(7*baseText, 1.75*baseText, 1); });
                this.linkObjects.forEach(l => { if(l.sprite) { let c = false; if(selNode) { if(l.p1 === selNode || l.p2 === selNode) c = true; } if(c) { l.sprite.visible = true; l.sprite.scale.set(5*baseText, 1.25*baseText, 1); } else l.sprite.visible = false; } });
            }
            onClick(e) {
                const rect = this.renderer.domElement.getBoundingClientRect();
                this.mouse.x = ((e.clientX - rect.left)/rect.width)*2 - 1; this.mouse.y = -((e.clientY - rect.top)/rect.height)*2 + 1;
                this.raycaster.setFromCamera(this.mouse, this.camera);
                const targets = []; this.meshMap.forEach(v=>targets.push(v.mesh)); this.groupMeshMap.forEach(v=>targets.push(v));
                const intersects = this.raycaster.intersectObjects(targets);
                if(intersects.length > 0) { const ud = intersects[0].object.userData; if(ud.type === 'node') this.app.selectNode(ud.id); else if (ud.type === 'group') this.app.toggleGroup(ud.group); } else this.app.selectNode(null);
            }
            resetCamera() { this.camera.position.set(0,30,60); this.controls.reset(); }
            animate() { requestAnimationFrame(()=>this.animate()); this.controls.update(); this.renderer.render(this.scene, this.camera); }
        }

        class UIManager {
            constructor(app) {
                this.app = app;
                this.inputs = { name: document.getElementById('node-name'), group: document.getElementById('node-group'), color: document.getElementById('node-color'), icon: document.getElementById('node-icon'), tags: document.getElementById('node-tags'), body: document.getElementById('node-body'), linkSelect: document.getElementById('link-select') };
                this.iSearch = document.getElementById('search-input'); this.mainListView = document.getElementById('main-list-view');
                this.bindEvents();
            }
            setStatus(msg) { document.getElementById('status-bar').innerText = msg; }
            bindEvents() {
                ['name','group','color','icon','tags','body'].forEach(k => { this.inputs[k].addEventListener('change', () => this.app.updateNodeProp()); });
                document.getElementById('range-slider').oninput = (e) => { this.app.visibleRange = parseInt(e.target.value); document.getElementById('range-val').innerText = this.app.visibleRange; this.app.refresh(); };
                this.iSearch.oninput = () => this.app.refresh();
                document.getElementById('file-input').onchange = (e) => {
                    const file = e.target.files[0];
                    if(!file) return;
                    const r = new FileReader();
                    r.onload = (ev) => { this.app.loadJsonData(ev.target.result); this.setStatus("Loaded: " + file.name); };
                    r.readAsText(file);
                };
                document.addEventListener('click', (e) => {
                    if(!e.target.closest('.menu-item')) document.querySelectorAll('.dropdown').forEach(d => d.style.display='none');
                    else {
                        const dd = e.target.closest('.menu-item').querySelector('.dropdown');
                        document.querySelectorAll('.dropdown').forEach(d => { if(d!==dd) d.style.display='none'; });
                        if(dd) dd.style.display = (dd.style.display==='block' ? 'none' : 'block');
                    }
                });
                const cm = document.getElementById('context-menu'); document.addEventListener('click', () => cm.style.display='none');
                document.getElementById('ctx-connect').onclick = () => this.app.addManualLink(this.ctxId);
                document.getElementById('ctx-delete').onclick = () => this.app.deleteNode(this.ctxId);
            }
            switchLeftTab(mode) {
                document.querySelectorAll('#left-panel .tab').forEach(e=>e.classList.remove('active'));
                document.querySelectorAll('#left-panel .tab')[mode==='nodes'?0:1].classList.add('active');
                document.getElementById('tab-nodes').classList.toggle('hidden', mode!=='nodes');
                document.getElementById('tab-groups').classList.toggle('hidden', mode!=='groups');
            }
            switchRightTab(mode) {
                document.querySelectorAll('#right-panel .tab').forEach(e=>e.classList.remove('active'));
                const idx = {'prop':0, 'tags':1, 'global':2}[mode];
                document.querySelectorAll('#right-panel .tab')[idx].classList.add('active');
                ['prop','tags','global'].forEach(m => document.getElementById('tab-'+m).classList.add('hidden'));
                document.getElementById('tab-'+mode).classList.remove('hidden');
            }
            updateConfigUI() {
                document.getElementById('conf-link').value = this.app.config.linkLength; document.getElementById('conf-link-val').innerText = this.app.config.linkLength;
                document.getElementById('conf-size').value = this.app.config.nodeSize; document.getElementById('conf-size-val').innerText = this.app.config.nodeSize;
                document.getElementById('conf-text').value = this.app.config.textSize; document.getElementById('conf-text-val').innerText = this.app.config.textSize;
            }
            updateSelection() {
                const nid = this.app.selectedNodeId;
                if(nid) {
                    document.getElementById('prop-node').classList.remove('hidden'); document.getElementById('prop-empty').classList.add('hidden');
                    const n = this.app.nodes.find(x=>x.id===nid);
                    this.inputs.name.value = n.name; this.inputs.group.value = n.group; this.inputs.color.value = n.color; this.inputs.icon.value = n.icon || "";
                    this.inputs.tags.value = n.tags.join(', '); this.inputs.body.value = n.body;
                    this.inputs.linkSelect.innerHTML = '<option value="">+ 接続先...</option>';
                    this.app.nodes.forEach(o => { if(o.id !== nid && !n.links.includes(o.id)) this.inputs.linkSelect.appendChild(new Option(o.name, o.id)); });
                    const list = document.getElementById('manual-links-list'); list.innerHTML = '';
                    n.links.forEach(lid => { const t = this.app.nodes.find(x=>x.id===lid); if(t) list.innerHTML += `<div style="font-size:0.8em; padding:2px; border-bottom:1px solid #333;">🔗 ${t.name} <span style="color:#f55; cursor:pointer; float:right;" onclick="app.removeManualLink(${lid})">×</span></div>`; });
                } else { document.getElementById('prop-node').classList.add('hidden'); document.getElementById('prop-empty').classList.remove('hidden'); }
                this.updateLists();
            }
            updateLists() {
                const genItem = (n) => {
                    let iconHtml = n.icon ? `<div class="node-icon-visual" style="background-image:url('${n.icon}')"></div>` : `<div class="node-icon-visual"><div class="node-ball" style="color:${n.color}"></div></div>`;
                    return `${iconHtml}<div class="node-info"><div class="node-name">${n.name}</div><div class="node-meta">${n.group}</div></div>`;
                };
                const nl = document.getElementById('node-list'); nl.innerHTML = '';
                const term = this.iSearch.value.toLowerCase(); const groups = new Set();
                this.app.nodes.forEach(n => {
                    groups.add(n.group); if(term && !n.name.toLowerCase().includes(term)) return;
                    const el = document.createElement('div'); el.className = 'list-box-item'; if(n.id === this.app.selectedNodeId) el.classList.add('active');
                    el.innerHTML = genItem(n); el.onclick = () => this.app.selectNode(n.id);
                    el.oncontextmenu = (e) => { e.preventDefault(); this.ctxId=n.id; const cm=document.getElementById('context-menu'); cm.style.display='block'; cm.style.left=e.pageX+'px'; cm.style.top=e.pageY+'px'; };
                    nl.appendChild(el);
                });
                if(this.app.mode === 'list') {
                    this.mainListView.innerHTML = '';
                    this.app.nodes.forEach(n => {
                        if(term && !n.name.toLowerCase().includes(term)) return;
                        const el = document.createElement('div'); el.className = 'list-box-item'; if(n.id === this.app.selectedNodeId) el.classList.add('active');
                        el.innerHTML = genItem(n); el.onclick = () => this.app.selectNode(n.id);
                        this.mainListView.appendChild(el);
                    });
                }
                const gl = document.getElementById('group-list'); gl.innerHTML = '';
                Array.from(groups).sort().forEach(g => {
                    const isCollapsed = this.app.collapsedGroups.has(g); const div = document.createElement('div'); div.className = 'group-item';
                    div.innerHTML = `<div class="group-header" onclick="app.toggleGroup('${g}')"><span><span class="group-toggle">[${isCollapsed?'+':'-'}]</span> ${g}</span></div>`; gl.appendChild(div);
                });
                const dl = document.getElementById('group-suggestions'); dl.innerHTML = ''; groups.forEach(g => dl.appendChild(new Option(g)));
                const tl = document.getElementById('tag-list'); tl.innerHTML = '';
                for(let tag in this.app.tagColors) {
                    const row = document.createElement('div'); row.className = 'tag-row'; const col = this.app.tagColors[tag];
                    row.innerHTML = `<input type="color" value="${col}" onchange="app.tagColors['${tag}']=this.value; app.refresh();" class="color-preview" style="padding:0; border:none;"><span style="flex:1; color:${col}">${tag}</span>`;
                    tl.appendChild(row);
                }
            }
        }
        class AIManager {
            constructor(app) { this.app = app; this.modal = document.getElementById('ai-modal'); }
            open() { this.modal.style.display = 'flex'; } close() { this.modal.style.display = 'none'; }
            generatePrompt() { const req = document.getElementById('ai-input').value; const p = `あなたはデータ構造化のプロです。ユーザーの要望に基づき、ネットワークグラフのJSON配列を作成してください。\n【要望】\n${req}\n【必須フォーマット】\n[\n  {\n    "name": "ノード名",\n    "group": "グループ名",\n    "tags": ["タグ1"],\n    "color": "#HEX",\n    "icon": "URL",\n    "body": "Markdown",\n    "connects_to": ["接続先名"]\n  }\n]\nMarkdownコードブロックは不要。純粋なJSONのみ。`; document.getElementById('ai-prompt-output').value = p; }
            copyPrompt() { document.getElementById('ai-prompt-output').select(); document.execCommand('copy'); }
            apply() { try { let raw = document.getElementById('ai-json-input').value.trim(); raw = raw.replace(/```json/g, '').replace(/```/g, '').trim(); const data = JSON.parse(raw); if(!Array.isArray(data)) throw new Error("Not Array"); const nameMap = {}; data.forEach(d => { const id = this.app.nextId++; const node = { id: id, name: d.name, group: d.group || "Imported", tags: d.tags || [], color: d.color || "#00e5ff", icon: d.icon || "", body: d.body || "", links: [], x: (Math.random()-0.5)*20, y: (Math.random()-0.5)*20, z: (Math.random()-0.5)*20, vx:0, vy:0, vz:0 }; this.app.nodes.push(node); nameMap[node.name] = id; node.tags.forEach(t => { if(!this.app.tagColors[t]) this.app.tagColors[t] = this.app.getRandomColor(); }); }); data.forEach(d => { if(d.connects_to) { const src = this.app.nodes.find(n=>n.id===nameMap[d.name]); d.connects_to.forEach(tn => { const tid = nameMap[tn]; if(tid) src.links.push(tid); }); } }); this.app.refresh(); this.app.explode(); this.close(); } catch(e) { alert("JSON Error: "+e.message); } }
        }
        var app, aiManager, ui; window.onload = () => { app = new App(); ui = app.ui; aiManager = new AIManager(app); window.app = app; window.aiManager = aiManager; window.ui = ui; };
    </script>
</body>
</html>

いいなと思ったら応援しよう!

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
Todoの検索ツール「Visible Grid」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1