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


画像


画像



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


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

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Neuro Linker v1.0</title>
    <style>
        :root {
            --bg: #050505;
            --panel: rgba(12, 16, 20, 0.96);
            --accent: #00ffcc;
            --danger: #ff4466;
            --warn: #ffcc00;
            --text: #e0f0ff;
            --border: #334455;
            --menu-bg: #0f1115;
            --toast-bg: rgba(20, 25, 30, 0.95);
        }
        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: 100;
            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: 200px; 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; }

        /* 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;
        }
        .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); }

        /* 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 Notifications */
        #toast-container {
            position: absolute; top: 50px; right: 20px; display: flex; flex-direction: column; gap: 10px; z-index: 9000; pointer-events: none;
        }
        .toast {
            background: var(--toast-bg); 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.error { border-color: var(--danger); }
        .toast.warn { border-color: var(--warn); }

        /* Dialog System */
        .modal-overlay {
            position: fixed; top: 0; left: 0; width: 100%; height: 100%;
            background: rgba(0,0,0,0.7); z-index: 2000; 
            display: flex; justify-content: center; align-items: center;
            opacity: 0; pointer-events: none; visibility: hidden; transition: opacity 0.3s;
            perspective: 1500px;
        }
        .modal-overlay.active { 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;
            transform-style: preserve-3d; opacity: 0; transform-origin: center; border-radius: 6px;
        }
        .modal-overlay.opening .dialog-box { animation: animFlipOpen 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards; }
        .modal-overlay.closing .dialog-box { animation: animFlipClose 0.3s cubic-bezier(0.6, -0.28, 0.735, 0.045) forwards; }
        @keyframes animFlipOpen { 0% { transform: translateZ(-500px) rotateX(-45deg); opacity: 0; } 100% { transform: translateZ(0) rotateX(0deg); opacity: 1; } }
        @keyframes animFlipClose { 0% { transform: translateZ(0) rotateX(0deg); opacity: 1; } 100% { transform: translateZ(-300px) rotateX(45deg); opacity: 0; } }

        .dlg-header {
            padding: 12px 20px; background: linear-gradient(90deg, rgba(255,255,255,0.05), transparent);
            border-bottom: 1px solid #333; color: var(--accent); font-weight: bold; font-size: 13px; letter-spacing: 1px;
            display: flex; justify-content: space-between; align-items: center;
        }
        .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; transition: 0.2s; border-radius: 3px; font-weight: bold;
        }
        .btn:hover { border-color: var(--accent); color: var(--accent); background: rgba(0,255,204,0.05); box-shadow: 0 0 10px rgba(0,255,204,0.1); }
        .btn-primary { border-color: var(--accent); color: var(--accent); }

        /* Loader */
        .status { position: absolute; top: 45px; left: 15px; color: #556677; font-size: 10px; letter-spacing: 1px; }
        .loader-overlay {
            position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.85);
            display: none; justify-content: center; align-items: center; z-index: 9999; color: var(--accent); font-weight: bold; flex-direction: column; gap: 15px;
        }
        .spinner { width: 40px; height: 40px; border: 3px solid rgba(0,255,204,0.3); border-top-color: var(--accent); border-radius: 50%; animation: spin 1s linear infinite; }
        @keyframes spin { to { transform: rotate(360deg); } }

        /* 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); }
    </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="loader" class="loader-overlay">
        <div class="spinner"></div>
        <div id="loader-msg">SYSTEM INITIALIZING...</div>
    </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')">✨ AI Gen (Manual Input)</div>
                <div class="sub-item" onclick="proj.genProject('api')">🤖 AI Gen (API Auto)</div>
                <div class="ctx-sep"></div>
                <div class="sub-item" onclick="app.executeAll()" style="color:var(--accent);">▶ Execute All</div>
                <div class="ctx-sep"></div>
                <div class="sub-item" onclick="ui.openProjectProps()">Properties</div>
            </div>
        </div>
        <div class="menu-item">
            EDIT
            <div class="submenu">
                <div class="sub-item" onclick="app.addNodeAtCursor('source')">+ Add Data Source</div>
                <div class="sub-item" onclick="app.addNodeAtCursor('filter')">+ Add Filter</div>
                <div class="sub-item" onclick="app.addNodeAtCursor('process')">+ Add Process</div>
                <div class="sub-item" onclick="app.addNodeAtCursor('view')">+ Add View</div>
            </div>
        </div>
        <div class="menu-item" style="margin-left:auto; cursor:default; color:#666; font-weight:bold;">NEURO LINKER v2.0 PRO</div>
    </div>

    <div id="ui-layer">
        <div class="status" id="status-text">READY // WAITING FOR INPUT</div>
        <div id="mode-bar">
            <div class="tool-group">
                <div class="group-label">Object</div>
                <div class="btn-row">
                    <button class="mode-btn active" 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">
        <div class="ctx-item" onclick="app.ctxAction('exec')" style="color:var(--accent); font-weight:bold;">▶ Execute</div>
        <div class="ctx-sep"></div>
        <div class="ctx-item" id="ctx-preview" onclick="app.ctxAction('preview')" style="display:none; color:var(--warn);">👁 Preview Results</div>
        <div class="ctx-item" onclick="app.ctxAction('edit')">Open Editor</div>
        <div class="ctx-item" onclick="app.ctxAction('rename')">Rename</div>
        <div class="ctx-item" onclick="app.ctxAction('copy')">Copy Node</div>
        <div class="ctx-item" onclick="app.ctxAction('delete')" style="color:var(--danger)">Delete</div>
    </div>
    
    <div id="ctx-global" class="ctx-menu">
        <div class="ctx-item" onclick="app.addNodeAtCursor('source')">Add Data Source</div>
        <div class="ctx-item" onclick="app.addNodeAtCursor('filter')">Add Filter</div>
        <div class="ctx-item" onclick="app.addNodeAtCursor('process')">Add Process</div>
        <div class="ctx-item" onclick="app.addNodeAtCursor('view')">Add View</div>
    </div>

    <div id="gen-dialog" class="modal-overlay">
        <div class="dialog-box interactive">
            <div class="dlg-header"><span id="gen-title">DIALOG</span><button style="background:none; border:none; color:#555; cursor:pointer;" onclick="ui.closeDialog('gen-dialog')">✕</button></div>
            <div class="dlg-body" id="gen-body"></div>
            <div class="btn-bar" id="gen-btns"></div>
        </div>
    </div>

    <div id="prop-dialog" class="modal-overlay">
        <div class="dialog-box interactive">
            <div class="dlg-header"><span>PROJECT PROPERTIES</span><button style="background:none; border:none; color:#555; cursor:pointer;" onclick="ui.closeDialog('prop-dialog')">✕</button></div>
            <div class="dlg-body">
                <div><div class="lbl-tag">AI PROVIDER</div><input id="prop-ai-name" type="text" value="OpenAI"></div>
                <div><div class="lbl-tag">MODEL</div><input id="prop-ai-model" type="text" value="gpt-4o"></div>
                <div><div class="lbl-tag">API KEY</div><input id="prop-ai-key" type="password" placeholder="sk-..."></div>
            </div>
            <div class="btn-bar"><button class="btn btn-primary" onclick="ui.saveProjectProps()">Save Settings</button></div>
        </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')">📂 Load</button>
                    <button class="btn" onclick="ui.editorAction('save')">💾 Save</button>
                    <div style="width:1px; background:#444; margin:0 5px;"></div>
                    <button class="btn" onclick="ui.editorAction('ai-input')">✨ AI (Manual)</button>
                    <button class="btn" onclick="ui.editorAction('ai-api')">🤖 AI (API)</button>
                </div>
                <div class="lbl-tag">CONTENT</div>
                <textarea id="editor-content" style="height:400px; 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 Changes</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 style="background:none; border:none; color:#555; cursor:pointer;" 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="ai-manual-dialog" class="modal-overlay">
        <div class="dialog-box interactive" style="width:650px;">
            <div class="dlg-header"><span>AI GENERATION (MANUAL)</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 YOUR GOAL</div>
                <div style="display:flex; gap:10px;">
                    <input type="text" id="ai-goal-input" placeholder="e.g. Filter rows where 'sales' > 1000">
                    <button class="btn" onclick="ui.genPrompt()">Generate Prompt</button>
                </div>
                
                <div class="lbl-tag" style="margin-top:10px;">2. COPY THIS PROMPT & 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 AI RESPONSE</div>
                <textarea id="ai-code-input" style="height:120px;" placeholder="Paste the code block here..."></textarea>
                <div style="display:flex; justify-content:flex-end;">
                    <button class="btn" onclick="ui.pasteTo('ai-code-input')">Paste from Clipboard</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 Code</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. Visualize Population Growth by Prefecture as Bar 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>

    <input type="file" id="file-load-input" style="display:none" onchange="ui.handleFileLoad(this)">

    <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 TWEEN from 'three/addons/libs/tween.module.js';

        // --- CONSTANTS ---
        const COLORS = { source: 0x00ffff, filter: 0xff00ff, process: 0xffaa00, view: 0x00ff00 };
        const CONFIG = { 
            bloomThreshold: 0.6, bloomStrength: 0.8, bloomRadius: 0.4, 
            objEmissive: 0.3, arrowEmissive: 1.2, textBrightness: 0.7 
        };
        
        // --- APP STATE ---
        const state = {
            nodes: [], links: [], mode: 'move', selectedNode: null, dragNode: null, linkStartNode: null, tempLinkMesh: null,
            cursorPos: new THREE.Vector3(), ctxTarget: null, nextNodeId: 1, projectHandle: null,
            projectSettings: { aiName: "OpenAI", aiModel: "gpt-4o", aiKey: "" }
        };

        // --- THREE.JS ---
        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.set(0, 40, 60);
        const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.toneMapping = THREE.ReinhardToneMapping; document.body.appendChild(renderer.domElement);
        const composer = new EffectComposer(renderer); composer.addPass(new RenderPass(scene, camera));
        composer.addPass(new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), CONFIG.bloomStrength, CONFIG.bloomRadius, CONFIG.bloomThreshold));
        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);
        
        const cursorGeo = new THREE.RingGeometry(2.8, 3.2, 32); cursorGeo.rotateX(-Math.PI/2);
        const cursorMat = new THREE.MeshBasicMaterial({ color: 0xffff00, transparent:true, opacity:0.8, side:THREE.DoubleSide });
        const selectionCursor = new THREE.Mesh(cursorGeo, cursorMat); selectionCursor.visible = false; scene.add(selectionCursor);

        // --- TOAST SYSTEM ---
        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')
        };

        // --- CLASSES ---
        class SpatialNode {
            constructor(type, pos, id=null) {
                this.id = id || state.nextNodeId++;
                this.type = type;
                this.name = `${type.toUpperCase()}_${this.id}`;
                if(type==='source') this.content = `id,label,value\n1,Alpha,100\n2,Beta,200`;
                else if(type==='filter') this.content = `return data;`;
                else if(type==='process') this.content = `return data;`;
                else this.content = `// View`;
                
                this.inputs = []; this.outputs = []; this.resultData = null;
                this.group = new THREE.Group(); this.group.position.copy(pos); this.group.userData = { isNode: true, obj: this };
                let geo, col = COLORS[type] || 0xffffff;
                if(type==='source') geo = new THREE.CylinderGeometry(2,2,3,16); else if(type==='filter') geo = new THREE.ConeGeometry(2,4,4); else if(type==='process') geo = new THREE.BoxGeometry(3,3,3); else geo = new THREE.PlaneGeometry(5,3);
                const mat = new THREE.MeshStandardMaterial({ color: col, emissive: col, emissiveIntensity: CONFIG.objEmissive, roughness: 0.2, metalness: 0.8 });
                const mesh = new THREE.Mesh(geo, mat);
                if(type==='filter') mesh.rotation.x = Math.PI; if(type==='view') mesh.rotation.x = -0.2; mesh.position.y = 2;
                this.group.add(mesh); this.mesh = mesh; this.color = col;
                this.labelSprite = this.createLabel(this.name); this.group.add(this.labelSprite);
                scene.add(this.group);
            }
            createLabel(txt) {
                const cvs = document.createElement('canvas'); cvs.width = 256; cvs.height = 64; const ctx = cvs.getContext('2d');
                ctx.font = 'bold 36px Arial'; ctx.lineWidth = 5; ctx.strokeStyle = 'black'; ctx.strokeText(txt, 128, 45);
                ctx.fillStyle = `rgba(220, 240, 255, ${CONFIG.textBrightness})`; ctx.textAlign = 'center'; ctx.fillText(txt, 128, 45);
                const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(cvs), transparent: true }));
                sp.position.y = 5.5; sp.scale.set(6, 1.5, 1); return sp;
            }
            updateLabel() { this.group.remove(this.labelSprite); this.labelSprite = this.createLabel(this.name); this.group.add(this.labelSprite); }
            getRadius() { return (this.type === 'view') ? 3.0 : 2.5; }
            serialize() { 
                const dirMap = { source:'Data', filter:'Filter', process:'Process', view:'View' };
                return { id: this.id, type: this.type, name: this.name, x: this.group.position.x, y: this.group.position.y, z: this.group.position.z, contentFileName: `${dirMap[this.type]}/${this.name}.txt` }; 
            }
            async execute(inputData = null) {
                const prev = this.mesh.material.emissiveIntensity; this.mesh.material.emissiveIntensity = 3.0;
                setTimeout(() => this.mesh.material.emissiveIntensity = prev, 300);
                let outputData = inputData;
                try {
                    if (this.type === 'source') {
                        const lines = this.content.trim().split('\n');
                        const headers = lines[0].split(',').map(s=>s.trim());
                        outputData = lines.slice(1).map(line => { 
                            if(!line.trim()) return null;
                            const v = line.split(','); let o={}; 
                            headers.forEach((h,i)=>{ if(v[i] !== undefined) o[h]=v[i].trim(); }); 
                            return o; 
                        }).filter(x=>x);
                    } else if (this.type === 'filter' || this.type === 'process') {
                        if(inputData) { const func = new Function('data', this.content); outputData = func(inputData); }
                    } else if (this.type === 'view') {
                        this.resultData = inputData;
                        toast.info(`View "${this.name}" updated`);
                        if(state.projectHandle) {
                            try {
                                const dir = await state.projectHandle.getDirectoryHandle('View', {create:true});
                                const fh = await dir.getFileHandle(`${this.name}_result.json`, {create:true});
                                const w = await fh.createWritable(); await w.write(JSON.stringify(inputData, null, 2)); await w.close();
                            } catch(e) { console.warn("Auto-save failed"); }
                        }
                    }
                } catch(e) { toast.error(`Error in ${this.name}: ${e.message}`); outputData = null; }
                if(outputData) { this.outputs.forEach(l => { l.pulse(); setTimeout(() => l.to.execute(outputData), 800); }); }
            }
        }

        class LaserLink {
            constructor(n1, n2) {
                this.from = n1; this.to = n2;
                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: n1.color, transparent: true, opacity: 0.9, side: THREE.DoubleSide });
                this.mesh = new THREE.Mesh(geo, mat);
                const coneGeo = new THREE.ConeGeometry(0.5, 1.5, 16); coneGeo.rotateX(Math.PI/2);
                this.arrow = new THREE.Mesh(coneGeo, mat);
                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);
                this.update();
            }
            update() {
                if(!this.from || !this.to) return;
                const p1 = this.from.group.position.clone().add(new THREE.Vector3(0,2,0));
                const p2 = this.to.group.position.clone().add(new THREE.Vector3(0,2,0));
                const dir = new THREE.Vector3().subVectors(p2, p1).normalize();
                const dist = p1.distanceTo(p2);
                const offset1 = this.from.getRadius(); const offset2 = this.to.getRadius();
                if (dist < offset1 + offset2) { this.mesh.visible = false; this.arrow.visible = false; return; }
                this.mesh.visible = true; this.arrow.visible = true;
                const start = p1.add(dir.clone().multiplyScalar(offset1));
                const end = p2.sub(dir.clone().multiplyScalar(offset2));
                this.mesh.position.copy(start); this.mesh.lookAt(end); this.mesh.scale.set(1, 1, start.distanceTo(end));
                this.arrow.position.copy(end); this.arrow.lookAt(end.clone().add(dir));
            }
            pulse() {
                this.packet.visible = true;
                const p1 = this.from.group.position.clone().add(new THREE.Vector3(0,2,0));
                const p2 = this.to.group.position.clone().add(new THREE.Vector3(0,2,0));
                let t = { val: 0 };
                new TWEEN.Tween(t).to({val:1}, 1000).onUpdate(()=>{ this.packet.position.lerpVectors(p1, p2, t.val); }).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(); }
        }

        // --- PROJECT MANAGER ---
        const proj = {
            async action(act) {
                try {
                    if (act === 'new') await this.createNew();
                    if (act === 'open') await this.openProject();
                    if (act === 'save') await this.saveProject();
                    if (act === 'saveas') await this.saveProjectAs();
                } catch (e) { toast.error(e.message); }
            },
            async createNew() { if(!confirm("Create New Project? Unsaved changes will be lost.")) return false; try { const h = await window.showDirectoryPicker(); state.projectHandle = h; this.clearScene(); await this.initFolders(h); document.getElementById('status-text').innerText = "PROJECT: " + h.name; toast.info("Project Created"); return true; } catch(e){return false;} },
            async initFolders(h) { await h.getDirectoryHandle('Data', {create:true}); await h.getDirectoryHandle('Filter', {create:true}); await h.getDirectoryHandle('Process', {create:true}); await h.getDirectoryHandle('View', {create:true}); },
            async openProject() { try { const h = await window.showDirectoryPicker(); state.projectHandle = h; this.clearScene(); const json = JSON.parse(await (await (await h.getFileHandle('settings.json')).getFile()).text()); state.nextNodeId = json.nextId || 1; for(const nd of json.nodes) { const n = new SpatialNode(nd.type, new THREE.Vector3(nd.x,nd.y,nd.z), nd.id); n.name = nd.name; n.updateLabel(); try { const pathParts = nd.contentFileName.split('/'); const dir = await h.getDirectoryHandle(pathParts[0]); const file = await dir.getFileHandle(pathParts[1]); n.content = await (await file.getFile()).text(); } catch(e){} state.nodes.push(n); } setTimeout(() => { json.links.forEach(ld => { const f = state.nodes.find(n=>n.id===ld.from); const t = state.nodes.find(n=>n.id===ld.to); if(f&&t) { const l = new LaserLink(f,t); state.links.push(l); f.outputs.push(l); t.inputs.push(l); } }); }, 100); document.getElementById('status-text').innerText = "PROJECT: "+h.name; toast.info("Project Loaded"); } catch(e){toast.error("Open Failed");} },
            async saveProject() { if(!state.projectHandle) { if(!(await this.createNew())) return; } const h = state.projectHandle; await this.initFolders(h); 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 h.getFileHandle('settings.json',{create:true})).createWritable(); await w.write(JSON.stringify(d,null,2)); await w.close(); const dirMap = { source:'Data', filter:'Filter', process:'Process', view:'View' }; for (const n of state.nodes) { const dir = await h.getDirectoryHandle(dirMap[n.type], {create:true}); const fw = await (await dir.getFileHandle(n.name+".txt", {create:true})).createWritable(); await fw.write(n.content); await fw.close(); } toast.info("Project Saved"); },
            async saveProjectAs() { const old=state.projectHandle; try{state.projectHandle=await window.showDirectoryPicker(); await this.saveProject();}catch(e){state.projectHandle=old;} },
            clearScene() { state.nodes.forEach(n=>scene.remove(n.group)); state.links.forEach(l=>l.dispose()); state.nodes=[]; state.links=[]; state.nextNodeId=1; state.selectedNode=null; selectionCursor.visible=false; },
            
            genProject(m) { ui.openDialog('ai-proj-dialog'); document.getElementById('ai-proj-manual-section').style.display=(m==='input')?'block':'none'; state.genMode=m; },
            
            // --- STRICT PROMPT ---
            genPrompt() {
                const req = document.getElementById('ai-proj-input').value;
                document.getElementById('ai-proj-prompt-out').value = `
[ROLE] System Architect for "Neuro Linker".
[STRICT RULES]
1. Source Node: Content MUST be raw CSV text inside the 'content' string. NO JavaScript, NO fetch.
2. Filter/Process Node: Content MUST be synchronous JavaScript. Input variable is 'data'. Output is 'return modifiedData;'.
3. Layout: Source(x:-15) -> Process(x:0) -> View(x:15).
4. Links: MUST include "links" array connecting IDs.

[GOAL] ${req}

[OUTPUT SCHEMA]
{
  "nodes": [
    { "id": 1, "type": "source", "name": "Src", "x": -15, "y": 0, "z": 0, "content": "key,val\\nA,10" },
    { "id": 2, "type": "process", "name": "Proc", "x": 0, "y": 0, "z": 0, "content": "return data.map(d=>({label:d.key, value:Number(d.val)}));" },
    { "id": 3, "type": "view", "name": "View", "x": 15, "y": 0, "z": 0, "content": "// View" }
  ],
  "links": [ { "from": 1, "to": 2 }, { "from": 2, "to": 3 } ]
}
`; 
                if(state.genMode==='api') setTimeout(()=>{ toast.info("API Sim: Done"); this.applyGen();},1000); 
            },

            applyGen() { 
                try {
                    const json = state.genMode==='api' ? 
                        {nodes:[{id:1,type:'source',name:'Src',x:-10,y:0,z:0,content:'year,val\n2020,10\n2021,20'},{id:2,type:'process',name:'Format',x:0,y:0,z:0,content:'return data.map(d=>({label:d.year, value:parseInt(d.val)}));'},{id:3,type:'view',name:'Vw',x:10,y:0,z:0}],links:[{from:1,to:2},{from:2,to:3}]} : 
                        JSON.parse(document.getElementById('ai-proj-json-in').value);
                    this.loadFromJSON(json);
                    ui.closeDialog('ai-proj-dialog');
                    toast.info("Project Generated. Auto-Executing...");
                    setTimeout(() => app.executeAll(), 800);
                } catch(e){ toast.error("Invalid JSON: " + e.message);}
            },

            loadFromJSON(json) {
                this.clearScene(); 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; if(d.content)n.content=d.content; state.nodes.push(n); if(d.id>=state.nextNodeId)state.nextNodeId=d.id+1; });
                json.links.forEach(l=>{ const f=state.nodes.find(n=>n.id===l.from); const t=state.nodes.find(n=>n.id===l.to); if(f&&t){const lnk=new LaserLink(f,t); state.links.push(lnk); f.outputs.push(lnk); t.inputs.push(lnk);} });
            }
        };

        // --- UI LOGIC ---
        const ui = {
            targetNode: null,
            openDialog: (id) => { document.getElementById(id).classList.remove('closing'); document.getElementById(id).classList.add('active', 'opening'); },
            closeDialog: (id) => { const el = document.getElementById(id); el.classList.remove('opening'); el.classList.add('closing'); setTimeout(() => { el.classList.remove('active', 'closing'); }, 400); },
            
            openEditor: (node) => { ui.targetNode = node; document.getElementById('editor-title').innerText = `EDIT: ${node.name}`; document.getElementById('editor-content').value = node.content; ui.openDialog('editor-dialog'); },
            saveEditor: () => { if(ui.targetNode) ui.targetNode.content = document.getElementById('editor-content').value; ui.closeDialog('editor-dialog'); toast.info("Content Saved"); },
            editorAction: (act) => {
                if(act === 'load') document.getElementById('file-load-input').click();
                if(act === 'save') { const b = new Blob([document.getElementById('editor-content').value], {type:'text/plain'}); const a = document.createElement('a'); a.href=URL.createObjectURL(b); a.download='code.txt'; a.click(); }
                if(act === 'ai-input') ui.openDialog('ai-manual-dialog');
                if(act === 'ai-api') document.getElementById('editor-content').value += "\n// API Call Simulated...";
            },
            handleFileLoad: (input) => { const f=input.files[0]; const r=new FileReader(); r.onload=e=>document.getElementById('editor-content').value=e.target.result; r.readAsText(f); input.value=''; },
            
            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}\n[RULES]\n- Source: Raw CSV text.\n- Process: JS code using 'data' array. Return new array.\n- View Data: {label: string, value: number}`;
            },
            copyPrompt: (id) => { document.getElementById(id).select(); document.execCommand('copy'); toast.info("Copied to Clipboard"); },
            pasteTo: async (id) => { try { const text = await navigator.clipboard.readText(); document.getElementById(id).value = text; } catch(e){alert("Please 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: () => ui.closeDialog('prop-dialog'),

            // PREVIEW LOGIC
            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.includes('label') ? 'label' : (keys.find(k => typeof data[0][k] === 'string') || keys[0]);
                const valueKey = keys.includes('value') ? 'value' : (keys.find(k => typeof data[0][k] === 'number' || !isNaN(parseFloat(data[0][k]))) || keys[1]);

                if(mode === 'text') { area.innerHTML = `<pre style="padding:15px; color:#fff; font-family:monospace;">${JSON.stringify(data, null, 2)}</pre>`; } 
                else if (mode === 'table') {
                    let h = "<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>";
                } else {
                    const w = 800, h = 420, pad = 50;
                    let svg = `<svg width="100%" height="100%" viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg" style="background:#080808">`;
                    const vals = data.map(d=>parseFloat(d[valueKey])||0); 
                    
                    // Graph Scaling (Negative Support)
                    let minVal = Math.min(0, ...vals);
                    let maxVal = Math.max(0, ...vals);
                    if(minVal === 0 && maxVal === 0) maxVal = 1;
                    const range = maxVal - minVal;
                    const zeroY = h - pad - ((0 - minVal) / range) * (h - pad*2);

                    if(mode === 'bar') {
                        const barW = (w - pad*2) / vals.length;
                        vals.forEach((v, i) => { 
                            const barH = (Math.abs(v) / range) * (h - pad*2);
                            let y = zeroY;
                            let col = "#00ffcc";
                            if(v >= 0) { y = zeroY - barH; } else { col = "#ff4466"; }
                            
                            svg += `<rect x="${pad + i*barW + 5}" y="${y}" width="${Math.max(1, barW-10)}" height="${Math.max(1, barH)}" fill="${col}" fill-opacity="0.8"/>`;
                            svg += `<text x="${pad + i*barW + barW/2}" y="${h-15}" fill="#888" font-size="10" text-anchor="middle" transform="rotate(0, ${pad + i*barW + barW/2}, ${h-15})">${data[i][labelKey].substring(0,10)}</text>`;
                        });
                        svg += `<line x1="${pad}" y1="${zeroY}" x2="${w-pad}" y2="${zeroY}" stroke="#555" stroke-width="1"/>`;
                    } else if (mode === 'line') {
                        const step = (w - pad*2) / (vals.length-1 || 1); let pts = "";
                        const getY = (v) => h - pad - ((v - minVal)/range)*(h-pad*2);
                        vals.forEach((v,i) => pts += `${pad + i*step},${getY(v)} `);
                        svg += `<polyline points="${pts}" fill="none" stroke="#ffaa00" stroke-width="2"/>`;
                        svg += `<line x1="${pad}" y1="${getY(0)}" x2="${w-pad}" y2="${getY(0)}" stroke="#333" stroke-dasharray="4"/>`;
                        vals.forEach((v,i) => svg += `<circle cx="${pad + i*step}" cy="${getY(v)}" r="4" fill="#fff"/>`);
                    } else if (mode === 'pie') {
                        const absVals = vals.map(Math.abs);
                        const total = absVals.reduce((a,b)=>a+b, 0); 
                        let acc = 0; const cx = w/2, cy = h/2, r = 160; const colors = ["#00ffcc", "#ff00ff", "#ffaa00", "#0088ff", "#ff5555"];
                        absVals.forEach((v,i) => { if(v<=0)return; const ang = (v/total) * Math.PI * 2; const x1 = cx + r*Math.cos(acc), y1 = cy + r*Math.sin(acc); const x2 = cx + r*Math.cos(acc+ang), y2 = cy + r*Math.sin(acc+ang); const large = ang > Math.PI ? 1 : 0; svg += `<path d="M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2} Z" fill="${colors[i%colors.length]}" stroke="#000"/><text x="${cx + (r+30)*Math.cos(acc+ang/2)}" y="${cy + (r+30)*Math.sin(acc+ang/2)}" fill="#fff" font-size="12" text-anchor="middle">${data[i][labelKey]}</text>`; acc += ang; });
                    }
                    area.innerHTML = svg + "</svg>";
                }
            }
        };

        // --- APP CONTROLLER ---
        const app = {
            setMode: (m) => {
                state.mode = m; document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
                const btn = document.querySelector(`button[onclick="app.setMode('${m}')"]`); if(btn) btn.classList.add('active');
                controls.enabled = ['rotate','pan','zoom'].includes(m);
                if(m==='rotate') controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.PAN };
                if(m==='pan') controls.mouseButtons = { LEFT: THREE.MOUSE.PAN, MIDDLE: THREE.MOUSE.ROTATE, RIGHT: THREE.MOUSE.PAN };
                if(m==='zoom') controls.mouseButtons = { LEFT: THREE.MOUSE.DOLLY, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.PAN };
            },
            addNodeAtCursor: (type) => { new SpatialNode(type, state.cursorPos); app.closeCtx(); },
            ctxAction: (act) => {
                const n = state.ctxTarget; app.closeCtx(); if(!n) return;
                if(act==='delete') {
                    state.links = state.links.filter(l => { if(l.from===n || l.to===n) { l.dispose(); return false; } return true; });
                    scene.remove(n.group); state.nodes = state.nodes.filter(node => node !== n);
                    if(state.selectedNode===n) { state.selectedNode=null; selectionCursor.visible=false; }
                }
                if(act==='copy') { const c = new SpatialNode(n.type, n.group.position.clone().add(new THREE.Vector3(5,0,5))); c.content=n.content; c.name=n.name+"_Copy"; c.updateLabel(); state.nodes.push(c); }
                if(act==='edit') ui.openEditor(n); if(act==='exec') n.execute(); if(act==='preview') ui.openPreview(n);
                if(act==='rename') { const v = prompt("Rename:", n.name); if(v){n.name=v;n.updateLabel();} }
            },
            closeCtx: () => document.querySelectorAll('.ctx-menu').forEach(e => e.style.display='none'),
            focusSelection: () => { if(state.selectedNode) { const p = state.selectedNode.group.position; new TWEEN.Tween(controls.target).to({x:p.x, y:p.y, z:p.z}, 1000).easing(TWEEN.Easing.Cubic.Out).start(); new TWEEN.Tween(camera.position).to({x:p.x+15, y:p.y+15, z:p.z+15}, 1000).easing(TWEEN.Easing.Cubic.Out).start(); app.setMode('rotate'); } },
            executeAll: async () => { if(!state.projectHandle) { if(!(await proj.createNew())) return; } state.nodes.filter(n => n.type === 'source').forEach(n => n.execute()); toast.info("Execution Started"); }
        };

        // --- INTERACTION ---
        window.addEventListener('contextmenu', (e) => {
            if(e.target.closest('.dialog-box') || e.target.closest('#menubar')) return; // Prevent ctx menu on UI
            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);
            const hit = hits.find(h => h.object.parent && h.object.parent.userData.isNode);
            raycaster.ray.intersectPlane(plane, state.cursorPos);
            if(hit) {
                state.ctxTarget = hit.object.parent.userData.obj;
                document.getElementById('ctx-preview').style.display = (state.ctxTarget.type === 'view') ? 'block' : 'none';
                const m = document.getElementById('ctx-node'); m.style.display='flex'; m.style.left=e.pageX+'px'; m.style.top=e.pageY+'px';
                document.getElementById('ctx-global').style.display='none';
            } else {
                state.ctxTarget = null;
                const m = document.getElementById('ctx-global'); m.style.display='flex'; m.style.left=e.pageX+'px'; m.style.top=e.pageY+'px';
                document.getElementById('ctx-node').style.display='none';
            }
        });
        window.addEventListener('click', (e) => { if(!e.target.closest('.ctx-menu') && !e.target.closest('.menu-item')) app.closeCtx(); });
        window.addEventListener('dblclick', (e) => {
            if(e.target.closest('.interactive')) 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 hit = hits.find(h => h.object.parent && h.object.parent.userData.isNode);
            if(hit) ui.openEditor(hit.object.parent.userData.obj);
        });
        window.addEventListener('pointerdown', (e) => {
            if(e.button!==0 || e.target.closest('.interactive') || e.target.closest('#menubar')) 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 hit = hits.find(h => h.object.parent && h.object.parent.userData.isNode);
            if(hit) {
                const node = hit.object.parent.userData.obj; state.selectedNode = node; selectionCursor.visible = true;
                if(state.mode === 'move') { state.dragNode = node; controls.enabled=false; }
                if(state.mode === 'link') { state.linkStartNode = node; const geo = new THREE.ConeGeometry(0.2, 1, 4); geo.rotateX(Math.PI/2); state.tempLinkMesh = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({color:node.color, transparent:true, opacity:0.5})); scene.add(state.tempLinkMesh); }
            } else { state.selectedNode = null; selectionCursor.visible = false; }
        });
        window.addEventListener('pointermove', (e) => {
            mouse.x = (e.clientX/window.innerWidth)*2-1; mouse.y = -(e.clientY/window.innerHeight)*2+1;
            raycaster.setFromCamera(mouse, camera);
            if(state.dragNode && state.mode==='move') { const pt = new THREE.Vector3(); raycaster.ray.intersectPlane(plane, pt); if(pt) { state.dragNode.group.position.copy(pt); state.links.forEach(l => { if(l.from===state.dragNode||l.to===state.dragNode) l.update(); }); } }
            if(state.linkStartNode && state.tempLinkMesh) { const pt = new THREE.Vector3(); raycaster.ray.intersectPlane(new THREE.Plane(new THREE.Vector3(0,1,0), -2), pt); if(pt) { state.tempLinkMesh.position.copy(pt); const s = state.linkStartNode.group.position.clone().add(new THREE.Vector3(0,2,0)); state.tempLinkMesh.lookAt(s); } }
        });
        window.addEventListener('pointerup', (e) => {
            if(state.linkStartNode) { 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 hit = hits.find(h => h.object.parent && h.object.parent.userData.isNode); if(hit) { const t = hit.object.parent.userData.obj; if(t !== state.linkStartNode) { const l = new LaserLink(state.linkStartNode, t); state.links.push(l); state.linkStartNode.outputs.push(l); t.inputs.push(l); } } scene.remove(state.tempLinkMesh); state.tempLinkMesh = null; state.linkStartNode = null; }
            if(state.dragNode) { state.dragNode = null; if(state.mode==='move') controls.enabled=false; }
        });

        function animate() { requestAnimationFrame(animate); TWEEN.update(); controls.update(); if(state.selectedNode) { const p = state.selectedNode.group.position; selectionCursor.position.set(p.x, 0.1, p.z); selectionCursor.rotation.z -= 0.02; } composer.render(); }
        
        // BOOT SEQUENCE
        setTimeout(() => { document.getElementById('loader').style.display = 'none'; }, 1000);
        animate();
        window.app = app; window.ui = ui; window.proj = proj;
        
        // Initial Demo
        const n1 = new SpatialNode('source', new THREE.Vector3(-10,0,0)); const n2 = new SpatialNode('view', new THREE.Vector3(10,0,0)); const l1 = new LaserLink(n1,n2); state.links.push(l1); n1.outputs.push(l1); n2.inputs.push(l1);
        window.addEventListener('resize', () => { camera.aspect = window.innerWidth/window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); composer.setSize(window.innerWidth, window.innerHeight); });
    </script>
</body>
</html>

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

コメント

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