未来っぽいグラフ作成ツール「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>

コメント