未来っぽいグラフ作成ツール「Neuro Linker」
【更新履歴】
・2026/2/13 バージョン1.0公開。
・2026/2/14 バージョン1.1公開。
・2026/2/15 バージョン1.2公開。(プロパティ、js部品化)
・ダウンロードされる方はこちら。↓
・ソースコードはこちら。↓
<!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>


コメント