未来っぽいグラフ作成ツール「Neuro Linker」

【更新履歴】

・2026/2/13 バージョン1.0公開。
・2026/2/14 バージョン1.1公開。
・2026/2/15 バージョン1.2公開。(プロパティ、js部品化)

画像
3Dワークスペースの画面
画像
円グラフのプレビュー画面


画像
棒グラフのプレビュー画面
画像
折れ線グラフのプレビュー画面
画像
表のプレビュー画面



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

《ユーザーコード(Javascript)の書き方ガイド》

・エディタに記述できるコードの例です。
・F1キーを押してプロパティモードにしてから
 接続作業を行ってください。

1. データ生成(Source Node)

・input は使いません。値を return してください。

// 配列データを生成
const items = [
    { id: 1, name: "Alpha", val: 100 },
    { id: 2, name: "Beta",  val: 200 }
];
// デバッグ用にログ出力(トースト通知されます)
log.info("Generating data...");

return items;

2. プロパティの使用と加工(Process Node)

設定: プロパティに threshold を追加し、値を 0 にしておきます。
接続: 別の「閾値設定用ノード(Sourceなど)」のOutポートから、
このノードの threshold ポートへ点線のケーブルをつなぐと、
自動的に値が上書きされます。

// props.threshold には、手動入力値、または接続されたノードの値が入っています
const th = parseFloat(props.threshold) || 0;

// データ加工
const filtered = input.filter(item => item.val > th);

// 結果を返す
return filtered;

3. 非同期処理(Agent Node / Action Node)

・await が使えます。

// 2秒待機する例
log.info("Waiting...");
await new Promise(r => setTimeout(r, 2000));
log.info("Done!");

return input;


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

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Neuro Linker v1.2</title>
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
    <style>
        :root {
            --bg: #050505;
            --panel: rgba(12, 16, 20, 0.96);
            --accent: #00ffcc;
            --text: #e0f0ff;
            --border: #334455;
            --menu-bg: #0f1115;
            --sel-box: rgba(0, 255, 204, 0.2);
            --sel-border: #00ffcc;
            --danger: #ff4466;
            --warn: #ffcc00;
        }
        body { margin: 0; overflow: hidden; background: var(--bg); font-family: 'Segoe UI', 'Roboto', monospace; color: var(--text); user-select: none; }

        /* --- UI LAYER --- */
        #ui-layer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }
        .interactive { pointer-events: auto; }

        /* Menu Bar */
        #menubar {
            position: absolute; top: 0; left: 0; width: 100%; height: 32px;
            background: var(--menu-bg); border-bottom: 1px solid #333;
            display: flex; align-items: center; padding-left: 10px; pointer-events: auto; z-index: 1000;
            box-shadow: 0 2px 10px rgba(0,0,0,0.5);
        }
        .menu-item {
            padding: 0 16px; height: 100%; display: flex; align-items: center; cursor: pointer;
            font-size: 12px; color: #bbb; position: relative; letter-spacing: 0.5px;
        }
        .menu-item:hover { background: #333; color: var(--accent); }
        .submenu {
            position: absolute; top: 32px; left: 0; background: var(--panel);
            border: 1px solid var(--border); min-width: 220px; display: none; flex-direction: column;
            box-shadow: 0 5px 20px rgba(0,0,0,0.8); border-radius: 0 0 4px 4px;
        }
        .menu-item:hover .submenu { display: flex; }
        .sub-item {
            padding: 10px 20px; color: #ccc; cursor: pointer; transition: 0.1s; border-bottom: 1px solid rgba(255,255,255,0.05); font-size: 12px;
        }
        .sub-item:hover { background: rgba(0, 255, 204, 0.1); color: var(--accent); padding-left: 25px; }
        .ctx-sep { height: 1px; background: #333; margin: 4px 0; }

        /* Left Library Pane */
        #lib-pane {
            position: absolute; top: 33px; left: 0; width: 250px; bottom: 0;
            background: var(--panel); border-right: 1px solid var(--border);
            display: flex; flex-direction: column; transition: transform 0.3s ease; pointer-events: auto; z-index: 900;
            outline: none;
        }
        #lib-pane.collapsed { transform: translateX(-250px); }
        #lib-toggle {
            position: absolute; top: 50%; right: -15px; width: 15px; height: 50px;
            background: var(--border); border-radius: 0 5px 5px 0; cursor: pointer;
            display: flex; align-items: center; justify-content: center; font-size: 10px; color: #aaa;
        }
        .lib-tabs { display: flex; border-bottom: 1px solid var(--border); }
        .lib-tab { flex: 1; padding: 10px; text-align: center; cursor: pointer; font-size: 11px; background: #080a0c; color: #666; }
        .lib-tab.active { background: var(--panel); color: var(--accent); font-weight: bold; border-bottom: 2px solid var(--accent); }
        
        #lib-toolbar { padding: 5px; border-bottom: 1px solid #333; }
        .lib-btn { 
            width: 100%; padding: 8px; background: #222; border: 1px solid #444; color: #ccc; 
            font-size: 11px; cursor: pointer; text-align: center; border-radius: 3px; display: block; box-sizing: border-box;
        }
        .lib-btn:hover { border-color: var(--accent); color: var(--accent); background: rgba(0,255,204,0.1); }

        #lib-content { flex: 1; overflow-y: auto; padding: 5px; outline: none; border: 2px solid transparent; transition: border 0.2s; }
        .lib-item {
            padding: 8px; margin-bottom: 4px; background: #1a1d22; border: 1px solid #333; border-radius: 3px;
            font-size: 11px; cursor: pointer; display: flex; justify-content: space-between; align-items: center;
        }
        .lib-item:hover { border-color: #555; background: #252a30; }
        .lib-item.selected { background: var(--accent); color: #000; border-color: #fff; }
        .drop-zone-active { background: rgba(0, 255, 204, 0.1) !important; border-color: var(--accent) !important; }

        /* Mode Toolbar */
        #mode-bar {
            position: absolute; bottom: 30px; left: 50%; transform: translateX(-50%);
            display: flex; gap: 20px; background: rgba(10, 12, 16, 0.9); padding: 10px 20px;
            border: 1px solid #333; border-radius: 50px; backdrop-filter: blur(10px);
            align-items: center; box-shadow: 0 10px 30px rgba(0,0,0,0.5); pointer-events: auto; z-index: 1000;
        }
        .tool-group { display: flex; flex-direction: column; gap: 2px; align-items: center; }
        .group-label { font-size: 9px; color: #556677; text-transform: uppercase; letter-spacing: 1px; font-weight: bold; }
        .btn-row { display: flex; gap: 8px; }
        .mode-btn {
            background: transparent; border: 1px solid #444; color: #888;
            padding: 8px 16px; cursor: pointer; font-weight: bold; font-size: 11px;
            text-transform: uppercase; transition: 0.2s; border-radius: 20px; min-width: 70px; text-align: center;
        }
        .mode-btn:hover { color: #fff; border-color: #666; background: #222; }
        .mode-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(0,255,204,0.15); box-shadow: 0 0 15px rgba(0,255,204,0.2); }

        /* Selection Box */
        #selection-box {
            position: absolute; border: 1px solid var(--sel-border); background: var(--sel-box);
            display: none; pointer-events: none; z-index: 200;
        }

        /* Context Menu */
        .ctx-menu {
            position: absolute; display: none; flex-direction: column;
            background: var(--panel); border: 1px solid var(--accent);
            box-shadow: 0 0 25px rgba(0,255,204,0.1); min-width: 180px; z-index: 5000;
            border-radius: 4px; overflow: hidden; pointer-events: auto;
        }
        .ctx-item { padding: 10px 15px; cursor: pointer; font-size: 12px; color: var(--text); border-bottom: 1px solid rgba(255,255,255,0.05); }
        .ctx-item:hover { background: var(--accent); color: #000; }
        
        /* Toast */
        #toast-container { position: absolute; top: 50px; right: 20px; display: flex; flex-direction: column; gap: 10px; z-index: 9000; pointer-events: none; }
        .toast {
            background: rgba(20, 25, 30, 0.95); color: #fff; padding: 12px 20px; border-left: 4px solid var(--accent);
            box-shadow: 0 5px 15px rgba(0,0,0,0.5); border-radius: 2px; font-size: 12px;
            opacity: 0; transform: translateX(50px); transition: 0.3s; pointer-events: auto; display: flex; align-items: center; gap: 10px;
        }
        .toast.show { opacity: 1; transform: translateX(0); }
        .toast.warn { border-color: #ffcc00; } .toast.error { border-color: #ff4466; }

        /* --- ANIMATED DIALOGS --- */
        .modal-overlay {
            position: fixed; top: 0; left: 0; width: 100%; height: 100%;
            background: rgba(0,0,0,0.7); z-index: 2000; 
            display: none; justify-content: center; align-items: center;
            opacity: 0; pointer-events: none; visibility: hidden; transition: opacity 0.3s;
            perspective: 1500px;
        }
        .modal-overlay.active { display: flex; opacity: 1; pointer-events: auto; visibility: visible; }

        .dialog-box {
            width: 500px; max-width: 90%; background: #0a0f14; border: 1px solid #334455;
            box-shadow: 0 0 60px rgba(0,0,0,0.8); display: flex; flex-direction: column; border-radius: 6px;
            transform-style: preserve-3d; opacity: 0; transform-origin: center;
        }
        
        .modal-overlay.opening .dialog-box { animation: animBackflipOpen 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards; }
        .modal-overlay.closing .dialog-box { animation: animBackflipClose 0.4s cubic-bezier(0.6, -0.28, 0.735, 0.045) forwards; }

        @keyframes animBackflipOpen { 
            0% { transform: scale(0.5) rotateX(-90deg); opacity: 0; } 
            100% { transform: scale(1) rotateX(0deg); opacity: 1; } 
        }
        @keyframes animBackflipClose { 
            0% { transform: scale(1) rotateX(0deg); opacity: 1; } 
            100% { transform: scale(0.5) rotateX(90deg); opacity: 0; } 
        }

        .dlg-header {
            padding: 12px 20px; background: rgba(255,255,255,0.05); border-bottom: 1px solid #333; 
            color: var(--accent); font-weight: bold; font-size: 13px; display: flex; justify-content: space-between;
        }
        .dlg-body { padding: 20px; display: flex; flex-direction: column; gap: 15px; max-height: 70vh; overflow-y: auto; }
        .btn-bar { display: flex; justify-content: flex-end; gap: 10px; padding: 15px 20px; border-top: 1px solid #222; background: rgba(0,0,0,0.2); }
        
        input, textarea, select { 
            background: #111; border: 1px solid #444; color: #eee; padding: 10px; 
            font-family: monospace; width: 100%; box-sizing: border-box; font-size: 12px; border-radius: 4px;
        }
        input:focus, textarea:focus { outline: none; border-color: var(--accent); background: #151515; }

        .lbl-tag { 
            font-size: 9px; color: var(--accent); background: rgba(0, 255, 204, 0.08); 
            padding: 3px 8px; border-radius: 3px; display: inline-block; margin-bottom: 5px; letter-spacing: 1px;
            font-weight: bold; border: 1px solid rgba(0,255,204,0.2);
        }

        .btn { background: #222; color: #ccc; border: 1px solid #444; padding: 8px 18px; cursor: pointer; font-size: 11px; border-radius: 3px; }
        .btn:hover { border-color: var(--accent); color: var(--accent); }
        .btn-primary { border-color: var(--accent); color: var(--accent); }
        .btn-sm { padding: 4px 8px; font-size: 10px; }

        /* Preview Area */
        #preview-area { width: 100%; height: 450px; background: #080808; border: 1px solid #333; overflow: hidden; position: relative; border-radius: 4px; }
        table { width: 100%; border-collapse: collapse; font-size: 12px; }
        th, td { border: 1px solid #444; padding: 8px; text-align: left; }
        th { color: var(--accent); background: #111; }
        tr:nth-child(even) { background: rgba(255,255,255,0.02); }

        /* Prop Grid */
        .prop-row { display: grid; grid-template-columns: 100px 1fr 30px; gap: 10px; margin-bottom: 5px; align-items: center; }
        .prop-key { font-size: 11px; color: #888; text-align: right; }
    </style>
    <script type="importmap">
        { "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js", "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" } }
    </script>
</head>
<body>
    <div id="selection-box"></div>
    <div id="toast-container"></div>

    <div id="menubar">
        <div class="menu-item">
            PROJECT
            <div class="submenu">
                <div class="sub-item" onclick="proj.action('new')">New Project</div>
                <div class="sub-item" onclick="proj.action('open')">Open Project...</div>
                <div class="sub-item" onclick="proj.action('save')">Save</div>
                <div class="sub-item" onclick="proj.action('saveas')">Save As...</div>
                <div class="ctx-sep"></div>
                <div class="sub-item" onclick="proj.genProject('input')">✨ Gen AI (Input)</div>
                <div class="sub-item" onclick="proj.genProject('api')">🤖 Gen AI (API)</div>
                <div class="ctx-sep"></div>
                <div class="sub-item" onclick="app.executeAll()" style="color:var(--accent);">▶ Execute Workflow</div>
                <div class="ctx-sep"></div>
                <div class="sub-item" onclick="ui.openProjectProps()">Global Properties</div>
            </div>
        </div>
        <div class="menu-item">
            ADD OBJECT
            <div class="submenu">
                <div class="sub-item" onclick="app.addNodeAtCenter('source')">+ Data Source</div>
                <div class="sub-item" onclick="app.addNodeAtCenter('process')">+ Process (JS)</div>
                <div class="sub-item" onclick="app.addNodeAtCenter('agent')">+ AI Agent</div>
                <div class="ctx-sep"></div>
                <div class="sub-item" onclick="app.addNodeAtCenter('loop')">+ Loop (Iterator)</div>
                <div class="sub-item" onclick="app.addNodeAtCenter('gate')">+ Gate (Condition)</div>
                <div class="sub-item" onclick="app.addNodeAtCenter('delay')">+ Delay (Wait)</div>
                <div class="ctx-sep"></div>
                <div class="sub-item" onclick="app.addNodeAtCenter('action')">+ DOM Action (RPA)</div>
                <div class="ctx-sep"></div>
                <div class="sub-item" onclick="app.addNodeAtCenter('join')">+ Sync (Join)</div>
                <div class="sub-item" onclick="app.addNodeAtCenter('view')">+ View Result</div>
            </div>
        </div>
        <div class="menu-item" style="margin-left:auto; cursor:default; color:#666; font-weight:bold;">NEURO LINKER v1.3</div>
    </div>

    <div id="lib-pane" tabindex="0">
        <div id="lib-toggle" onclick="lib.togglePane()">◀</div>
        <div class="lib-tabs">
            <div class="lib-tab active" id="tab-obj" onclick="lib.setTab('object')">OBJECTS</div>
            <div class="lib-tab" id="tab-flow" onclick="lib.setTab('workflow')">WORKFLOWS</div>
        </div>
        <div id="lib-toolbar">
            <div class="lib-btn" onclick="ui.showLibSaveDialog()">+ Add to Library</div>
        </div>
        <div id="lib-content" ondragover="lib.handleDragOver(event)" ondragleave="lib.handleDragLeave(event)" ondrop="lib.handleDrop(event)">
            <div style="padding:20px; text-align:center; color:#555;">Drag selected nodes here to save.</div>
        </div>
    </div>

    <div id="ui-layer">
        <div class="status" id="status-text" style="position:absolute; top:45px; left:265px; color:#556677; font-size:10px;">READY</div>
        <div style="position:absolute; top:45px; right:20px; color:#556677; font-size:10px; text-align:right;">F1: Props Mode (Normal/Ground/Off) | F2: Icon Mode<br>PageUp/Down: Zoom | Home: Reset</div>
        <div id="mode-bar">
            <div class="tool-group">
                <div class="group-label">Control</div>
                <div class="btn-row">
                    <button class="mode-btn active" onclick="app.setMode('select')">Select</button>
                    <button class="mode-btn" onclick="app.setMode('move')">Move</button>
                    <button class="mode-btn" onclick="app.setMode('link')">Link</button>
                </div>
            </div>
            <div style="width:1px; height:30px; background:#333; margin:0 5px;"></div>
            <div class="tool-group">
                <div class="group-label">Camera</div>
                <div class="btn-row">
                    <button class="mode-btn" onclick="app.setMode('rotate')">Rotate</button>
                    <button class="mode-btn" onclick="app.setMode('pan')">Pan</button>
                    <button class="mode-btn" onclick="app.setMode('zoom')">Zoom</button>
                    <button class="mode-btn" onclick="app.focusSelection()">Focus</button>
                </div>
            </div>
        </div>
    </div>

    <div id="ctx-node" class="ctx-menu interactive">
        <div class="ctx-item" onclick="app.ctxAction(event, 'exec')">▶ Execute</div>
        <div class="ctx-sep"></div>
        <div class="ctx-item" id="ctx-preview" onclick="app.ctxAction(event, 'preview')" style="display:none; color:var(--warn); font-weight:bold;">👁 Preview Results</div>
        <div class="ctx-item" onclick="app.ctxAction(event, 'edit')">Open Editor</div>
        <div class="ctx-item" onclick="app.ctxAction(event, 'props')">⚙️ Properties (Args)</div>
        <div class="ctx-sep"></div>
        <div class="ctx-item" onclick="app.ctxAction(event, 'saveLib')">Save to Library</div>
        <div class="ctx-item" onclick="app.ctxAction(event, 'copy')">Copy (C)</div>
        <div class="ctx-item" onclick="app.ctxAction(event, 'delete')" style="color:var(--danger)">Delete (Del)</div>
    </div>
    
    <div id="ctx-global" class="ctx-menu interactive">
        <div class="ctx-item" onclick="app.addNodeAtCenter('process')">Add Process</div>
        <div class="ctx-item" onclick="app.addNodeAtCenter('agent')">Add AI Agent</div>
        <div class="ctx-item" onclick="app.pasteAtCursor()">Paste (V)</div>
    </div>

    <div id="editor-dialog" class="modal-overlay">
        <div class="dialog-box interactive" style="width: 800px;">
            <div class="dlg-header"><span id="editor-title">EDITOR</span><button style="background:none; border:none; color:#555; cursor:pointer;" onclick="ui.closeDialog('editor-dialog')">✕</button></div>
            <div class="dlg-body">
                <div style="display:flex; gap:10px; margin-bottom:5px;">
                    <button class="btn" onclick="ui.editorAction('load-file')">📂 Load</button>
                    <button class="btn" onclick="ui.editorAction('save-file')">💾 Save</button>
                    <div style="width:1px; background:#444; margin:0 5px;"></div>
                    <button class="btn" onclick="ui.editorAction('ai-input')">✨ AI (Input)</button>
                    <button class="btn" onclick="ui.editorAction('ai-api')">🤖 AI (API)</button>
                </div>
                <div style="display:grid; grid-template-columns: 100px 1fr 100px 1fr; gap:10px; margin: 10px 0; align-items:center;">
                    <div class="lbl-tag" style="margin:0;">OBJECT TYPE</div>
                    <select id="editor-icon-type">
                        <option value="source">Data Source</option>
                        <option value="process">Process</option>
                        <option value="agent">Agent</option>
                        <option value="join">Join</option>
                        <option value="action">Action</option>
                        <option value="view">View</option>
                        <option value="loop">Loop</option>
                        <option value="gate">Gate</option>
                        <option value="delay">Delay</option>
                    </select>
                    <div class="lbl-tag" style="margin:0;">ICON (EMOJI)</div>
                    <input type="text" id="editor-icon-text" placeholder="e.g. 📂, 🚀" style="font-size:16px;">
                </div>
                <div class="lbl-tag">CONTENT (JAVASCRIPT)</div>
                <textarea id="editor-content" style="height:350px; font-size:13px; line-height:1.5; color:#aaffff; background:#080a0c; font-family:'Consolas', monospace;" placeholder="// Write your JS here. Return value is passed to next node.&#10;// Available: input (prev data), props (args), lib (THREE)&#10;return input;"></textarea>
            </div>
            <div class="btn-bar">
                <button class="btn" onclick="ui.closeDialog('editor-dialog')">Cancel</button>
                <button class="btn btn-primary" onclick="ui.saveEditor()">Apply</button>
            </div>
        </div>
    </div>

    <div id="node-prop-dialog" class="modal-overlay">
        <div class="dialog-box interactive" style="width: 500px;">
            <div class="dlg-header"><span>NODE PROPERTIES</span><button onclick="ui.closeDialog('node-prop-dialog')">✕</button></div>
            <div class="dlg-body">
                <div id="node-prop-list"></div>
                <button class="btn btn-sm" style="width:100%" onclick="ui.addNodeProp()">+ Add Property</button>
            </div>
            <div class="btn-bar">
                <button class="btn btn-primary" onclick="ui.saveNodeProps()">Save Properties</button>
            </div>
        </div>
    </div>

    <div id="prop-dialog" class="modal-overlay">
        <div class="dialog-box interactive">
            <div class="dlg-header"><span>GLOBAL PROPERTIES</span><button onclick="ui.closeDialog('prop-dialog')">✕</button></div>
            <div class="dlg-body"><div><div class="lbl-tag">API KEY</div><input id="prop-ai-key" type="password"></div></div>
            <div class="btn-bar"><button class="btn btn-primary" onclick="ui.saveProjectProps()">Save</button></div>
        </div>
    </div>

    <div id="preview-dialog" class="modal-overlay">
        <div class="dialog-box interactive" style="width:900px;">
            <div class="dlg-header"><span>RESULT PREVIEW</span><button onclick="ui.closeDialog('preview-dialog')">✕</button></div>
            <div class="dlg-body">
                <div style="display:flex; gap:10px; margin-bottom:5px;">
                    <button class="btn" onclick="ui.setPreviewMode('bar')">📊 Bar</button>
                    <button class="btn" onclick="ui.setPreviewMode('line')">📈 Line</button>
                    <button class="btn" onclick="ui.setPreviewMode('pie')">🥧 Pie</button>
                    <button class="btn" onclick="ui.setPreviewMode('table')">📋 Table</button>
                    <button class="btn" onclick="ui.setPreviewMode('text')">📝 Text</button>
                </div>
                <div id="preview-area"></div>
            </div>
            <div class="btn-bar"><button class="btn" onclick="ui.closeDialog('preview-dialog')">Close</button></div>
        </div>
    </div>

    <div id="input-dialog" class="modal-overlay">
        <div class="dialog-box interactive" style="width:400px;">
            <div class="dlg-header"><span>INPUT REQUIRED</span><button onclick="ui.closeDialog('input-dialog')">✕</button></div>
            <div class="dlg-body">
                <div id="input-msg" style="color:#ccc;">Enter value:</div>
                <input id="input-val" type="text" autocomplete="off">
            </div>
            <div class="btn-bar">
                <button class="btn" onclick="ui.closeDialog('input-dialog')">Cancel</button>
                <button class="btn btn-primary" id="input-ok-btn">OK</button>
            </div>
        </div>
    </div>

    <div id="ai-manual-dialog" class="modal-overlay">
        <div class="dialog-box interactive" style="width:650px;">
            <div class="dlg-header"><span>AI GENERATION (NODE)</span><button style="background:none; border:none; color:#555; cursor:pointer;" onclick="ui.closeDialog('ai-manual-dialog')">✕</button></div>
            <div class="dlg-body">
                <div class="lbl-tag">1. DESCRIBE GOAL</div>
                <div style="display:flex; gap:10px;">
                    <input type="text" id="ai-goal-input" placeholder="e.g. Filter rows where value > 100">
                    <button class="btn" onclick="ui.genPrompt()">Generate Prompt</button>
                </div>
                <div class="lbl-tag" style="margin-top:10px;">2. COPY & SEND TO AI</div>
                <div style="display:flex; gap:10px;">
                    <textarea id="ai-prompt-output" style="height:70px;" readonly></textarea>
                    <button class="btn" onclick="ui.copyPrompt('ai-prompt-output')">Copy</button>
                </div>
                <div class="lbl-tag" style="margin-top:10px;">3. PASTE CODE</div>
                <textarea id="ai-code-input" style="height:120px;" placeholder="Paste code here..."></textarea>
                <div style="display:flex; justify-content:flex-end;">
                    <button class="btn" onclick="ui.pasteTo('ai-code-input')">Paste</button>
                </div>
            </div>
            <div class="btn-bar">
                <button class="btn" onclick="ui.closeDialog('ai-manual-dialog')">Cancel</button>
                <button class="btn btn-primary" onclick="ui.applyAICode()">Apply</button>
            </div>
        </div>
    </div>

    <div id="ai-proj-dialog" class="modal-overlay">
        <div class="dialog-box interactive" style="width:700px;">
            <div class="dlg-header"><span>PROJECT GENERATION (AI)</span><button style="background:none; border:none; color:#555; cursor:pointer;" onclick="ui.closeDialog('ai-proj-dialog')">✕</button></div>
            <div class="dlg-body">
                <div class="lbl-tag">1. DESCRIBE THE SYSTEM</div>
                <div style="display:flex; gap:10px;">
                    <input type="text" id="ai-proj-input" placeholder="e.g. Load CSV data, filter by Year > 2023, then show Chart">
                    <button class="btn" onclick="proj.genPrompt()">Generate Prompt</button>
                </div>
                
                <div id="ai-proj-manual-section">
                    <div class="lbl-tag" style="margin-top:10px;">2. COPY PROMPT</div>
                    <div style="display:flex; gap:10px;">
                        <textarea id="ai-proj-prompt-out" style="height:140px; font-size:11px;" readonly></textarea>
                        <button class="btn" onclick="ui.copyPrompt('ai-proj-prompt-out')">Copy</button>
                    </div>
                    
                    <div class="lbl-tag" style="margin-top:10px;">3. PASTE JSON RESULT</div>
                    <textarea id="ai-proj-json-in" style="height:140px;" placeholder="Paste JSON here..."></textarea>
                    <div style="display:flex; justify-content:flex-end;">
                        <button class="btn" onclick="ui.pasteTo('ai-proj-json-in')">Paste</button>
                    </div>
                </div>
            </div>
            <div class="btn-bar">
                <button class="btn" onclick="ui.closeDialog('ai-proj-dialog')">Cancel</button>
                <button class="btn btn-primary" onclick="proj.applyGen()">Generate Project</button>
            </div>
        </div>
    </div>

    <script type="module">
        import * as THREE from 'three';
        import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
        import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
        import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
        import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
        import { Line2 } from 'three/addons/lines/Line2.js';
        import { LineMaterial } from 'three/addons/lines/LineMaterial.js';
        import { LineGeometry } from 'three/addons/lines/LineGeometry.js';
        import TWEEN from 'three/addons/libs/tween.module.js';

        const toast = {
            show: (msg, type='info') => {
                const c = document.getElementById('toast-container');
                const t = document.createElement('div');
                t.className = `toast ${type}`;
                t.innerHTML = `<span style="font-size:16px">${type=='error'?'❌':type=='warn'?'⚠️':'ℹ️'}</span> ${msg}`;
                c.appendChild(t);
                requestAnimationFrame(()=>t.classList.add('show'));
                setTimeout(()=> { t.classList.remove('show'); setTimeout(()=>t.remove(), 300); }, 3000);
            },
            info: (m) => toast.show(m, 'info'),
            warn: (m) => toast.show(m, 'warn'),
            error: (m) => toast.show(m, 'error')
        };
        window.toast = toast;

        const state = {
            nodes: [], links: [], cables: [], implicitLinks: [],
            mode: 'select', selectedNodes: [], 
            dragNode: null, linkStartNode: null, linkStartPort: null,
            cursorPos: new THREE.Vector3(), projectHandle: null,
            clipboard: null,
            boxSel: { start: new THREE.Vector2(), active: false },
            nextNodeId: 1, executionId: 0,
            projectSettings: { aiKey: "" },
            inputCallback: null,
            genMode: 'input',
            // view.propsMode: 0=Off, 1=Normal, 2=Ground
            view: { propsMode: 0, large: false }, 
            homeState: { pos: new THREE.Vector3(0, 40, 60), target: new THREE.Vector3(0,0,0) }
        };

        const COLORS = { source: 0x00ffff, process: 0xffaa00, agent: 0xaa00ff, join: 0xff0055, action: 0xff8800, loop: 0xffee00, gate: 0xffee00, delay: 0x888888, view: 0x00ff00 };
        const DEFAULT_ICONS = { source: '📄', process: '⚙️', agent: '🤖', join: '🔗', action: '⚡', view: '👁️', loop: '🔄', gate: '❓', delay: '⏳' };

        const scene = new THREE.Scene(); scene.fog = new THREE.FogExp2(0x020202, 0.0025);
        const camera = new THREE.PerspectiveCamera(50, window.innerWidth/window.innerHeight, 0.1, 2000); 
        camera.position.copy(state.homeState.pos);
        
        const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); 
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.toneMapping = THREE.ReinhardToneMapping;
        document.body.appendChild(renderer.domElement);
        renderer.domElement.style.touchAction = 'none';

        const composer = new EffectComposer(renderer); 
        composer.addPass(new RenderPass(scene, camera));
        const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 1.5, 0.5, 1.1); 
        composer.addPass(bloomPass);
        
        const controls = new OrbitControls(camera, renderer.domElement); 
        controls.enableDamping = true; controls.enabled = false;

        scene.add(new THREE.GridHelper(200, 50, 0x222222, 0x111111)); scene.add(new THREE.AmbientLight(0xffffff, 0.3));
        const dl = new THREE.DirectionalLight(0xffffff, 0.8); dl.position.set(20,50,20); scene.add(dl);
        const raycaster = new THREE.Raycaster(); const mouse = new THREE.Vector2(); const plane = new THREE.Plane(new THREE.Vector3(0,1,0), 0);

        class SpatialNode {
            constructor(type, pos, id=null) {
                this.id = id || state.nextNodeId++;
                this.type = type;
                this.name = `${type.toUpperCase()}_${this.id}`;
                this.content = "// Default Content";
                this.props = {}; 
                this.icon = DEFAULT_ICONS[type] || '📦';
                this.inputs = []; this.outputs = []; 
                this.group = new THREE.Group(); this.group.position.copy(pos); 
                this.group.userData = { isNode: true, obj: this };
                this.resultData = null;
                this.lastClick = 0;
                
                this.stripGroup = new THREE.Group(); this.group.add(this.stripGroup);
                this.largeIconGroup = new THREE.Group(); this.largeIconGroup.visible = false; this.group.add(this.largeIconGroup);
                this.panelGroup = new THREE.Group(); this.panelGroup.visible = false; this.group.add(this.panelGroup);
                
                const ringGeo = new THREE.RingGeometry(3.5, 3.8, 32); ringGeo.rotateX(-Math.PI/2);
                this.ring = new THREE.Mesh(ringGeo, new THREE.MeshBasicMaterial({ color: 0xffff00, transparent:true, opacity:0 }));
                this.ring.position.y = 0.1;
                this.group.add(this.ring);

                this.buildVisuals();
                this.ports = []; 
                scene.add(this.group);
                state.nodes.push(this);
                if(this.id >= state.nextNodeId) state.nextNodeId = this.id + 1;
            }

            createLabelCanvas(text, size=32) {
                const w=256, h=64;
                const cvs = document.createElement('canvas'); cvs.width = w; cvs.height = h; const ctx = cvs.getContext('2d');
                ctx.font = `bold ${size}px Arial`; ctx.fillStyle = "#ffffff"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
                ctx.shadowColor="black"; ctx.shadowBlur=4; ctx.fillText(text, w/2, h/2);
                return cvs;
            }

            buildVisuals() {
                this.buildStrip();
                this.buildLargeIcon();
                if(state.view.propsMode > 0) this.buildPanel();
                this.updateVisibility();
            }

            buildStrip() {
                while(this.stripGroup.children.length > 0) this.stripGroup.remove(this.stripGroup.children[0]);
                const cvs = document.createElement('canvas'); cvs.width = 256; cvs.height = 64; const ctx = cvs.getContext('2d');
                ctx.fillStyle = "rgba(0,0,0,0.6)"; ctx.roundRect(10, 10, 236, 44, 10); ctx.fill();
                ctx.strokeStyle = '#'+COLORS[this.type].toString(16).padStart(6,'0'); ctx.lineWidth = 4; ctx.stroke();
                ctx.font = 'bold 32px Arial'; ctx.fillStyle = "#ffffff"; ctx.textAlign = 'center'; ctx.fillText(this.name, 128, 42);
                const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(cvs), toneMapped: false }));
                sp.scale.set(8, 2, 1); sp.position.y = 2;
                this.stripGroup.add(sp);
                const dot = new THREE.Mesh(new THREE.BoxGeometry(1,1,1), new THREE.MeshStandardMaterial({ color:COLORS[this.type], emissive: COLORS[this.type], emissiveIntensity: 2.0 }));
                dot.position.y = 0.5;
                this.stripGroup.add(dot);
            }

            buildLargeIcon() {
                while(this.largeIconGroup.children.length > 0) this.largeIconGroup.remove(this.largeIconGroup.children[0]);
                const cvs = this.createLabelCanvas(this.name);
                const labelSp = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(cvs), toneMapped: false }));
                labelSp.scale.set(8, 2, 1); labelSp.position.y = 6.5;
                this.largeIconGroup.add(labelSp);
                if(this.icon && this.icon.trim() !== '') {
                    const iCvs = document.createElement('canvas'); iCvs.width = 128; iCvs.height = 128; const ctx = iCvs.getContext('2d');
                    ctx.font = '100px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(this.icon, 64, 64);
                    const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(iCvs), toneMapped: false }));
                    sp.scale.set(6, 6, 1); sp.position.y = 3;
                    this.largeIconGroup.add(sp);
                } else {
                    let geo, col = COLORS[this.type] || 0xffffff;
                    if(this.type==='source') geo = new THREE.CylinderGeometry(2,2,3,16); 
                    else if(this.type==='agent') geo = new THREE.IcosahedronGeometry(2.5, 0); 
                    else if(this.type==='action') geo = new THREE.DodecahedronGeometry(2.5, 0);
                    else if(this.type==='loop') geo = new THREE.TorusGeometry(2, 0.8, 8, 20);
                    else geo = new THREE.BoxGeometry(3,3,3);
                    const mat = new THREE.MeshStandardMaterial({ color: col, emissive: col, emissiveIntensity: 3.0 });
                    const mesh = new THREE.Mesh(geo, mat); mesh.position.y = 3;
                    this.largeIconGroup.add(mesh);
                }
            }

            buildPanel() {
                while(this.panelGroup.children.length > 0) this.panelGroup.remove(this.panelGroup.children[0]);
                this.ports = [];
                const w = 14, h = 10;
                
                const isGround = state.view.propsMode === 2;
                
                if (isGround) {
                    this.panelGroup.rotation.x = -Math.PI / 2;
                    this.panelGroup.position.set(0, 0.2, 0); 
                } else {
                    this.panelGroup.rotation.x = 0;
                    this.panelGroup.position.set(0, 0, 0);
                }
                
                const yOff = isGround ? 0 : -5;
                const zOff = isGround ? 0 : -1;

                const bg = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({ color: 0x0a0f14, side: THREE.DoubleSide, transparent:true, opacity:0.9 }));
                bg.position.set(0, yOff, zOff);
                bg.userData = { isNode: true, obj: this };
                
                const border = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.PlaneGeometry(w, h)), new THREE.LineBasicMaterial({ color: new THREE.Color(COLORS[this.type]).multiplyScalar(2) }));
                border.position.set(0, yOff, zOff);
                
                this.panelGroup.add(bg); this.panelGroup.add(border);

                const cvs = document.createElement('canvas'); cvs.width = 512; cvs.height = 360; const ctx = cvs.getContext('2d');
                ctx.fillStyle = COLORS[this.type].getStyle ? COLORS[this.type].getStyle() : '#fff'; 
                ctx.font = "bold 40px Arial"; ctx.fillText(this.name, 20, 50);
                
                const portMat = new THREE.MeshBasicMaterial({ color: 0xcccccc });
                const addPort = (name, x, y, type, key=null) => {
                    const mesh = new THREE.Mesh(new THREE.SphereGeometry(0.5), portMat);
                    mesh.position.set(x, yOff + y, zOff + 0.1); 
                    mesh.userData = { isPort: true, node: this, type: type, key: key };
                    this.panelGroup.add(mesh);
                    this.ports.push(mesh);
                    
                    ctx.font = "24px Arial"; ctx.fillStyle = "#ccc";
                    const relY = 5 - y; 
                    const cvsY = (relY / 10) * 360;
                    if(x < 0) { ctx.textAlign = "left"; ctx.fillText(name, 40, cvsY); } 
                    else { ctx.textAlign = "right"; ctx.fillText(name, 470, cvsY); }
                };

                addPort("In", -w/2, 4, 'in');
                addPort("Out", w/2, 4, 'out');
                let py = 2;
                Object.keys(this.props).forEach(k => { addPort(k, -w/2, py, 'prop', k); py -= 1.5; });

                const txtPlane = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({ map: new THREE.CanvasTexture(cvs), transparent: true, toneMapped: false }));
                txtPlane.position.set(0, yOff, zOff + 0.05);
                this.panelGroup.add(txtPlane);
            }

            updateVisibility() {
                const pMode = state.view.propsMode;
                if (pMode > 0) { 
                    this.panelGroup.visible = true;
                    this.stripGroup.visible = false;
                    this.largeIconGroup.visible = false;
                    this.buildPanel(); 
                }
                else if (state.view.large) { 
                    this.panelGroup.visible = false;
                    this.stripGroup.visible = false;
                    this.largeIconGroup.visible = true;
                }
                else { 
                    this.panelGroup.visible = false;
                    this.stripGroup.visible = true;
                    this.largeIconGroup.visible = false;
                }
                app.updateLinks();
            }

            getSurfacePoint(targetPos) {
                let min = new THREE.Vector3(), max = new THREE.Vector3();
                if (state.view.large) {
                    min.set(-4, 0, -1); max.set(4, 8, 1);
                } else {
                    min.set(-4, 1, -1); max.set(4, 3, 1);
                }
                const localTarget = targetPos.clone().sub(this.group.position);
                const boxCenter = min.clone().add(max).multiplyScalar(0.5);
                const dir = localTarget.clone().sub(boxCenter).normalize();

                const invDir = new THREE.Vector3(1/dir.x, 1/dir.y, 1/dir.z);
                const t1 = (min.x - boxCenter.x) * invDir.x;
                const t2 = (max.x - boxCenter.x) * invDir.x;
                const t3 = (min.y - boxCenter.y) * invDir.y;
                const t4 = (max.y - boxCenter.y) * invDir.y;
                const t5 = (min.z - boxCenter.z) * invDir.z;
                const t6 = (max.z - boxCenter.z) * invDir.z;
                const tmax = Math.min(Math.min(Math.max(t1, t2), Math.max(t3, t4)), Math.max(t5, t6));
                
                if (tmax < 0) return this.group.position.clone().add(boxCenter);
                return this.group.position.clone().add(boxCenter).add(dir.multiplyScalar(tmax));
            }

            setSelected(bool) { this.ring.material.opacity = bool ? 1 : 0; }
            serialize() { 
                return { 
                    id: this.id, type: this.type, name: this.name, icon: this.icon,
                    x: this.group.position.x, y: this.group.position.y, z: this.group.position.z, 
                    content: this.content, props: this.props
                }; 
            }

            // --- EXECUTION LOGIC (IMPLEMENTED) ---
            async execute(inputData = null) { 
                this.flash();
                toast.info(`Exec: ${this.name}`);
                
                // 1. Resolve Props from Cables
                // This allows parameters to be passed dynamically from other nodes
                const runtimeProps = { ...this.props };
                
                state.cables.forEach(cable => {
                    if (cable.dst.node === this && cable.dst.key) {
                        const srcNode = cable.src.node;
                        let val = null;
                        
                        // If connected from an 'out' port, take the resultData
                        // If connected from a 'prop' port, take that specific prop
                        if (cable.src.type === 'out') {
                            val = srcNode.resultData;
                            // If resultData is a complex object, we might want to be more specific, 
                            // but for now, we pass the whole result.
                            // If it's an array of single value, unwrap it if it looks like a scalar
                            if(Array.isArray(val) && val.length === 1 && typeof val[0] !== 'object') {
                                val = val[0];
                            }
                        } else if (cable.src.type === 'prop') {
                            val = srcNode.props[cable.src.key];
                        }
                        
                        // Numeric conversion attempt if the target expects a number
                        if(val !== undefined && val !== null) {
                            const currentVal = runtimeProps[cable.dst.key];
                            if(!isNaN(parseFloat(currentVal)) && !isNaN(parseFloat(val))) {
                                val = parseFloat(val);
                            }
                            runtimeProps[cable.dst.key] = val;
                        }
                    }
                });

                // 2. Execute Internal Logic (User JS)
                let output = inputData;
                
                // If content is just comments or empty, pass through input
                const hasCode = this.content && this.content.replace(/\/\/.*/g, '').trim().length > 0;
                
                if (hasCode) {
                    try {
                        // Create a secure-ish function. 
                        // We provide 'input', 'props', 'three' (lib), and 'log' as arguments.
                        const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
                        const userFunc = new AsyncFunction('input', 'props', 'lib', 'log', `
                            try {
                                ${this.content}
                            } catch(err) {
                                throw err;
                            }
                        `);
                        
                        const logProxy = {
                            log: (m) => console.log(`[${this.name}]`, m),
                            info: (m) => toast.info(`${this.name}: ${m}`),
                            warn: (m) => toast.warn(`${this.name}: ${m}`),
                            error: (m) => toast.error(`${this.name}: ${m}`)
                        };

                        const result = await userFunc(inputData, runtimeProps, THREE, logProxy);
                        
                        if (result !== undefined) {
                            output = result;
                        }
                    } catch (e) {
                        toast.error(`Error in ${this.name}: ${e.message}`);
                        console.error(e);
                        return; // Stop flow on error
                    }
                }

                this.resultData = Array.isArray(output) ? output : [output];
                
                // Auto-show view nodes
                if (this.type === 'view') {
                    ui.openPreview(this);
                }

                // 3. Propagate to Next Nodes
                // We use setTimeout to decouple the stack slightly and allow UI updates
                this.outputs.forEach(l => { 
                    l.pulse(); 
                    setTimeout(() => l.to.execute(output), 500); 
                }); 
            }

            flash() { 
                const target = state.view.large ? this.largeIconGroup : this.stripGroup;
                const s = target.scale.clone();
                new TWEEN.Tween(target.scale).to({x:s.x*1.2, y:s.y*1.2, z:s.z*1.2}, 100).yoyo(true).repeat(1).start();
            }
        }

        class LaserLink {
            constructor(n1, n2, listToAddTo = null) {
                this.from = n1; this.to = n2;
                if (listToAddTo !== state.implicitLinks) { 
                    n1.outputs.push(this); n2.inputs.push(this);
                }
                const geo = new THREE.CylinderGeometry(0.2, 0.2, 1, 8, 1, true); geo.translate(0, 0.5, 0); geo.rotateX(Math.PI/2);
                const mat = new THREE.MeshBasicMaterial({ color: new THREE.Color(2, 2, 2), transparent: true, opacity: 0.6 });
                this.mesh = new THREE.Mesh(geo, mat);
                const coneGeo = new THREE.ConeGeometry(0.6, 1.5, 8); coneGeo.rotateX(Math.PI/2);
                this.arrow = new THREE.Mesh(coneGeo, new THREE.MeshBasicMaterial({ color: new THREE.Color(2,2,2) }));
                this.packet = new THREE.Mesh(new THREE.SphereGeometry(0.4), new THREE.MeshBasicMaterial({color:0xffffff})); this.packet.visible=false;
                scene.add(this.mesh); scene.add(this.arrow); scene.add(this.packet);
                const targetList = listToAddTo || state.links;
                targetList.push(this); 
                this.update();
            }
            update() {
                if(!this.from || !this.to) return;
                const visible = (state.view.propsMode === 0);
                this.mesh.visible = visible;
                this.arrow.visible = visible;
                if(!visible) return;

                let startPos = this.from.getSurfacePoint(this.to.group.position);
                let endPos = this.to.getSurfacePoint(this.from.group.position);
                this.mesh.position.copy(startPos); 
                this.mesh.lookAt(endPos); 
                const dist = startPos.distanceTo(endPos);
                this.mesh.scale.set(1, 1, dist); 
                this.arrow.position.copy(endPos);
                this.arrow.lookAt(startPos); 
            }
            pulse() { 
                if(!this.mesh.visible) return;
                this.packet.visible=true; let t={v:0}; 
                let startPos = this.from.getSurfacePoint(this.to.group.position);
                let endPos = this.to.getSurfacePoint(this.from.group.position);
                new TWEEN.Tween(t).to({v:1}, 500).onUpdate(()=>{
                    this.packet.position.lerpVectors(startPos, endPos, t.v);
                }).onComplete(()=>this.packet.visible=false).start(); 
            }
            dispose() { 
                scene.remove(this.mesh); scene.remove(this.arrow); scene.remove(this.packet); 
                this.mesh.geometry.dispose(); this.arrow.geometry.dispose();
                if (state.links.includes(this)) {
                    this.from.outputs = this.from.outputs.filter(l=>l!==this);
                    this.to.inputs = this.to.inputs.filter(l=>l!==this);
                }
            }
        }

        class ConnectionCable {
            constructor(port1, port2) {
                // Store metadata to allow dynamic resolution
                this.src = { node: port1.userData.node, type: port1.userData.type, key: port1.userData.key };
                this.dst = { node: port2.userData.node, type: port2.userData.type, key: port2.userData.key };
                
                const geometry = new LineGeometry();
                const material = new LineMaterial({ color: 0x888888, linewidth: 2, dashed: true });
                material.resolution.set(window.innerWidth, window.innerHeight);
                this.mesh = new Line2(geometry, material);
                scene.add(this.mesh);
                state.cables.push(this);
                this.update();
            }
            update() {
                // Ensure visibility rule: only show when propsMode > 0
                const visible = (state.view.propsMode > 0);
                
                if (!visible) {
                    this.mesh.visible = false;
                    return;
                }

                // Dynamically resolve ports from the node's current ports list
                const p1 = this.src.node.ports.find(p => p.userData.type === this.src.type && p.userData.key === this.src.key);
                const p2 = this.dst.node.ports.find(p => p.userData.type === this.dst.type && p.userData.key === this.dst.key);

                // If ports are missing (e.g. node deleted, or panel not ready), hide cable
                if (!p1 || !p2) {
                    this.mesh.visible = false;
                    return;
                }

                this.mesh.visible = true;
                
                const v1 = new THREE.Vector3(); p1.getWorldPosition(v1);
                const v2 = new THREE.Vector3(); p2.getWorldPosition(v2);
                
                const d = v1.distanceTo(v2) * 0.4;
                let cp1 = v1.clone().add(new THREE.Vector3(-d, 0, 0));
                let cp2 = v2.clone().add(new THREE.Vector3(d, 0, 0));
                if(p1.position.x > 0) cp1 = v1.clone().add(new THREE.Vector3(d, 0, 0));
                if(p2.position.x < 0) cp2 = v2.clone().add(new THREE.Vector3(-d, 0, 0));
                
                const curve = new THREE.CubicBezierCurve3(v1, cp1, cp2, v2);
                const positions = []; curve.getPoints(20).forEach(p => positions.push(p.x, p.y, p.z));
                this.mesh.geometry.setPositions(positions);
            }
            dispose() {
                scene.remove(this.mesh);
                this.mesh.geometry.dispose(); this.mesh.material.dispose();
                state.cables = state.cables.filter(c => c !== this);
            }
        }

        const lib = {
            currentTab: 'object', selectedItemName: null,
            togglePane: () => { document.getElementById('lib-pane').classList.toggle('collapsed'); },
            setTab: (t) => { lib.currentTab = t; document.querySelectorAll('.lib-tab').forEach(e => e.classList.remove('active')); document.getElementById(t === 'object' ? 'tab-obj' : 'tab-flow').classList.add('active'); lib.refresh(); },
            async getDirHandle(name) { if(!state.projectHandle) return null; return await state.projectHandle.getDirectoryHandle(name, {create:true}); },
            async refresh() {
                const list = document.getElementById('lib-content'); list.innerHTML = ""; lib.selectedItemName = null;
                if(!state.projectHandle) { list.innerHTML = "<div style='padding:20px; text-align:center;'>Open a Project to use Library.</div>"; return; }
                const dirName = lib.currentTab === 'object' ? 'ObjectLib' : 'WorkFlowLib';
                try {
                    const dir = await lib.getDirHandle(dirName);
                    for await (const [name, handle] of dir.entries()) {
                        if(name.endsWith('.json')) {
                            const el = document.createElement('div'); el.className = 'lib-item'; el.draggable = true;
                            el.innerHTML = `<span>${name.replace('.json','')}</span>`;
                            el.onclick = () => lib.selectItem(el, name);
                            el.ondblclick = () => { lib.selectItem(el, name); lib.pasteSelectedItem(true); };
                            el.ondragstart = (e) => { 
                                e.dataTransfer.setData('application/json', JSON.stringify({type: 'lib-load', file: name, tab: lib.currentTab})); 
                                e.dataTransfer.effectAllowed = 'copy'; 
                            };
                            list.appendChild(el);
                        }
                    }
                } catch(e) { console.error(e); }
            },
            selectItem: (el, name) => { document.querySelectorAll('.lib-item').forEach(e=>e.classList.remove('selected')); el.classList.add('selected'); lib.selectedItemName = name; },
            getConnectedGraph(startNodes) {
                const visitedNodes = new Set(); const queue = [...startNodes];
                while(queue.length > 0) {
                    const n = queue.shift(); if(visitedNodes.has(n)) continue; visitedNodes.add(n);
                    state.links.forEach(l => { if(l.from === n && !visitedNodes.has(l.to)) queue.push(l.to); if(l.to === n && !visitedNodes.has(l.from)) queue.push(l.from); });
                }
                return { nodes: Array.from(visitedNodes), links: state.links.filter(l => visitedNodes.has(l.from) && visitedNodes.has(l.to)) };
            },
            async saveFromSelection(customName) {
                if(state.selectedNodes.length === 0) return toast.warn("Nothing selected.");
                if(!state.projectHandle) { toast.error("No project open."); return; }
                let nodesToSave = [], linksToSave = [];
                if (lib.currentTab === 'workflow') { const graph = lib.getConnectedGraph(state.selectedNodes); nodesToSave = graph.nodes; linksToSave = graph.links; } 
                else { nodesToSave = state.selectedNodes; linksToSave = state.links.filter(l => state.selectedNodes.includes(l.from) && state.selectedNodes.includes(l.to)); }
                const data = { nodes: nodesToSave.map(n => n.serialize()), links: linksToSave.map(l => ({ from: l.from.id, to: l.to.id })) };
                const dirName = lib.currentTab === 'object' ? 'ObjectLib' : 'WorkFlowLib';
                const dir = await lib.getDirHandle(dirName);
                const fh = await dir.getFileHandle(`${customName}.json`, {create:true});
                const w = await fh.createWritable(); await w.write(JSON.stringify(data, null, 2)); await w.close();
                lib.refresh(); toast.info(`Saved "${customName}" to ${dirName}`);
            },
            async deleteSelectedItem() {
                if(!lib.selectedItemName) return; if(!confirm(`Delete ${lib.selectedItemName}?`)) return;
                const dirName = lib.currentTab === 'object' ? 'ObjectLib' : 'WorkFlowLib';
                const dir = await lib.getDirHandle(dirName); await dir.removeEntry(lib.selectedItemName); lib.refresh();
            },
            async pasteSelectedItem(atCenter=false) {
                if(!lib.selectedItemName) return;
                const dirName = lib.currentTab === 'object' ? 'ObjectLib' : 'WorkFlowLib';
                const dir = await lib.getDirHandle(dirName); const fh = await dir.getFileHandle(lib.selectedItemName);
                const content = JSON.parse(await (await fh.getFile()).text()); state.clipboard = content;
                if(atCenter) { const center = new THREE.Vector3(); raycaster.setFromCamera(new THREE.Vector2(0,0), camera); raycaster.ray.intersectPlane(plane, center); state.cursorPos.copy(center); }
                app.pasteAtCursor();
            },
            handleDragOver: (e) => { e.preventDefault(); document.getElementById('lib-content').classList.add('drop-zone-active'); },
            handleDragLeave: (e) => { document.getElementById('lib-content').classList.remove('drop-zone-active'); },
            handleDrop: (e) => { 
                e.preventDefault(); 
                document.getElementById('lib-content').classList.remove('drop-zone-active');
                const dataStr = e.dataTransfer.getData('application/json'); 
                if (dataStr) { 
                    try { 
                        const data = JSON.parse(dataStr); 
                        if(data.type === 'lib-load') { /* Do nothing */ }
                        else if(data.type === 'canvas-save') { ui.showLibSaveDialog(); } 
                    } catch(err) { } 
                } 
            }
        };

        window.addEventListener('keydown', (e) => {
            if(e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
            
            if(e.key === 'Home') {
                new TWEEN.Tween(camera.position).to(state.homeState.pos, 500).easing(TWEEN.Easing.Quadratic.Out).start();
                new TWEEN.Tween(controls.target).to(state.homeState.target, 500).easing(TWEEN.Easing.Quadratic.Out).start();
                toast.info("View Reset");
            }
            if(e.key === 'PageUp') { controls.dollyIn(1.2); controls.update(); }
            if(e.key === 'PageDown') { controls.dollyOut(1.2); controls.update(); }

            if(e.key === 'Delete') app.ctxAction(e, 'delete');
            if(e.key.toLowerCase() === 'c') app.copySelection();
            if(e.key.toLowerCase() === 'v') app.pasteAtCursor();
            
            // F1: Cycle Props Mode (0 -> 1 -> 2 -> 0)
            if(e.key === 'F1') {
                e.preventDefault();
                state.view.propsMode = (state.view.propsMode + 1) % 4;
                if(state.view.propsMode===3) state.view.propsMode=0; 

                if(state.view.propsMode > 0) state.view.large = false; 
                
                let modeText = "OFF";
                if (state.view.propsMode === 1) modeText = "Show (Normal)";
                if (state.view.propsMode === 2) modeText = "Show (Ground)";
                
                app.updateView();
                toast.info(`Properties: ${modeText}`);
            }
            // F2: Toggle Large Icon
            if(e.key === 'F2') {
                e.preventDefault();
                state.view.large = !state.view.large;
                if(state.view.large) state.view.propsMode = 0; 
                app.updateView();
                toast.info(`Icon Mode: ${state.view.large?'Large':'Strip'}`);
            }
        });

        const app = {
            setMode: (m) => {
                state.mode = m; 
                document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
                let btn = document.querySelector(`button[onclick="app.setMode('${m}')"]`); 
                if(btn) btn.classList.add('active');
                
                const isCamMode = ['rotate', 'pan', 'zoom'].includes(m);
                controls.enabled = isCamMode; 
                
                if(isCamMode) {
                    if(m==='rotate') controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.PAN };
                    else if(m==='pan') controls.mouseButtons = { LEFT: THREE.MOUSE.PAN, MIDDLE: THREE.MOUSE.ROTATE, RIGHT: THREE.MOUSE.PAN };
                    else if(m==='zoom') controls.mouseButtons = { LEFT: THREE.MOUSE.DOLLY, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.PAN };
                    renderer.domElement.style.cursor = m === 'pan' ? 'grab' : 'default';
                } else {
                    renderer.domElement.style.cursor = 'default';
                }
            },
            updateView: () => {
                state.nodes.forEach(n => n.updateVisibility());
                app.updateLinks();
            },
            updateLinks: () => {
                // 1. Manage Implicit Links based on Cables
                const cablePairs = new Set();
                state.cables.forEach(c => {
                    const n1 = c.src.node;
                    const n2 = c.dst.node;
                    if (n1 && n2 && n1 !== n2) {
                        const id1 = n1.id < n2.id ? n1.id : n2.id;
                        const id2 = n1.id < n2.id ? n2.id : n1.id;
                        cablePairs.add(`${id1}-${id2}`);
                    }
                });

                const explicitPairs = new Set();
                state.links.forEach(l => {
                    const id1 = l.from.id < l.to.id ? l.from.id : l.to.id;
                    const id2 = l.from.id < l.to.id ? l.to.id : l.from.id;
                    explicitPairs.add(`${id1}-${id2}`);
                });

                state.implicitLinks = state.implicitLinks.filter(l => {
                    const id1 = l.from.id < l.to.id ? l.from.id : l.to.id;
                    const id2 = l.from.id < l.to.id ? l.to.id : l.from.id;
                    const key = `${id1}-${id2}`;
                    if (!cablePairs.has(key) || explicitPairs.has(key)) {
                        l.dispose();
                        return false;
                    }
                    return true;
                });

                cablePairs.forEach(key => {
                    if (!explicitPairs.has(key)) {
                        const existing = state.implicitLinks.find(l => {
                            const id1 = l.from.id < l.to.id ? l.from.id : l.to.id;
                            const id2 = l.from.id < l.to.id ? l.to.id : l.from.id;
                            return `${id1}-${id2}` === key;
                        });
                        if (!existing) {
                            const [id1, id2] = key.split('-').map(Number);
                            const n1 = state.nodes.find(n => n.id === id1);
                            const n2 = state.nodes.find(n => n.id === id2);
                            if (n1 && n2) new LaserLink(n1, n2, state.implicitLinks);
                        }
                    }
                });

                // 2. Update All Links
                state.links.forEach(l => l.update());
                state.implicitLinks.forEach(l => l.update());
                state.cables.forEach(c => c.update());
            },
            focusSelection: () => {
                if(state.selectedNodes.length === 0) { toast.warn("No node selected"); return; }
                const p = state.selectedNodes[0].group.position;
                new TWEEN.Tween(camera.position).to({x:p.x, y:p.y+40, z:p.z+30}, 800).easing(TWEEN.Easing.Cubic.Out).start();
                new TWEEN.Tween(controls.target).to({x:p.x, y:p.y, z:p.z}, 800).easing(TWEEN.Easing.Cubic.Out).onComplete(() => { app.setMode('rotate'); }).start();
            },
            selectNode: (node, ctrlKey) => {
                if(ctrlKey) {
                    if(state.selectedNodes.includes(node)) { node.setSelected(false); state.selectedNodes = state.selectedNodes.filter(n => n !== node); } 
                    else { state.selectedNodes.push(node); node.setSelected(true); }
                } else {
                    if(!state.selectedNodes.includes(node)) { app.clearSelection(); state.selectedNodes.push(node); node.setSelected(true); }
                }
            },
            clearSelection: () => { state.selectedNodes.forEach(n => n.setSelected(false)); state.selectedNodes = []; },
            copySelection: () => {
                if(state.selectedNodes.length === 0) return;
                const nodesData = state.selectedNodes.map(n => n.serialize());
                const linksData = state.links.filter(l => state.selectedNodes.includes(l.from) && state.selectedNodes.includes(l.to)).map(l => ({ from: l.from.id, to: l.to.id }));
                state.clipboard = { nodes: nodesData, links: linksData }; toast.info(`Copied ${nodesData.length} items`);
            },
            pasteAtCursor: () => {
                if(!state.clipboard) return;
                const center = state.clipboard.nodes.reduce((acc, n) => ({x:acc.x+n.x, z:acc.z+n.z}), {x:0, z:0});
                center.x /= state.clipboard.nodes.length; center.z /= state.clipboard.nodes.length;
                const offset = { x: state.cursorPos.x - center.x, z: state.cursorPos.z - center.z };
                const idMap = {};
                state.clipboard.nodes.forEach(d => {
                    const n = new SpatialNode(d.type, new THREE.Vector3(d.x + offset.x, d.y, d.z + offset.z));
                    n.content = d.content; n.name = d.name + "_Copy"; n.props = d.props || {}; n.icon = d.icon;
                    n.buildVisuals(); idMap[d.id] = n;
                });
                state.clipboard.links.forEach(l => { if(idMap[l.from] && idMap[l.to]) new LaserLink(idMap[l.from], idMap[l.to]); });
                app.clearSelection(); Object.values(idMap).forEach(n => n.setSelected(true)); state.selectedNodes = Object.values(idMap); toast.info("Pasted");
            },
            addNodeAtCenter: (type) => {
                raycaster.setFromCamera(new THREE.Vector2(0,0), camera);
                const pt = new THREE.Vector3(); raycaster.ray.intersectPlane(plane, pt);
                const n = new SpatialNode(type, pt); 
                app.clearSelection(); app.selectNode(n, false);
                document.querySelectorAll('.ctx-menu').forEach(e => e.style.display='none');
            },
            ctxAction: (e, act) => {
                if(e) e.stopPropagation(); document.querySelectorAll('.ctx-menu').forEach(e => e.style.display='none');
                const targetNodes = state.selectedNodes;
                if(act==='delete') {
                    state.links = state.links.filter(l => { if(targetNodes.includes(l.from) || targetNodes.includes(l.to)) { l.dispose(); return false; } return true; });
                    state.implicitLinks = state.implicitLinks.filter(l => { 
                         if(targetNodes.includes(l.from) || targetNodes.includes(l.to)) { l.dispose(); return false; } return true; 
                    });
                    state.cables = state.cables.filter(c => { if(targetNodes.includes(c.src.node) || targetNodes.includes(c.dst.node)) { c.dispose(); return false; } return true; });
                    targetNodes.forEach(n => { scene.remove(n.group); });
                    state.nodes = state.nodes.filter(n => !targetNodes.includes(n)); state.selectedNodes = [];
                }
                if(act==='copy') app.copySelection();
                if(act==='saveLib') { ui.showLibSaveDialog(); }
                if(act==='edit') targetNodes.length === 1 ? ui.openEditor(targetNodes[0]) : toast.warn("Select one node");
                if(act==='props') targetNodes.length === 1 ? ui.openNodeProps(targetNodes[0]) : toast.warn("Select one node");
                if(act === 'exec') targetNodes.length > 0 ? targetNodes.forEach(n => n.execute(null)) : toast.warn("Select nodes");
                if(act === 'preview') targetNodes.length === 1 && targetNodes[0].type === 'view' ? ui.openPreview(targetNodes[0]) : toast.warn("Select one View node");
            },
            executeAll: () => { state.executionId++; state.nodes.filter(n => n.type === 'source').forEach(n => n.execute(null)); toast.info("Execution Started"); }
        };

        const ui = {
            targetNode: null,
            openDialog: (id) => { const d = document.getElementById(id); d.classList.remove('closing'); d.classList.add('active', 'opening'); },
            closeDialog: (id) => { const d = document.getElementById(id); d.classList.remove('opening'); d.classList.add('closing'); setTimeout(() => { d.classList.remove('active', 'closing'); }, 400); },
            showInputDialog: (msg, cb) => { document.getElementById('input-msg').innerText = msg; document.getElementById('input-val').value = ""; state.inputCallback = cb; ui.openDialog('input-dialog'); setTimeout(() => document.getElementById('input-val').focus(), 400); },
            showLibSaveDialog: () => { if(state.selectedNodes.length === 0) return toast.warn("Nothing selected"); ui.showInputDialog("Save to Library (Enter Name):", (name) => { lib.saveFromSelection(name); }); },
            openEditor: (node) => { 
                ui.targetNode = node; 
                document.getElementById('editor-title').innerText = `EDIT: ${node.name}`; 
                document.getElementById('editor-content').value = node.content; 
                document.getElementById('editor-icon-type').value = node.type; 
                document.getElementById('editor-icon-text').value = node.icon || "";
                ui.openDialog('editor-dialog'); 
            },
            saveEditor: () => { 
                if(ui.targetNode) {
                    ui.targetNode.content = document.getElementById('editor-content').value;
                    const newType = document.getElementById('editor-icon-type').value;
                    const newIcon = document.getElementById('editor-icon-text').value;
                    let rebuild = false;
                    if (newType !== ui.targetNode.type) { ui.targetNode.type = newType; rebuild = true; }
                    if (newIcon !== ui.targetNode.icon) { ui.targetNode.icon = newIcon; rebuild = true; }
                    
                    if(rebuild) ui.targetNode.buildVisuals();
                }
                ui.closeDialog('editor-dialog'); toast.info("Saved"); 
            },
            editorAction: async (act) => {
                if(act === 'load-file') {
                    if(!state.projectHandle) return alert("Open project first");
                    try { const typeDir = await state.projectHandle.getDirectoryHandle(getDirForType(ui.targetNode.type), {create:true}); const [fh] = await window.showOpenFilePicker({ startIn: typeDir }); const file = await fh.getFile(); document.getElementById('editor-content').value = await file.text(); } catch(e) { }
                }
                if(act === 'save-file') {
                    if(!state.projectHandle) return alert("Open project first");
                    const typeDir = await state.projectHandle.getDirectoryHandle(getDirForType(ui.targetNode.type), {create:true}); const fh = await typeDir.getFileHandle(`${ui.targetNode.name}.txt`, {create:true}); const w = await fh.createWritable(); await w.write(document.getElementById('editor-content').value); await w.close(); toast.info(`Saved to ${getDirForType(ui.targetNode.type)}`);
                }
                if(act === 'ai-input') { ui.openDialog('ai-manual-dialog'); }
                if(act === 'ai-api') { if(!state.projectSettings.aiKey) return alert("Set API Key in Properties"); document.getElementById('editor-content').value += "\n\n// AI Gen (API): Executed."; }
            },
            openNodeProps: (node) => {
                ui.targetNode = node; const list = document.getElementById('node-prop-list'); list.innerHTML = '';
                if(!node.props) node.props = {};
                Object.keys(node.props).forEach(k => ui.addNodePropRow(k, node.props[k]));
                ui.openDialog('node-prop-dialog');
            },
            addNodePropRow: (key, val) => {
                const list = document.getElementById('node-prop-list');
                const div = document.createElement('div'); div.className = 'prop-row';
                div.innerHTML = `<input type="text" class="prop-k" value="${key}" placeholder="Key"><input type="text" class="prop-v" value="${val}" placeholder="Value"><button class="btn btn-sm" style="color:red" onclick="this.parentElement.remove()">×</button>`;
                list.appendChild(div);
            },
            addNodeProp: () => ui.addNodePropRow('newKey', 'value'),
            saveNodeProps: () => {
                if(!ui.targetNode) return; const rows = document.querySelectorAll('#node-prop-list .prop-row'); const newProps = {};
                rows.forEach(r => { const k = r.querySelector('.prop-k').value.trim(); const v = r.querySelector('.prop-v').value; if(k) newProps[k] = v; });
                ui.targetNode.props = newProps; ui.closeDialog('node-prop-dialog'); toast.info("Properties Saved");
                if(state.view.propsMode > 0) ui.targetNode.buildPanel(); 
            },
            genPrompt: () => { const goal = document.getElementById('ai-goal-input').value; const type = ui.targetNode ? ui.targetNode.type : "Node"; document.getElementById('ai-prompt-output').value = `[TASK] Write content for a "${type}" node in Neuro Linker.\n[GOAL] ${goal}`; },
            copyPrompt: (id) => { document.getElementById(id).select(); document.execCommand('copy'); toast.info("Copied"); },
            pasteTo: async (id) => { try { const text = await navigator.clipboard.readText(); document.getElementById(id).value = text; } catch(e){alert("Use Ctrl+V");} },
            applyAICode: () => { document.getElementById('editor-content').value = document.getElementById('ai-code-input').value; ui.closeDialog('ai-manual-dialog'); },
            openProjectProps: () => ui.openDialog('prop-dialog'),
            saveProjectProps: () => { state.projectSettings.aiKey = document.getElementById('prop-ai-key').value; ui.closeDialog('prop-dialog'); },
            openPreview: (node) => { ui.targetNode = node; ui.openDialog('preview-dialog'); ui.renderPreview('bar'); },
            setPreviewMode: (mode) => ui.renderPreview(mode),
            renderPreview: (mode) => {
                const area = document.getElementById('preview-area'); area.innerHTML = "";
                const data = ui.targetNode.resultData; 
                if(!data || !Array.isArray(data) || data.length === 0) { area.innerHTML = "<div style='padding:50px; color:#666; text-align:center;'>NO DATA AVAILABLE<br>Please run 'EXECUTE'</div>"; return; }
                const keys = Object.keys(data[0]); const labelKey = keys[0]; const valueKey = keys[1] || keys[0];
                if(mode === 'text') area.innerHTML = `<pre style="padding:15px; color:#fff; font-family:monospace; height:100%; overflow:auto;">${JSON.stringify(data, null, 2)}</pre>`; 
                else if (mode === 'table') { let h = "<div style='height:100%; overflow:auto;'><table><thead><tr>"; keys.forEach(k=>h+=`<th>${k}</th>`); h+="</tr></thead><tbody>"; data.forEach(r => { h+="<tr>"; keys.forEach(k=>h+=`<td>${r[k]}</td>`); h+="</tr>"; }); area.innerHTML = h + "</tbody></table></div>"; } 
                else { area.innerHTML = `<div style="padding:50px; text-align:center; color:#888;">Simple Chart Preview</div>`; }
            }
        };

        document.getElementById('input-ok-btn').addEventListener('click', () => { const val = document.getElementById('input-val').value; if(val && state.inputCallback) state.inputCallback(val); ui.closeDialog('input-dialog'); });
        function getDirForType(t) { const map = { source:'Data', process:'Process', agent:'Agent', join:'Join', view:'View', action:'Action' }; return map[t] || 'Misc'; }
        
        const proj = {
            async action(act) {
                if(act === 'new') { if(confirm("New Project?")) { state.nodes.forEach(n=>scene.remove(n.group)); state.nodes=[]; state.links.forEach(l=>l.dispose()); state.links=[]; state.cables.forEach(c=>c.dispose()); state.cables=[]; if(await proj.ensureHandle()) await proj.initFolders(); } }
                if(act === 'open') { try { const h = await window.showDirectoryPicker(); state.projectHandle = h; const f = await h.getFileHandle('settings.json'); const json = JSON.parse(await (await f.getFile()).text()); proj.loadFromJSON(json); document.getElementById('status-text').innerText = "PROJ: " + h.name; lib.refresh(); } catch(e) { console.error(e); } }
                if(act === 'save') { if(!state.projectHandle && !(await proj.ensureHandle())) return; await proj.initFolders(); const d = { nextId: state.nextNodeId, nodes: state.nodes.map(n=>n.serialize()), links: state.links.map(l=>({from:l.from.id, to:l.to.id})) }; const w = await (await state.projectHandle.getFileHandle('settings.json',{create:true})).createWritable(); await w.write(JSON.stringify(d,null,2)); await w.close(); toast.info("Project Saved"); lib.refresh(); }
                if(act === 'saveas') { try { const h = await window.showDirectoryPicker(); state.projectHandle = h; await this.action('save'); document.getElementById('status-text').innerText = "PROJ: " + h.name; } catch (e) { } }
            },
            async ensureHandle() { try { state.projectHandle = await window.showDirectoryPicker(); return true; } catch(e){return false;} },
            async initFolders() { const dirs = ['Data', 'Process', 'Agent', 'Join', 'View', 'Action', 'ObjectLib', 'WorkFlowLib', 'Misc']; for(const d of dirs) await state.projectHandle.getDirectoryHandle(d, {create:true}); },
            genProject(m) { state.genMode = m; ui.openDialog('ai-proj-dialog'); const manual = document.getElementById('ai-proj-manual-section'); manual.style.display = (m === 'input') ? 'block' : 'none'; if(m === 'api' && !state.projectSettings.aiKey) toast.warn("Setup API Key first"); },
            genPrompt() { const req = document.getElementById('ai-proj-input').value; document.getElementById('ai-proj-prompt-out').value = `[ROLE] Architect for "Neuro Linker".\n[GOAL] ${req}\n[OUTPUT] JSON (nodes, links). Use 'single quotes' in content.`; if(state.genMode === 'api') { setTimeout(() => { toast.info("API Sim: Done"); this.applyGen(true); }, 1000); } },
            applyGen(useSim = false) { try { let json; if(useSim || state.genMode === 'api') { json = { nodes: [ {id:1, type:'source', name:'Sim_Data', x:-15, y:0, z:0, content:'// Generated Data'}, {id:2, type:'process', name:'Sim_Proc', x:0, y:0, z:0, content:'// Generated Logic'}, {id:3, type:'view', name:'Sim_View', x:15, y:0, z:0} ], links: [{from:1, to:2}, {from:2, to:3}] }; } else { json = JSON.parse(document.getElementById('ai-proj-json-in').value); } this.loadFromJSON(json); ui.closeDialog('ai-proj-dialog'); toast.info("Project Generated"); } catch(e) { console.error(e); alert("JSON Error: " + e.message); } },
            loadFromJSON(json) { 
                state.nodes.forEach(n=>scene.remove(n.group)); state.nodes=[]; state.links.forEach(l=>l.dispose()); state.links=[]; state.implicitLinks.forEach(l=>l.dispose()); state.implicitLinks=[]; state.nextNodeId = 1; 
                json.nodes.forEach(d => { 
                    const n = new SpatialNode(d.type, new THREE.Vector3(d.x, d.y, d.z), d.id); 
                    n.name = d.name; n.content = d.content || ""; n.props = d.props || {}; n.icon = d.icon;
                    n.buildVisuals(); 
                }); 
                if(json.links) { json.links.forEach(l => { const f = state.nodes.find(n=>n.id===l.from), t = state.nodes.find(n=>n.id===l.to); if(f&&t) new LaserLink(f,t); }); } 
            }
        };

        // --- INTERACTION ---
        function findParentNode(obj) {
            while(obj) {
                if(obj.userData && obj.userData.isNode) return obj.userData.obj;
                obj = obj.parent;
            }
            return null;
        }

        window.addEventListener('pointerdown', (e) => {
            if(e.button!==0 || e.target.closest('.interactive') || e.target.closest('#menubar') || e.target.closest('#mode-bar') || e.target.closest('#lib-pane')) return;
            if (['rotate', 'pan', 'zoom'].includes(state.mode)) return;

            mouse.x = (e.clientX/window.innerWidth)*2-1; mouse.y = -(e.clientY/window.innerHeight)*2+1;
            raycaster.setFromCamera(mouse, camera);
            const hits = raycaster.intersectObjects(scene.children, true);
            const hitPort = hits.find(h => h.object.userData.isPort);
            
            let nodeObj = null;
            hits.find(h => { const n = findParentNode(h.object); if(n) { nodeObj = n; return true; } return false; });
            
            raycaster.ray.intersectPlane(plane, state.cursorPos);

            if (state.mode === 'select') {
                if (nodeObj) {
                    const node = nodeObj;
                    const now = Date.now();
                    if (now - node.lastClick < 300) { ui.openEditor(node); } 
                    else { app.selectNode(node, e.ctrlKey); }
                    node.lastClick = now;
                    if(state.selectedNodes.includes(node)) { renderer.domElement.draggable = true; }
                } else if(!e.ctrlKey) {
                    app.clearSelection();
                    state.boxSel.active = true;
                    state.boxSel.start.set(e.clientX, e.clientY);
                    const box = document.getElementById('selection-box');
                    box.style.display = 'block'; box.style.left = e.clientX + 'px'; box.style.top = e.clientY + 'px'; box.style.width = '0px'; box.style.height = '0px';
                }
            }
            else if (state.mode === 'move' && nodeObj) {
                const node = nodeObj;
                if(!state.selectedNodes.includes(node)) { app.clearSelection(); app.selectNode(node, false); }
                state.dragNode = node; 
            }
            else if (state.mode === 'link') {
                if(hitPort) { state.linkStartPort = hitPort.object; } 
                else if (nodeObj) { state.linkStartNode = nodeObj; }
            }
        });

        window.addEventListener('pointermove', (e) => {
            if(state.dragNode && state.mode==='move') {
                mouse.x = (e.clientX/window.innerWidth)*2-1; mouse.y = -(e.clientY/window.innerHeight)*2+1;
                raycaster.setFromCamera(mouse, camera);
                const pt = new THREE.Vector3(); raycaster.ray.intersectPlane(plane, pt);
                if(pt) {
                    const delta = pt.clone().sub(state.dragNode.group.position);
                    state.selectedNodes.forEach(n => n.group.position.add(delta));
                    app.updateLinks();
                }
            }
            if(state.boxSel.active && state.mode === 'select') {
                const currentX = e.clientX; const currentY = e.clientY;
                const x = Math.min(currentX, state.boxSel.start.x), y = Math.min(currentY, state.boxSel.start.y);
                const w = Math.abs(currentX - state.boxSel.start.x), h = Math.abs(currentY - state.boxSel.start.y);
                const box = document.getElementById('selection-box');
                box.style.left = x + 'px'; box.style.top = y + 'px'; box.style.width = w + 'px'; box.style.height = h + 'px';
            }
        });

        window.addEventListener('pointerup', (e) => {
            renderer.domElement.draggable = false; 
            if(state.boxSel.active) {
                state.boxSel.active = false;
                const box = document.getElementById('selection-box');
                const startX = parseFloat(box.style.left), startY = parseFloat(box.style.top);
                const w = parseFloat(box.style.width), h = parseFloat(box.style.height);
                box.style.display = 'none';
                if(w > 5 || h > 5) {
                    state.nodes.forEach(n => {
                        const p = n.group.position.clone().project(camera);
                        const sx = (p.x * .5 + .5) * window.innerWidth;
                        const sy = (-(p.y * .5) + .5) * window.innerHeight;
                        if(sx > startX && sx < (startX + w) && sy > startY && sy < (startY + h)) app.selectNode(n, true);
                    });
                }
            }
            state.dragNode = null;
            if(state.mode === 'link') {
                 mouse.x = (e.clientX/window.innerWidth)*2-1; mouse.y = -(e.clientY/window.innerHeight)*2+1;
                 raycaster.setFromCamera(mouse, camera);
                 const hits = raycaster.intersectObjects(scene.children, true);
                 const hitPort = hits.find(h => h.object.userData.isPort);
                 let nodeObj = null;
                 hits.find(h => { const n = findParentNode(h.object); if(n) { nodeObj = n; return true; } return false; });
                 
                 if(state.linkStartPort && hitPort && state.linkStartPort !== hitPort.object) {
                     new ConnectionCable(state.linkStartPort, hitPort.object);
                     app.updateLinks(); // Update implicit
                     toast.info("Parameter Connected");
                 }
                 else if (state.linkStartNode && nodeObj && state.linkStartNode !== nodeObj) {
                     new LaserLink(state.linkStartNode, nodeObj);
                     toast.info("Node Linked");
                 }
                 state.linkStartNode = null; state.linkStartPort = null;
            }
        });

        document.body.addEventListener('dragstart', (e) => {
            if (e.target.classList.contains('lib-item')) { } 
            else if (state.mode === 'select' && state.selectedNodes.length > 0 && e.target === renderer.domElement) {
                e.dataTransfer.setData('application/json', JSON.stringify({type: 'canvas-save'})); 
                e.dataTransfer.effectAllowed = 'copy';
                const img = new Image(); img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; 
                e.dataTransfer.setDragImage(img, 0, 0);
            } else { e.preventDefault(); }
        });
        document.body.addEventListener('dragover', e => e.preventDefault());
        document.body.addEventListener('drop', async (e) => {
            e.preventDefault(); const dataStr = e.dataTransfer.getData('application/json');
            if (dataStr) { try { const data = JSON.parse(dataStr); 
                if(data.type === 'lib-load' && e.target === renderer.domElement) {
                    const rect = renderer.domElement.getBoundingClientRect(); mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; raycaster.setFromCamera(mouse, camera); raycaster.ray.intersectPlane(plane, state.cursorPos);
                    const dirName = data.tab === 'object' ? 'ObjectLib' : 'WorkFlowLib'; const dir = await lib.getDirHandle(dirName); const f = await dir.getFileHandle(data.file); const content = JSON.parse(await (await f.getFile()).text()); state.clipboard = content; app.pasteAtCursor(); 
                }
                if(data.type === 'canvas-save' && e.target.closest('#lib-content')) ui.showLibSaveDialog();
            } catch(err) { } }
        });

        window.addEventListener('contextmenu', (e) => {
            if(e.target.closest('.interactive') || e.target.closest('#lib-pane')) return;
            e.preventDefault(); mouse.x = (e.clientX/window.innerWidth)*2-1; mouse.y = -(e.clientY/window.innerHeight)*2+1;
            raycaster.setFromCamera(mouse, camera);
            const hits = raycaster.intersectObjects(scene.children, true);
            let hitNode = null;
            hits.find(h => { const n = findParentNode(h.object); if(n) { hitNode = n; return true; } return false; });

            const m = hitNode ? document.getElementById('ctx-node') : document.getElementById('ctx-global');
            document.querySelectorAll('.ctx-menu').forEach(e=>e.style.display='none');
            if(hitNode) {
                if(!state.selectedNodes.includes(hitNode)) { app.clearSelection(); app.selectNode(hitNode, false); }
                const pvBtn = document.getElementById('ctx-preview'); pvBtn.style.display = hitNode.type === 'view' ? 'block' : 'none';
            }
            m.style.display = 'flex'; m.style.left = e.pageX + 'px'; m.style.top = e.pageY + 'px';
        });
        window.addEventListener('click', (e) => { if(!e.target.closest('.ctx-menu')) document.querySelectorAll('.ctx-menu').forEach(e=>e.style.display='none'); });
        
        function animate() { requestAnimationFrame(animate); TWEEN.update(); controls.update(); composer.render(); state.cables.forEach(c => c.update()); }
        animate();
        app.setMode('select'); 
        window.app = app; window.ui = ui; window.proj = proj; window.lib = lib;
        new SpatialNode('source', new THREE.Vector3(-10,0,0));
    </script>
</body>
</html>

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

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

便利なツール

  • 56本

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
未来っぽいグラフ作成ツール「Neuro Linker」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word 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