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

【更新履歴】

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

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


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



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


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

<!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 v2.0</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</div>
                <textarea id="editor-content" style="height:350px; font-size:13px; line-height:1.5; color:#aaffff; background:#080a0c; font-family:'Consolas', monospace;"></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
                }; 
            }
            async execute(inputData = null) { 
                this.flash(); toast.info(`Exec: ${this.name}`);
                let output = inputData;
                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