Todoの検索ツール「Visible Grid」


【更新履歴】

・2026/2/11 1.0公開。
・2026/2/12 1.2日本語版を公開。(AI連携など追加)
・2026/2/12 1.3日本語版を公開。(表示モードなど追加)
・2026/2/12 1.4日本語版を公開。(フォルダ形式に変更)


画像
3Dグラフ画面

Visible Grid 取扱説明書

  1. 概要

・Visible Grid は、
 アイデアやデータを
 3次元空間で構造化するツールです。

・従来のナレッジグラフツールと異なり、
 「1つのノードを1つのJSONファイル」として
 PC上のフォルダで直接管理する仕組みを採用しています。

・これにより、OSの検索機能との親和性を高め、
 大量のデータを安全かつ柔軟に管理できます。

  1. 動作環境

 必須ブラウザ:

  ・Google Chrome または Microsoft Edge(PC版)

  ※「File System Access API」を使用するため、
   これら以外のブラウザやスマホでは
   一部機能(フォルダ直接編集)が制限されます。

  1. 基本操作(マウス・キーボード)

 ・左クリック
  … ノードの選択(カメラは動きません)

 ・ダブルクリック
  … ノードの選択 + フォーカス(対象を画面中央に捉えます)

 ・右ドラッグ
  … カメラの移動(パン)

 ・左ドラッグ
  … カメラの回転(※モードにより変更可)

 ・ホイール
  … ズームイン・アウト

 ・PageUp / PageDown
  … ズームイン・アウト(キーボード操作)

 ・Home
  … カメラ位置を初期状態にリセット

  1. ファイル管理(フォルダ・ワークスペース)

 ・本ツールは、指定したフォルダ内にある .json ファイルを
   … すべて読み込み、グラフとして構築します。

 • 新規ワークスペース:
   … 空のフォルダを選択して、新しい作業を開始します。
 
 • 開く (フォルダ):
   …  JSONファイルが入ったフォルダを選択して読み込みます。
     サブフォルダ(グループ)も読み込みます。

 • すべて保存:
   … 現在の状態をフォルダに一括保存します。
     ファイル名は ノード名_タグ_タグ_ID.json の形式で保存され、
     OSのエクスプローラー上でも検索しやすくなっています。

 • JSONファイルに保存:
  …  バックアップ用として、全データをまとめた
     単一のJSONファイルをダウンロードします。

  1. ノードの編集とプロパティ

 ・ノードを選択すると、右パネルで詳細を編集できます。
 ・変更はリアルタイムにプレビューされます。

  • 名前: ノードの名称。
  
  • グループ:
    … 所属グループ。
      保存時、自動的に同名のサブフォルダが作成され、
      そこにファイルが格納されます。

  • 色設定:
    … カラーピッカーで色を変更できます。
      ドラッグ中も即座に横線や3Dモデルに色が反映されます。

  • アイコン:
    … 「選択...」ボタンから画像ファイルを読み込めます。
       画像データはファイル内に埋め込まれます(DataURL)。

  • メモ (Markdown):
    … 「編集」ボタンを押すと、エディタ画面が開きます。

  1. ビュー・ドック(表示モード切替)

 ・画面下部のボタンでレイアウトを瞬時に切り替えます。

  1. 🕸️ 3D Graph: … 物理演算による力学モデル配置。

  2. 🌳 Tree:   … 選択中のノードを頂点とした階層ツリー表示。

  3. 🎯 Radial:  … 選択中のノードを画面中央 (0,0,0) に固定し、
            接続ノードを円形に配置します。

  4. 📝 List:   … 3D表示をオフにし、2Dのリストボックス形式で
            高速に閲覧・編集します。

  1. メニュー詳細

 • カメラ (Camera)

   ・回転 / 移動 / ズーム:
     … 左ドラッグ時の挙動を切り替えます。

   ・注視してズーム:
     … 画面中央(選択ノード)に向かって一直線にズームします。

   ・注視して旋回:
     … 選択中のノードをロックオンし、
       その周囲を回り込むようにカメラを動かします。

 • ツール (Tool)

   ・パスをクリア:
    … 経路探索のハイライトを消去します。
   
   ・画面キャプチャ:
     … 現在の表示を高解像度画像として保存します。

 • ✨ AI 設計:

    … AIに「〇〇の相関図を作って」と指示し、
      生成されたデータを貼り付けることで
      グラフを自動構築します。

  1. 最短経路探索

  1. ゴール設定:
    … 目標のノードを選択し、
      右パネルの「経路探索ゴールに設定」を押します。
  
  2. スタート選択:
    … 任意のノードをクリックすると、
      ゴールまでの最短ルートが赤く光ります。

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

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

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Visible Grid 1.4</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 */
        #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: 240px; 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-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; }
        
        /* Panels */
        #container { display: flex; height: calc(100% - 40px); }
        .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; color: var(--accent); }
        
        /* 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.active { color: var(--accent); border-bottom-color: var(--accent); background: #1e222b; color: #fff; }

        /* Lists */
        #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; 
            border-left: 6px solid transparent; 
        }
        .list-box-item:hover { background: #1f232a; }
        .list-box-item.active { background: #2a303b; padding-left: 12px; }
        
        .node-icon-visual { width: 32px; height: 32px; 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; }
        .tag-row { padding: 8px 15px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid #222; }
        
        /* 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; border-left-width: 6px; }
        
        /* View Dock */
        #view-dock { 
            position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
            height: 40px; background: rgba(20, 24, 30, 0.9); border: 1px solid #444; border-radius: 20px;
            display: flex; align-items: center; justify-content: center; gap: 10px; z-index: 20; padding: 0 15px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.5);
        }
        .view-btn { background: transparent; border: 1px solid transparent; color: #aaa; padding: 4px 12px; border-radius: 15px; cursor: pointer; font-size: 0.8em; display: flex; align-items: center; gap: 5px; transition: 0.2s; }
        .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: flex; justify-content: space-between; align-items: center; 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; }
        
        input[type="color"] { -webkit-appearance: none; padding: 0; border: 1px solid #444; height: 35px; cursor: pointer; background: none; width: 100%; }
        input[type="color"]::-webkit-color-swatch-wrapper { padding: 0; }
        input[type="color"]::-webkit-color-swatch { border: none; border-radius: 3px; }

        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.btn-primary { background: linear-gradient(45deg, #1e3c72, #2a5298); border: none; color: white; }
        button.btn-danger { background: #3a1c1c; border-color: #522; color: #ff8888; }
        .btn-small { width: auto; padding: 2px 8px; font-size: 0.85em; margin: 0; }

        .hidden { display: none !important; }
        #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; }

        /* Modal */
        .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); }
        .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; max-height: 90vh; }
        .modal-header { padding: 15px 20px; background: linear-gradient(90deg, #4a148c, #1a1c23); color: #fff; font-weight: bold; display: flex; justify-content: space-between; align-items: center; }
        .modal-body { padding: 20px; 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; }
        #note-editor-area { width: 100%; height: 400px; background: #121418; color: #ddd; font-family: 'Consolas', monospace; padding: 10px; border: 1px solid #444; resize: none; font-size: 1em; line-height: 1.5; }

        #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; z-index:20; }
    </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">
            ファイル (File)
            <div class="dropdown">
                <div class="dd-item" onclick="app.newWorkspace()">新規ワークスペース</div>
                <div class="dd-item" onclick="app.openFolder()">開く (フォルダ)</div>
                <div class="dd-sep"></div>
                <div class="dd-item" onclick="app.saveAllFiles()">すべて保存</div>
                <div class="dd-item" onclick="app.downloadSingleJSON()">JSONファイルに保存</div>
            </div>
        </div>

        <div class="menu-item">
            カメラ (Camera)
            <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-item" onclick="app.visualizer.setCamMode('focus-zoom')">注視してズーム (Focus Zoom)</div>
                <div class="dd-sep"></div>
                <div class="dd-item" onclick="app.visualizer.focusSelection()">注視して旋回 (Orbit Selection)</div>
                <div class="dd-item" onclick="app.visualizer.resetCamera()">リセット (Home)</div>
            </div>
        </div>
        
        <div class="menu-item">
            ツール (Tool)
            <div class="dropdown">
                <div class="dd-item" onclick="app.clearPath()">パスをクリア</div>
                <div class="dd-item" onclick="app.captureGraph()">画面キャプチャ</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" class="modal">
        <div class="modal-content">
            <div class="modal-header"><span>AI Architect</span><button class="close-btn" onclick="aiManager.close()">×</button></div>
            <div class="modal-body">
                <div class="ai-step">
                    <div style="font-weight:bold; color:#ccc; margin-bottom:5px;">Step 1: 要望入力</div>
                    <textarea id="ai-input" placeholder="例: ファンタジー小説の人物相関図を作って..."></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:80px;"></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: 結果反映</div>
                    <textarea id="ai-json-input" class="code-area" placeholder='[ { "name": "...", ... } ]' style="height:80px; color:#fff;"></textarea>
                    <button class="action-btn btn-primary" onclick="aiManager.apply()">グラフを生成</button>
                </div>
            </div>
        </div>
    </div>

    <div id="note-modal" class="modal">
        <div class="modal-content" style="height:80vh;">
            <div class="modal-header" style="background: #333;"><span>メモ編集</span><button class="close-btn" onclick="ui.closeNoteEditor()">×</button></div>
            <div class="modal-body" style="display:flex; flex-direction:column; height:100%;">
                <textarea id="note-editor-area" placeholder="Markdown形式で入力..."></textarea>
                <div style="display:flex; justify-content:flex-end; gap:10px; margin-top:10px;">
                    <button class="action-btn" onclick="ui.closeNoteEditor()">キャンセル</button>
                    <button class="action-btn btn-primary" onclick="ui.saveNoteEditor()">変更して閉じる</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(true);">Reset</button>
                        </div>
                        <label class="prop-label">
                            アイコン
                            <div>
                                <input type="file" id="icon-file-input" style="display:none;" accept="image/*">
                                <button class="action-btn btn-small" onclick="document.getElementById('icon-file-input').click()">選択...</button>
                            </div>
                        </label>
                        <input type="text" id="node-icon" placeholder="">
                        <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">
                            メモ (Markdown)
                            <button class="action-btn btn-small" onclick="ui.openNoteEditor()">編集</button>
                        </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">5</span></label>
                    <input type="range" id="conf-link" min="1" max="30" value="5" oninput="app.updateConfig('linkLength', this.value)">
                    <label class="prop-label">アイコンサイズ: <span id="conf-size-val">2.0</span></label>
                    <input type="range" id="conf-size" min="0.5" max="5.0" step="0.1" value="2.0" oninput="app.updateConfig('nodeSize', this.value)">
                    <label class="prop-label">文字サイズ: <span id="conf-text-val">3.0</span></label>
                    <input type="range" id="conf-text" min="0.5" max="20.0" step="0.5" value="3.0" oninput="app.updateConfig('textSize', this.value)">
                </div>
                <div class="panel-header">ビュー設定</div>
                <div class="prop-group">
                    <label class="prop-label">可視深度: <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" 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 10.1: Ultimate (Selection/Focus Fix + Anti-Vanish)
         */
        class App {
            constructor() {
                this.nodes = [];
                this.nextId = 1;
                this.selectedNodeId = null;
                this.targetNodeId = null;
                this.dirHandle = null; 
                
                this.visibleRange = 2;
                this.searchQuery = "";
                this.physicsParams = { repulsion: 1000, spring: 0.05, damping: 0.90 };
                this.config = { linkLength: 5, nodeSize: 2.0, textSize: 3.0 };

                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();
                this.initDemoData(); 
                this.physicsLoop();
            }

            async openFolder() {
                if(!window.showDirectoryPicker) { alert("Chrome/Edge only"); return; }
                try {
                    const handle = await window.showDirectoryPicker();
                    this.dirHandle = handle;
                    this.nodes = []; this.tagColors = {}; let maxId = 0;
                    this.ui.setStatus("Reading...");
                    await this.scanDirectory(handle);
                    if(this.nodes.length === 0) { this.initDemoData(); this.ui.setStatus("Empty folder"); } 
                    else { this.nextId = maxId + 1; this.ui.setStatus(`Loaded ${this.nodes.length} nodes`); }
                    this.refresh();
                } catch(e) { console.error(e); this.ui.setStatus("Cancelled"); }
            }
            async scanDirectory(dirHandle) {
                for await (const entry of dirHandle.values()) {
                    if (entry.kind === 'file' && entry.name.endsWith('.json')) {
                        if(entry.name.startsWith('._')) continue;
                        try {
                            const file = await entry.getFile(); const text = await file.text(); const data = JSON.parse(text);
                            if(data.id && data.name) {
                                this.sanitizeNode(data);
                                this.nodes.push(data);
                                if(data.id >= this.nextId) this.nextId = data.id + 1;
                                data.tags.forEach(t => { if(!this.tagColors[t]) this.tagColors[t] = this.getRandomColor(); });
                            }
                        } catch(e) {}
                    } else if (entry.kind === 'directory') { await this.scanDirectory(entry); }
                }
            }
            sanitizeNode(n) {
                // NaN/Infinity Guard
                if(!Number.isFinite(n.x)) n.x = (Math.random()-0.5)*20; 
                if(!Number.isFinite(n.y)) n.y = (Math.random()-0.5)*20; 
                if(!Number.isFinite(n.z)) n.z = (Math.random()-0.5)*20;
                n.vx=0; n.vy=0; n.vz=0; 
                if(!n.color) n.color = "#00e5ff"; if(!n.links) n.links = []; if(!n.tags) n.tags = [];
                if(n.icon && typeof n.icon === 'string') {
                    if(n.icon.includes('(') && n.icon.includes(')')) { const m = n.icon.match(/\((http.*?)\)/); if(m) n.icon = m[1]; }
                    n.icon = n.icon.replace(/[\[\]]/g, ''); if(n.icon.includes("example.com") || n.icon.includes("placeholder")) n.icon = "";
                }
            }
            async newWorkspace() { if(confirm("Clear workspace?")) { this.nodes=[]; this.nextId=1; this.selectedNodeId=null; this.tagColors={}; this.dirHandle=null; await this.openFolder(); } }
            async saveNodeFile(node) {
                if(!this.dirHandle) return; 
                try {
                    const groupName = node.group ? node.group.replace(/[\/\\?%*:|"<>]/g, '_') : "Default";
                    const groupHandle = await this.dirHandle.getDirectoryHandle(groupName, { create: true });
                    const safeName = node.name.replace(/[^a-z0-9\u00a0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]/gi, '_') || 'node';
                    const safeTags = node.tags.map(t => t.replace(/[^a-z0-9\u00a0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]/gi, '_')).join('_');
                    const fileName = safeTags ? `${safeName}_${safeTags}_${node.id}.json` : `${safeName}_${node.id}.json`;
                    const fileHandle = await groupHandle.getFileHandle(fileName, { create: true });
                    const writable = await fileHandle.createWritable();
                    await writable.write(JSON.stringify(node, null, 2));
                    await writable.close();
                } catch(e) { console.error("Save failed", e); }
            }
            async saveAllFiles() {
                if(!this.dirHandle) { if(confirm("No folder set. Open folder?")) { await this.openFolder(); if(this.dirHandle) { for(const n of this.nodes) await this.saveNodeFile(n); this.ui.setStatus("Saved."); } } return; }
                this.ui.setStatus("Saving all..."); for(const n of this.nodes) await this.saveNodeFile(n); this.ui.setStatus("All saved.");
            }
            downloadSingleJSON() { const blob = new Blob([JSON.stringify({nodes:this.nodes, config:this.config}, null, 2)], {type:"application/json"}); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download="grid_export.json"; a.click(); }

            initDemoData() { this.nodes = [{ id: 1, name: "Start Node", group: "System", tags: ["Root"], links: [], body: "Welcome", x:0, y:0, z:0, vx:0, vy:0, vz:0, color:"#00e5ff", icon:"" }]; this.nextId = 2; this.assignAutoColors(); }
            updateConfig(key, val) { this.config[key] = parseFloat(val); this.ui.updateConfigUI(); this.refresh(); }
            addNode() { const id = this.nextId++; const node = { 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.nodes.push(node); this.selectNode(id, true); this.saveNodeFile(node); if(this.mode !== 'graph' && this.mode !== 'list') this.calcLayout(); }
            
            updateNodeProp(forceSave = false) {
                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.sanitizeNode(n);
                    this.ui.updateListItemColor(n.id, n.color);
                    this.visualizer.updateNodeColor(n.id, n.color); 
                    if(forceSave) { this.saveNodeFile(n); this.refresh(); }
                }
            }
            deleteNode(id) {
                this.nodes.forEach(n => { const oldLen = n.links.length; n.links = n.links.filter(lid => lid != id); if(n.links.length !== oldLen) this.saveNodeFile(n); });
                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();
            }
            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.saveNodeFile(n); 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.saveNodeFile(n); this.refresh(); this.ui.updateSelection(); } }
            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 || (this.nodes.length > 0 ? this.nodes[0].id : null); this.layoutTargets = {}; if(!rootId) return;
                
                if(this.mode === 'radial') {
                    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; 
                    if(neighbors.length > 0) {
                        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 }; });
                    }
                    this.nodes.forEach(n => { if(n.id !== rootId && !neighbors.includes(n.id)) this.layoutTargets[n.id] = {x: (Math.random()-0.5)*200, y: (Math.random()-0.5)*200, z: -100}; });
                } 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 = 12; const height = 12;
                        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;
                    // Physics Guard (Prevents Vanish)
                    if(!Number.isFinite(n.x) || Math.abs(n.x)>1000) n.x=0;
                    if(!Number.isFinite(n.y) || Math.abs(n.y)>1000) n.y=0;
                    if(!Number.isFinite(n.z) || Math.abs(n.z)>1000) n.z=0;
                    if(!Number.isFinite(n.vx)) n.vx=0; if(!Number.isFinite(n.vy)) n.vy=0; if(!Number.isFinite(n.vz)) n.vz=0;
                });
                if(this.mode !== 'list') this.visualizer.sync();
            }
            selectNode(id, focus = false) {
                this.selectedNodeId = id; this.pathLinks.clear();
                if(this.targetNodeId && id) this.calculatePath(id, this.targetNodeId);
                // For Radial/Tree, re-layout
                if(id && (this.mode === 'radial' || this.mode === 'tree')) {
                    this.calcLayout();
                }
                this.refresh(); 
                this.ui.updateSelection();
                if(focus && id) this.visualizer.focusSelection();
            }
            setPathTarget() { if(this.selectedNodeId) { this.targetNodeId=this.selectedNodeId; this.refresh(); } }
            clearPath() { this.targetNodeId=null; this.pathLinks.clear(); this.refresh(); }
            getRandomColor() { return '#' + Math.floor(Math.random()*16777215).toString(16).padStart(6,'0'); }
            getTagColor(t) { return this.tagColors[t] || '#888'; }
            assignAutoColors() { this.nodes.forEach(n => n.tags.forEach(t => { if(!this.tagColors[t]) this.tagColors[t] = this.getRandomColor(); })); }
            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 || 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(); }
            explode() { this.physicsParams.repulsion = 3000; setTimeout(()=>this.physicsParams.repulsion=800, 500); }
            captureGraph() { const w = prompt("W", "1920"); const h = prompt("H", "1080"); if(w && h) this.visualizer.capture(parseInt(w), parseInt(h)); }
        }

        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 = {};
                this.mouseDownPos = new THREE.Vector2();
            }
            init() {
                this.scene = new THREE.Scene(); 
                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);
                this.scene.add(new THREE.AmbientLight(0xffffff, 1.2)); 
                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.mouseDownPos.set(e.clientX, e.clientY); });
                this.renderer.domElement.addEventListener('pointerup', (e) => {
                    const dx = e.clientX - this.mouseDownPos.x; const dy = e.clientY - this.mouseDownPos.y;
                    if(Math.abs(dx) < 3 && Math.abs(dy) < 3) { this.onClick(e); }
                });
                this.renderer.domElement.addEventListener('dblclick', (e) => { this.onDblClick(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'); this.camMode = m;
                if(m==='rotate') { this.controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.PAN }; el.innerText = "回転"; }
                else if(m==='pan') { this.controls.mouseButtons = { LEFT: THREE.MOUSE.PAN, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.ROTATE }; el.innerText = "移動"; }
                else if(m==='zoom') { this.controls.mouseButtons = { LEFT: THREE.MOUSE.DOLLY, MIDDLE: THREE.MOUSE.ROTATE, RIGHT: THREE.MOUSE.PAN }; el.innerText = "ズーム"; }
                else if(m==='focus-zoom') { this.controls.mouseButtons = { LEFT: THREE.MOUSE.DOLLY, MIDDLE: THREE.MOUSE.ROTATE, RIGHT: THREE.MOUSE.PAN }; el.innerText = "注視ズーム"; }
            }
            focusSelection() { if(this.app.selectedNodeId) { const n = this.app.nodes.find(x=>x.id===this.app.selectedNodeId); if(n && Number.isFinite(n.x)) { this.controls.target.set(n.x, n.y, n.z); this.controls.update(); } } }
            zoom(dir) { const t = this.controls.target; const cp = this.camera.position; const v = new THREE.Vector3().subVectors(cp, t); const l = v.length(); let nl = dir > 0 ? Math.max(5, l * 0.8) : Math.min(1000, l * 1.2); v.setLength(nl); this.camera.position.copy(t).add(v); this.controls.update(); }
            capture(tw, th) { const os = new THREE.Vector2(); this.renderer.getSize(os); const oa = this.camera.aspect; this.renderer.setSize(tw, th); this.camera.aspect = tw/th; 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 l = document.createElement('a'); l.download = `cap_${Date.now()}.png`; l.href = this.renderer.domElement.toDataURL('image/png'); l.click(); this.renderer.setSize(os.x, os.y); this.camera.aspect = oa; this.camera.updateProjectionMatrix(); this.controls.update(); }
            
            updateNodeColor(id, color) {
                if(this.meshMap.has(id)) {
                    const obj = this.meshMap.get(id);
                    const n = this.app.nodes.find(x=>x.id===id);
                    if(n.icon && this.textureCache[n.icon]) {
                        obj.mesh.material.map = this.textureCache[n.icon];
                        obj.mesh.material.color.setHex(0xffffff);
                        obj.mesh.material.emissive.setHex(0x333333);
                    } else if (n.icon) {
                    } else {
                        obj.mesh.material.map = null;
                        obj.mesh.material.color.set(color);
                        obj.mesh.material.emissive.set(color);
                    }
                    obj.mesh.material.needsUpdate = true;
                }
            }

            updateScene() {
                const vs = this.app.visibleNodeIds;
                this.meshMap.forEach((o, id) => { if(!vs.has(id)) { this.scene.remove(o.mesh); this.scene.remove(o.label); this.meshMap.delete(id); } });
                const geo = new THREE.SphereGeometry(1, 32, 32);
                vs.forEach(id => {
                    const n = this.app.nodes.find(x=>x.id===id); const sz = this.app.config.nodeSize;
                    
                    const setupMat = (obj) => {
                        if(n.icon) { 
                            if(!this.textureCache[n.icon]) {
                                this.textureCache[n.icon] = this.texLoader.load(n.icon, (tex)=>{
                                    if(this.meshMap.has(id)) {
                                        const o = this.meshMap.get(id);
                                        o.mesh.material.map = tex; o.mesh.material.color.setHex(0xffffff); o.mesh.material.emissive.setHex(0x333333); o.mesh.material.needsUpdate = true;
                                    }
                                }, undefined, ()=>{
                                    if(this.meshMap.has(id)) {
                                        const o = this.meshMap.get(id);
                                        o.mesh.material.map = null; o.mesh.material.color.set(n.color); o.mesh.material.emissive.set(n.color); o.mesh.material.needsUpdate = true;
                                    }
                                });
                            }
                            if(this.textureCache[n.icon] && this.textureCache[n.icon].image) {
                                obj.mesh.material.map = this.textureCache[n.icon];
                                obj.mesh.material.color.setHex(0xffffff); 
                                obj.mesh.material.emissive.setHex(0x333333); 
                            } else {
                                obj.mesh.material.map = null;
                                obj.mesh.material.color.set(n.color); 
                                obj.mesh.material.emissive.set(n.color);
                            }
                        } else {
                            obj.mesh.material.map = null;
                            obj.mesh.material.color.set(n.color); 
                            obj.mesh.material.emissive.set(n.color);
                        }
                        obj.mesh.material.needsUpdate = true;
                    };

                    if(!this.meshMap.has(id)) {
                        const mat = new THREE.MeshPhongMaterial({ color: n.color, emissive: n.color, emissiveIntensity: 0.5, shininess: 30 });
                        const mesh = new THREE.Mesh(geo, mat); mesh.userData = { id: id, type: 'node' }; mesh.scale.setScalar(sz); 
                        mesh.frustumCulled = false; this.scene.add(mesh);
                        const label = this.createLabel(n.name, this.app.config.textSize, "#ffffff"); this.scene.add(label); 
                        const obj = {mesh, label};
                        this.meshMap.set(id, obj);
                        setupMat(obj);
                    } else {
                        const obj = this.meshMap.get(id); 
                        setupMat(obj);
                        obj.mesh.scale.setScalar(sz);
                    }
                });
                this.groupMeshMap.forEach((m, g) => { if(!this.app.collapsedGroups.has(g) || !this.app.groupCenters[g]) { this.scene.remove(m); this.groupMeshMap.delete(g); } });
                this.app.collapsedGroups.forEach(g => { if(!this.groupMeshMap.has(g)) { const m = new THREE.Mesh(new THREE.BoxGeometry(4,4,4), new THREE.MeshPhongMaterial({color:0xffaa00, wireframe:true, emissive:0xffaa00})); m.userData = { group: g, type: 'group' }; m.frustumCulled = false; const l = this.createLabel(`[G] ${g}`, this.app.config.textSize*1.2, '#ffcc00'); l.position.y=3; m.add(l); this.scene.add(m); this.groupMeshMap.set(g, m); } });
                this.linkObjects.forEach(l => { this.scene.remove(l.obj); if(l.sprite) this.scene.remove(l.sprite); }); this.linkObjects = [];
                const visArr = Array.from(vs); const gp = (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 sid = this.app.selectedNodeId;
                visArr.forEach(nid => {
                    const n1 = this.app.nodes.find(x=>x.id===nid); const p1 = gp(nid);
                    n1.links.forEach(tid => { const n2 = this.app.nodes.find(x=>x.id===tid); if(!n2 || (!vs.has(tid) && !this.app.collapsedGroups.has(n2.group))) return; const p2 = gp(tid); if(p1 !== p2) { const isSel = (n1.id === sid || n2.id === sid); 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 s = n1.tags.filter(t => n2.tags.includes(t)); if(s.length > 0) { const p1 = gp(n1.id); const p2 = gp(n2.id); if(p1!==p2) { const isSel = (n1.id === sid || n2.id === sid); this.createLink(p1, p2, this.app.getTagColor(s[0]), isSel?0.9:0.5, isSel, s[0]); } } } }
            }
            createLink(p1, p2, c, op, laz, txt) {
                let o; if (laz) { const d = new THREE.Vector3(p1.x,p1.y,p1.z).distanceTo(new THREE.Vector3(p2.x,p2.y,p2.z)); const g = new THREE.CylinderGeometry(0.3, 0.3, d, 8, 1, true); g.rotateX(Math.PI / 2); const m = new THREE.MeshBasicMaterial({ color: c, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending }); o = new THREE.Mesh(g, m); } 
                else { const pts = [new THREE.Vector3(p1.x,p1.y,p1.z), new THREE.Vector3(p2.x,p2.y,p2.z)]; const g = new THREE.BufferGeometry().setFromPoints(pts); o = new THREE.Line(g, new THREE.LineBasicMaterial({ color: c, transparent: true, opacity: op })); }
                o.frustumCulled = false; this.scene.add(o); let s = null; if(txt) { s = this.createLabel(txt, this.app.config.textSize*0.8, c); s.visible = false; this.scene.add(s); }
                this.linkObjects.push({ obj:o, sprite:s, p1, p2, isLaser:laz, labelText:txt });
            }
            createLabel(t, sm, c) { 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=c; ctx.textAlign="center"; ctx.fillText(t, 256, 90); const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map:new THREE.CanvasTexture(cvs), transparent:true, depthTest:false })); sp.scale.set(8 * sm, 2 * sm, 1); sp.renderOrder = 999; return sp; }
            sync() {
                if(!this.scene) return; const sz = this.app.config.nodeSize;
                this.meshMap.forEach((o, id) => { const n = this.app.nodes.find(x => x.id === id); if(n) { o.mesh.position.set(n.x, n.y, n.z); o.label.position.set(n.x, n.y + sz + 1.5, n.z); } });
                this.groupMeshMap.forEach((m, g) => { const c = this.app.groupCenters[g]; if(c) m.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(sz); } 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();
                
                // Camera Auto-Recovery (Fix Disappearing Bug)
                if(!Number.isFinite(this.camera.position.x) || !Number.isFinite(this.camera.position.y) || !Number.isFinite(this.camera.position.z)) {
                    this.resetCamera();
                }
            }
            updateHighlights() { const sid = this.app.selectedNodeId; const sn = sid ? this.app.nodes.find(x=>x.id===sid) : null; const bt = this.app.config.textSize; this.meshMap.forEach((o, id) => { if(id === sid) o.label.scale.set(10*bt, 2.5*bt, 1); else o.label.scale.set(7*bt, 1.75*bt, 1); }); this.linkObjects.forEach(l => { if(l.sprite) { let c = false; if(sn) { if(l.p1 === sn || l.p2 === sn) c = true; } if(c) { l.sprite.visible = true; l.sprite.scale.set(5*bt, 1.25*bt, 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 t = []; this.meshMap.forEach(v=>t.push(v.mesh)); this.groupMeshMap.forEach(v=>t.push(v)); const i = this.raycaster.intersectObjects(t);
                if(i.length > 0) { 
                    const d = i[0].object.userData; 
                    if(d.type === 'node') this.app.selectNode(d.id, false); // Single Click: Select Only
                    else if (d.type === 'group') this.app.toggleGroup(d.group); 
                } else this.app.selectNode(null);
            }
            onDblClick(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 t = []; this.meshMap.forEach(v=>t.push(v.mesh)); const i = this.raycaster.intersectObjects(t);
                if(i.length > 0) { 
                    const d = i[0].object.userData; 
                    if(d.type === 'node') {
                        this.app.selectNode(d.id, false);
                        this.focusSelection(); // DblClick: Focus!
                    }
                }
            }
            resetCamera() { 
                this.camera.position.set(0,30,60); 
                this.controls.target.set(0,0,0);
                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() {
                this.inputs.color.addEventListener('input', () => this.app.updateNodeProp(false));
                this.inputs.color.addEventListener('change', () => this.app.updateNodeProp(true));
                ['name','group','icon','tags','body'].forEach(k => { this.inputs[k].addEventListener('change', () => this.app.updateNodeProp(true)); });
                document.getElementById('icon-file-input').addEventListener('change', (e) => { const f = e.target.files[0]; if(!f) return; const r = new FileReader(); r.onload = (ev) => { this.inputs.icon.value = ev.target.result; this.app.updateNodeProp(true); }; r.readAsDataURL(f); });
                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.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; }
            updateListItemColor(id, color) { const el = document.getElementById('list-item-' + id); if(el) { el.style.borderLeftColor = color; const ball = el.querySelector('.node-ball'); if(ball) ball.style.color = color; } }
            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'; 
                    el.id = 'list-item-' + n.id;
                    if(n.id === this.app.selectedNodeId) el.classList.add('active'); el.style.borderLeftColor = n.color; el.innerHTML = genItem(n); 
                    el.onclick = () => this.app.selectNode(n.id, false); // Single Click: Select Only
                    el.ondblclick = () => this.app.selectNode(n.id, true); // DblClick: Focus!
                    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.style.borderLeftColor = n.color; el.innerHTML = genItem(n); 
                el.onclick = () => this.app.selectNode(n.id, false); el.ondblclick = () => this.app.selectNode(n.id, true); 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); }
            }
            openNoteEditor() { const n = this.app.nodes.find(x => x.id === this.app.selectedNodeId); if(n) { document.getElementById('note-editor-area').value = n.body; document.getElementById('note-modal').style.display = 'flex'; } }
            closeNoteEditor() { document.getElementById('note-modal').style.display = 'none'; }
            saveNoteEditor() { const val = document.getElementById('note-editor-area').value; document.getElementById('node-body').value = val; this.app.updateNodeProp(true); this.closeNoteEditor(); }
        }
        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のみ。\n【重要】アイコンURLは実在する画像のURLのみ。不明な場合は空欄 "" にしてください。`; 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>

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

ピックアップされています

便利なツール

  • 55本

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
小ぶりなプログラムを試しに作っているんですが、 ここではその説明書きをしていこうと思います。 こういう機能をつけてみてほしいだとかいった要望があれば コメント欄に書いてみて下さい。 ひまをみて対応します。
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