Todoの検索ツール「Visible Grid」


【更新履歴】

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


画像

使い方のヒント

  1. 画像の貼り付け:

    • ノードを選択し、右側の「Image URL」欄に画像のURL(例: https://imgur.com/example.jpg など)を入力してください。キューブがその画像に変わります。

    • 注意: CORS(セキュリティ制限)により、一部のサイトの画像は表示されない場合があります。

  2. 最短経路(Pathfinding):

    • ステップ1: ノードA(ゴールにしたいノード)を選択します。

    • ステップ2: 右パネルの「Set as Path Target」ボタンを押します(またはリストで右クリックしてSet Target)。

    • ステップ3: 別のノードB(スタート地点)を選択します。

    • 結果: AとBをつなぐ最短のルートが、赤い太線で表示されます。

  3. Markdown:

    • Content欄に **太字** や URL を入力すると、すぐ下のプレビュー欄で見やすく表示され、リンクもクリックできるようになります。

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

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

<!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