Todoの検索ツール「Visible Grid」
【更新履歴】
・2026/2/11 1.0公開。
・2026/2/12 1.2日本語版を公開。(AI連携など追加)
・2026/2/12 1.3日本語版を公開。(表示モードなど追加)
使い方のヒント
画像の貼り付け:
ノードを選択し、右側の「Image URL」欄に画像のURL(例: https://imgur.com/example.jpg など)を入力してください。キューブがその画像に変わります。
注意: CORS(セキュリティ制限)により、一部のサイトの画像は表示されない場合があります。
最短経路(Pathfinding):
ステップ1: ノードA(ゴールにしたいノード)を選択します。
ステップ2: 右パネルの「Set as Path Target」ボタンを押します(またはリストで右クリックしてSet Target)。
ステップ3: 別のノードB(スタート地点)を選択します。
結果: AとBをつなぐ最短のルートが、赤い太線で表示されます。
Markdown:
Content欄に **太字** や URL を入力すると、すぐ下のプレビュー欄で見やすく表示され、リンクもクリックできるようになります。
・ダウンロードされる方はこちら。↓
・ソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Visible Grid 1.3 日本語版</title>
<style>
/* --- Base & Layout --- */
:root { --accent: #00e5ff; --bg-dark: #0b0c10; --panel-bg: rgba(20, 24, 30, 0.98); --text-main: #ffffff; --menu-bg: #1a1c23; --menu-hover: #2979ff; }
body, html { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background-color: var(--bg-dark); color: var(--text-main); font-family: 'Inter', 'Yu Gothic UI', sans-serif; user-select: none; }
/* Menu Bar (Dropdown System) */
#menubar { height: 40px; background: #15171e; display: flex; align-items: center; padding: 0 10px; border-bottom: 1px solid #333; z-index: 100; position: relative; }
.brand { font-weight: 800; color: var(--accent); margin-right: 20px; font-size: 1.1em; letter-spacing: 1px; padding: 0 10px; cursor: default; }
.menu-item { position: relative; padding: 0 15px; height: 100%; display: flex; align-items: center; cursor: pointer; font-size: 0.9em; transition: background 0.2s; color: #ccc; }
.menu-item:hover { background: #2a2d35; color: #fff; }
.menu-item:hover .dropdown { display: block; }
.dropdown { display: none; position: absolute; top: 100%; left: 0; background: var(--menu-bg); min-width: 180px; box-shadow: 0 4px 12px rgba(0,0,0,0.5); border: 1px solid #333; border-top: none; z-index: 101; }
.dd-item { padding: 10px 15px; font-size: 0.9em; color: #ddd; cursor: pointer; display: flex; justify-content: space-between; border-bottom: 1px solid rgba(255,255,255,0.05); }
.dd-item:hover { background: var(--menu-hover); color: #fff; }
.dd-item.disabled { color: #555; cursor: default; }
.dd-item.disabled:hover { background: none; }
.dd-sep { height: 1px; background: #333; margin: 2px 0; }
.shortcut { font-size: 0.8em; color: #888; margin-left: 10px; }
.menu-spacer { flex: 1; }
.status-msg { font-size: 0.85em; color: var(--accent); margin-right: 15px; font-family: monospace; }
.ai-btn { background: linear-gradient(45deg, #7b1fa2, #4a148c); border: 1px solid #ba68c8; color: #fff; padding: 4px 12px; border-radius: 4px; font-size: 0.85em; cursor: pointer; margin-left: 10px; }
.ai-btn:hover { box-shadow: 0 0 8px rgba(186,104,200, 0.6); }
/* Main Container */
#container { display: flex; height: calc(100% - 40px); }
/* Panels */
.panel { width: 320px; background: var(--panel-bg); border-right: 1px solid #333; display: flex; flex-direction: column; z-index: 5; backdrop-filter: blur(5px); }
#right-panel { border-left: 1px solid #333; border-right: none; }
.panel-header { padding: 10px 15px; background: rgba(255,255,255,0.05); font-weight: bold; border-bottom: 1px solid #333; font-size: 0.75em; text-transform: uppercase; letter-spacing: 1.2px; color: var(--accent); display:flex; justify-content:space-between; align-items:center; }
/* Tabs */
.tab-bar { display: flex; background: #15171e; }
.tab { flex: 1; text-align: center; padding: 10px; cursor: pointer; font-size: 0.8em; color: #888; border-bottom: 2px solid transparent; transition: 0.2s; }
.tab:hover { color: #fff; background: #222; }
.tab.active { color: var(--accent); border-bottom-color: var(--accent); background: #1e222b; color: #fff; }
/* List Box Style */
#node-list, #group-list, #tag-list, #main-list-view { flex: 1; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #333 transparent; background: #121418; }
.list-box-item { padding: 8px 12px; cursor: pointer; border-bottom: 1px solid #222; display: flex; align-items: center; gap: 12px; transition: background 0.1s; }
.list-box-item:hover { background: #1f232a; }
.list-box-item.active { background: #2a303b; border-left: 3px solid var(--accent); padding-left: 9px; }
.node-icon-visual {
width: 28px; height: 28px; flex-shrink: 0; border-radius: 50%;
background-size: cover; background-position: center;
box-shadow: 0 0 5px rgba(255,255,255,0.2);
display: flex; align-items: center; justify-content: center; background-color: #000;
}
.node-ball {
width: 100%; height: 100%; border-radius: 50%;
background: radial-gradient(circle at 30% 30%, rgba(255,255,255,1), rgba(255,255,255,0) 30%),
radial-gradient(circle at 50% 50%, currentColor, #222);
}
.node-info { flex: 1; overflow: hidden; }
.node-name { font-size: 0.95em; font-weight: bold; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.node-meta { font-size: 0.75em; color: #aaa; margin-top: 2px; }
.group-item { background: #1a1c23; margin-bottom: 1px; }
.group-header { padding: 8px 15px; display: flex; align-items: center; justify-content: space-between; font-weight: bold; font-size: 0.85em; cursor: pointer; color: #ddd; }
.group-header:hover { background: #252a35; }
.group-toggle { color: var(--accent); margin-right: 8px; font-family: monospace; }
.tag-row { padding: 8px 15px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid #222; }
.color-preview { width: 16px; height: 16px; border-radius: 3px; border: 1px solid #555; cursor: pointer; }
/* Canvas */
#center-panel { flex: 1; position: relative; background: #000; overflow: hidden; display: flex; flex-direction: column; }
#canvas-container { width: 100%; flex: 1; outline: none; }
#main-list-view { display: none; width: 100%; height: 100%; background: #0b0c10; padding: 20px; box-sizing: border-box; }
#main-list-view .list-box-item { background: #15181e; margin-bottom: 5px; border-radius: 4px; border: 1px solid #222; padding: 15px; }
#view-dock { height: 40px; background: rgba(0,0,0,0.8); border-top: 1px solid #333; display: flex; align-items: center; justify-content: center; gap: 15px; z-index: 10; }
.view-btn { background: transparent; border: 1px solid #444; color: #888; padding: 4px 12px; border-radius: 15px; cursor: pointer; font-size: 0.8em; display: flex; align-items: center; gap: 5px; }
.view-btn:hover { color: #fff; background: rgba(255,255,255,0.1); }
.view-btn.active { color: #000; background: var(--accent); border-color: var(--accent); font-weight: bold; }
/* Forms */
.prop-group { padding: 15px; border-bottom: 1px solid #333; }
.prop-label { display: block; margin-bottom: 5px; font-size: 0.7em; color: #aaa; text-transform: uppercase; letter-spacing: 0.5px; }
input, textarea, select { width: 100%; background: #1f232a; border: 1px solid #444; color: #fff; padding: 8px; margin-bottom: 10px; box-sizing: border-box; border-radius: 4px; font-family: inherit; font-size: 0.9em; }
input:focus, textarea:focus, select:focus { border-color: var(--accent); outline: none; }
textarea { height: 80px; resize: vertical; }
input[type="range"] { width: 100%; cursor: pointer; }
button.action-btn { width: 100%; padding: 8px; background: #2b3540; border: 1px solid #333; color: #ddd; cursor: pointer; margin-top: 5px; border-radius: 4px; transition: 0.2s; font-size: 0.85em; }
button.action-btn:hover { background: #3a4755; border-color: #555; }
button.btn-primary { background: linear-gradient(45deg, #1e3c72, #2a5298); border: none; color: white; }
button.btn-danger { background: #3a1c1c; border-color: #522; color: #ff8888; }
.hidden { display: none !important; }
/* Context Menu */
#context-menu { position: absolute; display: none; background: rgba(30,30,30,0.98); border: 1px solid #444; box-shadow: 0 10px 30px rgba(0,0,0,0.8); z-index: 2000; min-width: 160px; border-radius: 6px; }
.ctx-item { padding: 10px 15px; cursor: pointer; color: #ddd; font-size: 0.9em; border-bottom: 1px solid rgba(255,255,255,0.05); }
.ctx-item:hover { background: var(--menu-hover); color: #fff; }
/* AI Modal */
#ai-modal { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 9000; display: none; align-items: center; justify-content: center; backdrop-filter: blur(5px); }
#ai-modal-content { width: 600px; max-width: 90%; background: #1a1c23; border: 1px solid #444; border-radius: 8px; box-shadow: 0 0 30px rgba(0,0,0,0.8); display: flex; flex-direction: column; overflow: hidden; }
.ai-header { padding: 15px 20px; background: linear-gradient(90deg, #4a148c, #1a1c23); color: #fff; font-weight: bold; display: flex; justify-content: space-between; align-items: center; }
.ai-body { padding: 20px; max-height: 80vh; overflow-y: auto; }
.ai-step { margin-bottom: 25px; border-left: 2px solid #555; padding-left: 15px; }
textarea.code-area { font-family: 'Consolas', monospace; font-size: 0.85em; color: #a5d6a7; background: #0f1115; }
.close-btn { background: none; border: none; color: #aaa; cursor: pointer; font-size: 1.5em; }
/* Camera Mode Indicator */
#cam-mode-indicator { position: absolute; top: 10px; right: 10px; background: rgba(0,0,0,0.5); padding: 5px 10px; border-radius: 4px; color: var(--accent); font-size: 0.8em; pointer-events: none; }
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
</head>
<body>
<div id="menubar">
<div class="brand">VISIBLE GRID</div>
<div class="menu-item">
ファイル
<div class="dropdown">
<div class="dd-item" onclick="app.newFile()">新規作成 <span class="shortcut">New</span></div>
<div class="dd-item" onclick="app.openFile()">開く... <span class="shortcut">Open</span></div>
<div class="dd-sep"></div>
<div class="dd-item" onclick="app.saveFile()">上書き保存 <span class="shortcut">Save</span></div>
<div class="dd-item" onclick="app.saveAsFile()">別名で保存... <span class="shortcut">Save As</span></div>
</div>
</div>
<div class="menu-item">
カメラ
<div class="dropdown">
<div class="dd-item" onclick="app.visualizer.setCamMode('rotate')">操作: 回転 (Rotate)</div>
<div class="dd-item" onclick="app.visualizer.setCamMode('pan')">操作: 移動 (Pan)</div>
<div class="dd-item" onclick="app.visualizer.setCamMode('zoom')">操作: ズーム (Zoom)</div>
<div class="dd-sep"></div>
<div class="dd-item" onclick="app.visualizer.focusSelection()">注視して旋回 (Orbit Focus)</div>
<div class="dd-item" onclick="app.visualizer.resetCamera()">リセット (Home)</div>
</div>
</div>
<button class="ai-btn" onclick="aiManager.open()">✨ AI 設計</button>
<div class="menu-spacer"></div>
<div id="status-bar" class="status-msg">Ready</div>
</div>
<div id="ai-modal">
<div id="ai-modal-content">
<div class="ai-header">
<span>AI Architect</span>
<button class="close-btn" onclick="aiManager.close()">×</button>
</div>
<div class="ai-body">
<div class="ai-step">
<div style="font-weight:bold; color:#ccc; margin-bottom:5px;">Step 1: 生成したいグラフの内容</div>
<textarea id="ai-input" placeholder="例: Web開発のロードマップを作って。Frontend, Backend, DevOpsのグループに分けて、色も変えて。"></textarea>
<button class="action-btn btn-primary" onclick="aiManager.generatePrompt()">プロンプト生成</button>
</div>
<div class="ai-step">
<div style="font-weight:bold; color:#ccc; margin-bottom:5px;">Step 2: AIへの指示 (コピー & 送信)</div>
<textarea id="ai-prompt-output" class="code-area" readonly style="height:100px;"></textarea>
<button class="action-btn" onclick="aiManager.copyPrompt()">コピー</button>
</div>
<div class="ai-step">
<div style="font-weight:bold; color:#ccc; margin-bottom:5px;">Step 3: 結果のJSONを貼り付け</div>
<textarea id="ai-json-input" class="code-area" placeholder='[ { "name": "...", ... } ]' style="height:100px; color:#fff;"></textarea>
<button class="action-btn btn-primary" onclick="aiManager.apply()">グラフを生成して反映</button>
</div>
</div>
</div>
</div>
<div id="container">
<div id="left-panel" class="panel">
<div class="tab-bar">
<div class="tab active" onclick="ui.switchLeftTab('nodes')">ノード</div>
<div class="tab" onclick="ui.switchLeftTab('groups')">グループ</div>
</div>
<div id="tab-nodes" style="display:flex; flex-direction:column; flex:1; overflow:hidden;">
<div style="padding:10px; border-bottom:1px solid #333;">
<input type="text" id="search-input" placeholder="検索..." style="margin-bottom:0;">
</div>
<div id="node-list"></div>
</div>
<div id="tab-groups" class="hidden" style="flex:1; overflow:hidden; display:flex; flex-direction:column;">
<div id="group-list"></div>
</div>
</div>
<div id="center-panel">
<div id="canvas-container"></div>
<div id="cam-mode-indicator">Mode: Rotate</div>
<div id="main-list-view"></div>
<div id="view-dock">
<button class="view-btn active" onclick="app.setMode('graph')" id="btn-mode-graph">🕸️ 3D Graph</button>
<button class="view-btn" onclick="app.setMode('tree')" id="btn-mode-tree">🌳 Tree</button>
<button class="view-btn" onclick="app.setMode('radial')" id="btn-mode-radial">🎯 Radial</button>
<button class="view-btn" onclick="app.setMode('list')" id="btn-mode-list">📝 List</button>
<button class="view-btn" onclick="app.captureGraph()">📷 Capture</button>
</div>
</div>
<div id="right-panel" class="panel">
<div class="tab-bar">
<div class="tab active" onclick="ui.switchRightTab('prop')">プロパティ</div>
<div class="tab" onclick="ui.switchRightTab('tags')">タグ</div>
<div class="tab" onclick="ui.switchRightTab('global')">設定</div>
</div>
<div id="tab-prop" style="flex:1; overflow-y:auto;">
<div id="prop-node" class="hidden">
<div class="panel-header">選択中のノード</div>
<div class="prop-group">
<label class="prop-label">名前</label>
<input type="text" id="node-name">
<label class="prop-label">グループ</label>
<input type="text" id="node-group" list="group-suggestions" placeholder="Group Name">
<datalist id="group-suggestions"></datalist>
<label class="prop-label">色設定</label>
<div style="display:flex; gap:5px;">
<input type="color" id="node-color" style="flex:1">
<button class="action-btn" style="margin-top:0; width:auto;" onclick="document.getElementById('node-color').value='#00e5ff'; app.updateNodeProp();">Reset</button>
</div>
<label class="prop-label">アイコンURL</label>
<input type="text" id="node-icon" placeholder="https://...">
<label class="prop-label">タグ</label>
<input type="text" id="node-tags" placeholder="カンマ区切り">
<label class="prop-label">接続</label>
<select id="link-select" onchange="app.addManualLink(this.value)">
<option value="">+ 接続先...</option>
</select>
<div id="manual-links-list" style="margin-top:5px;"></div>
<label class="prop-label" style="margin-top:10px;">メモ</label>
<textarea id="node-body"></textarea>
</div>
<div class="prop-group">
<button class="action-btn" onclick="app.setPathTarget()">経路探索ゴールに設定</button>
<button class="action-btn btn-danger" onclick="app.deleteNode(app.selectedNodeId)">削除</button>
</div>
</div>
<div id="prop-empty" style="padding:20px; text-align:center; color:#555;">ノードを選択してください</div>
</div>
<div id="tab-tags" class="hidden" style="flex:1; overflow-y:auto;">
<div class="panel-header">タグ配色設定</div>
<div id="tag-list"></div>
</div>
<div id="tab-global" class="hidden" style="flex:1; overflow-y:auto;">
<div class="panel-header">表示カスタマイズ</div>
<div class="prop-group">
<label class="prop-label">接続線長: <span id="conf-link-val">12</span></label>
<input type="range" id="conf-link" min="5" max="30" value="12" oninput="app.updateConfig('linkLength', this.value)">
<label class="prop-label">アイコンサイズ: <span id="conf-size-val">1.5</span></label>
<input type="range" id="conf-size" min="0.5" max="4.0" step="0.1" value="1.5" oninput="app.updateConfig('nodeSize', this.value)">
<label class="prop-label">文字サイズ: <span id="conf-text-val">1.2</span></label>
<input type="range" id="conf-text" min="0.5" max="3.0" step="0.1" value="1.2" oninput="app.updateConfig('textSize', this.value)">
</div>
<div class="panel-header">ビュー設定</div>
<div class="prop-group">
<label class="prop-label">可視範囲 (Depth): <span id="range-val" style="color:var(--accent)">2</span></label>
<input type="range" id="range-slider" min="1" max="10" value="2">
</div>
<div class="prop-group">
<button class="action-btn btn-primary" onclick="app.visualizer.resetCamera()">カメラリセット (Home)</button>
<button class="action-btn" onclick="app.explode()">レイアウト拡散</button>
</div>
</div>
</div>
</div>
<div id="context-menu">
<div class="ctx-item" id="ctx-connect">ここに接続</div>
<div class="ctx-item" id="ctx-copy">コピー</div>
<div class="ctx-item" id="ctx-paste">貼り付け</div>
<div class="ctx-item" id="ctx-delete" style="color:#ff6b6b;">削除</div>
</div>
<input type="file" id="file-input" style="display:none" accept=".json">
<script>
/**
* Logic 7.0 Ultimate
*/
class App {
constructor() {
this.nodes = [];
this.nextId = 1;
this.selectedNodeId = null;
this.targetNodeId = null;
this.fileHandle = null; // for File System Access API
this.visibleRange = 2;
this.searchQuery = "";
this.physicsParams = { repulsion: 800, spring: 0.03, damping: 0.92 };
this.config = { linkLength: 12, nodeSize: 1.5, textSize: 1.2 };
this.visibleNodeIds = new Set();
this.collapsedGroups = new Set();
this.groupCenters = {};
this.pathLinks = new Set();
this.tagColors = {};
this.mode = 'graph';
this.layoutTargets = {};
this.ui = new UIManager(this);
this.visualizer = new Visualizer(this);
this.visualizer.init();
// No auto load - start fresh
this.refresh();
this.physicsLoop();
}
// --- File Operations ---
newFile() {
if(this.nodes.length > 0 && !confirm("現在の内容は消去されます。よろしいですか?")) return;
this.nodes = [];
this.nextId = 1;
this.selectedNodeId = null;
this.tagColors = {};
this.fileHandle = null;
this.ui.setStatus("New File Created");
this.refresh();
}
async openFile() {
// Try File System Access API
if(window.showOpenFilePicker) {
try {
const [handle] = await window.showOpenFilePicker({ types: [{ description: 'JSON Files', accept: {'application/json': ['.json']} }] });
const file = await handle.getFile();
const text = await file.text();
this.loadJsonData(text);
this.fileHandle = handle;
this.ui.setStatus("File Opened: " + file.name);
} catch(e) { console.log(e); }
} else {
// Fallback
document.getElementById('file-input').click();
}
}
async saveFile() {
if(this.fileHandle) {
try {
const writable = await this.fileHandle.createWritable();
await writable.write(this.getJsonString());
await writable.close();
this.ui.setStatus("Saved.");
} catch(e) { alert("Save failed: " + e); }
} else {
this.saveAsFile();
}
}
async saveAsFile() {
if(window.showSaveFilePicker) {
try {
const handle = await window.showSaveFilePicker({ types: [{ description: 'JSON Files', accept: {'application/json': ['.json']} }] });
const writable = await handle.createWritable();
await writable.write(this.getJsonString());
await writable.close();
this.fileHandle = handle;
this.ui.setStatus("Saved As...");
} catch(e) { console.log(e); }
} else {
const blob = new Blob([this.getJsonString()], {type:"application/json"});
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download="visible_grid_data.json"; a.click();
}
}
getJsonString() {
return JSON.stringify({ nodes: this.nodes, nextId: this.nextId, tagColors: this.tagColors, config: this.config }, null, 2);
}
loadJsonData(jsonStr) {
try {
const data = JSON.parse(jsonStr);
this.nodes = data.nodes || [];
this.nextId = data.nextId || 1;
this.tagColors = data.tagColors || {};
if(data.config) {
this.config = {...this.config, ...data.config};
this.ui.updateConfigUI();
}
this.nodes.forEach(n => {
if(!n.group) n.group = "Default";
if(!n.color) n.color = "#00e5ff";
if(!n.links) n.links = [];
});
this.refresh();
} catch(e) { alert("Invalid JSON"); }
}
// --- Logic ---
updateConfig(key, val) {
this.config[key] = parseFloat(val);
this.ui.updateConfigUI(); // Update label text
this.refresh();
}
addNode() {
const id = this.nextId++;
this.nodes.push({
id: id, name: "Node " + id, group: "Default", tags: [], links: [], body: "",
x: (Math.random()-0.5)*10, y: (Math.random()-0.5)*10, z: (Math.random()-0.5)*10,
vx:0, vy:0, vz:0, color: "#00e5ff", icon: ""
});
this.selectNode(id);
if(this.mode !== 'graph' && this.mode !== 'list') this.calcLayout();
}
deleteNode(id) {
this.nodes.forEach(n => { n.links = n.links.filter(lid => lid != id); });
this.nodes = this.nodes.filter(n => n.id !== id);
if(this.selectedNodeId === id) this.selectedNodeId = null;
this.refresh();
if(this.mode !== 'graph' && this.mode !== 'list') this.calcLayout();
}
setMode(m) {
this.mode = m;
document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active'));
document.getElementById('btn-mode-'+m).classList.add('active');
const isList = (m === 'list');
document.getElementById('canvas-container').style.display = isList ? 'none' : 'block';
document.getElementById('main-list-view').style.display = isList ? 'block' : 'none';
document.getElementById('cam-mode-indicator').style.display = isList ? 'none' : 'block';
if(m === 'graph') this.explode();
else if(!isList) this.calcLayout();
this.refresh();
}
calcLayout() {
let rootId = this.selectedNodeId;
if(!rootId && this.nodes.length > 0) rootId = this.nodes[0].id;
this.layoutTargets = {};
if(!rootId) return;
if(this.mode === 'radial') {
// Selected Node at (0,0,0)
const rootNode = this.nodes.find(n => n.id === rootId);
this.layoutTargets[rootId] = {x:0, y:0, z:0};
const neighbors = [];
this.nodes.forEach(n => {
if(n.id !== rootId && (rootNode.links.includes(n.id) || n.links.includes(rootId))) {
neighbors.push(n.id);
}
});
const r1 = 20;
neighbors.forEach((nid, i) => {
const angle = (Math.PI * 2 / neighbors.length) * i;
this.layoutTargets[nid] = { x: Math.cos(angle) * r1, y: Math.sin(angle) * r1, z: 0 };
});
// Others loose
this.nodes.forEach(n => {
if(n.id !== rootId && !neighbors.includes(n.id)) {
this.layoutTargets[n.id] = {x: (Math.random()-0.5)*100, y: (Math.random()-0.5)*100, z: -50};
}
});
} else if(this.mode === 'tree') {
const queue = [{id: rootId, level: 0}];
const visited = new Set([rootId]);
this.layoutTargets[rootId] = {x:0, y:0, z:0};
while(queue.length > 0) {
const curr = queue.shift();
const n = this.nodes.find(x=>x.id===curr.id);
const children = [];
this.nodes.forEach(other => {
if((n.links.includes(other.id) || other.links.includes(n.id)) && !visited.has(other.id)) {
children.push(other.id); visited.add(other.id);
}
});
const width = 10; const height = 10;
children.forEach((cid, i) => {
const offset = (i - (children.length-1)/2) * width;
const parentPos = this.layoutTargets[curr.id];
this.layoutTargets[cid] = { x: parentPos.x + offset, y: -(curr.level + 1) * height, z: 0 };
queue.push({id: cid, level: curr.level + 1});
});
}
this.nodes.forEach(n => { if(!visited.has(n.id)) this.layoutTargets[n.id] = {x:0, y:-100, z:0}; });
}
}
physicsLoop() {
requestAnimationFrame(() => this.physicsLoop());
const activeNodes = this.nodes.filter(n => this.visibleNodeIds.has(n.id));
const dt = 0.05;
if (this.mode === 'graph') {
for(let i=0; i<activeNodes.length; i++) {
for(let j=i+1; j<activeNodes.length; j++) {
const n1 = activeNodes[i], n2 = activeNodes[j];
let dx = n1.x - n2.x, dy = n1.y - n2.y, dz = n1.z - n2.z;
let dSq = dx*dx + dy*dy + dz*dz || 0.1;
let f = this.physicsParams.repulsion / dSq;
let d = Math.sqrt(dSq);
n1.vx += (dx/d)*f*dt; n1.vy += (dy/d)*f*dt; n1.vz += (dz/d)*f*dt;
n2.vx -= (dx/d)*f*dt; n2.vy -= (dy/d)*f*dt; n2.vz -= (dz/d)*f*dt;
}
}
activeNodes.forEach(n => {
if(this.groupCenters[n.group] && this.groupCenters[n.group].count > 1) {
const c = this.groupCenters[n.group];
n.vx -= (n.x - c.x)*0.02*dt; n.vy -= (n.y - c.y)*0.02*dt; n.vz -= (n.z - c.z)*0.02*dt;
}
});
activeNodes.forEach(n1 => {
n1.links.forEach(tid => {
const n2 = activeNodes.find(n => n.id === tid);
if(n2) {
let dx = n2.x - n1.x, dy = n2.y - n1.y, dz = n2.z - n1.z;
let d = Math.sqrt(dx*dx + dy*dy + dz*dz) || 0.1;
let f = (d - this.config.linkLength) * 0.1;
n1.vx += (dx/d)*f*dt; n1.vy += (dy/d)*f*dt; n1.vz += (dz/d)*f*dt;
n2.vx -= (dx/d)*f*dt; n2.vy -= (dy/d)*f*dt; n2.vz -= (dz/d)*f*dt;
}
});
});
} else if (this.mode !== 'list') {
activeNodes.forEach(n => {
const t = this.layoutTargets[n.id];
if(t) {
n.vx += (t.x - n.x) * 0.1; n.vy += (t.y - n.y) * 0.1; n.vz += (t.z - n.z) * 0.1;
}
});
}
activeNodes.forEach(n => {
n.vx *= this.physicsParams.damping; n.vy *= this.physicsParams.damping; n.vz *= this.physicsParams.damping;
n.x += n.vx; n.y += n.vy; n.z += n.vz;
});
if(this.mode !== 'list') this.visualizer.sync();
}
captureGraph() {
const w = prompt("Width (px)", "1920"); const h = prompt("Height (px)", "1080");
if(w && h) this.visualizer.capture(parseInt(w), parseInt(h));
}
updateNodeProp() {
if(!this.selectedNodeId) return;
const n = this.nodes.find(x => x.id === this.selectedNodeId);
if(n) {
n.name = document.getElementById('node-name').value;
n.group = document.getElementById('node-group').value || "Default";
n.color = document.getElementById('node-color').value;
n.icon = document.getElementById('node-icon').value;
n.body = document.getElementById('node-body').value;
const tagVal = document.getElementById('node-tags').value;
n.tags = tagVal.split(',').map(t => t.trim()).filter(t => t);
n.tags.forEach(t => { if(!this.tagColors[t]) this.tagColors[t] = this.getRandomColor(); });
this.refresh();
}
}
addManualLink(tid) {
if(!this.selectedNodeId || !tid) return;
const n = this.nodes.find(x => x.id === this.selectedNodeId);
const t = parseInt(tid);
if(n && t !== n.id && !n.links.includes(t)) { n.links.push(t); this.refresh(); this.ui.updateSelection(); }
}
removeManualLink(tid) {
const n = this.nodes.find(x => x.id === this.selectedNodeId);
if(n) { n.links = n.links.filter(id => id !== tid); this.refresh(); this.ui.updateSelection(); }
}
selectNode(id) {
this.selectedNodeId = id;
this.pathLinks.clear();
if(this.targetNodeId && id) this.calculatePath(id, this.targetNodeId);
if(this.mode === 'radial' || this.mode === 'tree') this.calcLayout();
this.refresh();
this.ui.updateSelection();
}
setPathTarget() { if(this.selectedNodeId) { this.targetNodeId=this.selectedNodeId; this.refresh(); } }
clearPath() { this.targetNodeId=null; this.pathLinks.clear(); this.refresh(); }
calculatePath(s,e) {
const q=[[s]], v=new Set([s]);
while(q.length){
const p=q.shift(), c=p[p.length-1];
if(c===e){ p.forEach((id,i)=>{ if(i<p.length-1){ this.pathLinks.add(id+'-'+p[i+1]); this.pathLinks.add(p[i+1]+'-'+id); }}); return; }
const n=this.nodes.find(x=>x.id===c);
this.nodes.forEach(o=>{
if(!v.has(o.id) && (n.links.includes(o.id)||o.links.includes(n.id)||n.tags.some(t=>o.tags.includes(t)))) { v.add(o.id); q.push([...p,o.id]); }
});
}
}
toggleGroup(g) { if(this.collapsedGroups.has(g)) this.collapsedGroups.delete(g); else this.collapsedGroups.add(g); this.refresh(); }
updateVisibility() {
this.visibleNodeIds.clear(); this.groupCenters = {};
const sums = {};
this.nodes.forEach(n => {
if(!sums[n.group]) sums[n.group] = {x:0, y:0, z:0, count:0};
sums[n.group].x += n.x; sums[n.group].y += n.y; sums[n.group].z += n.z; sums[n.group].count++;
});
for(let g in sums) if(sums[g].count>0) this.groupCenters[g] = {x:sums[g].x/sums[g].count, y:sums[g].y/sums[g].count, z:sums[g].z/sums[g].count, count:sums[g].count};
const sv = this.ui.iSearch.value.toLowerCase();
this.nodes.forEach(n => {
let vis = false;
if(sv) {
if(n.name.toLowerCase().includes(sv) || n.tags.some(t=>t.toLowerCase().includes(sv))) vis = true;
} else if (this.selectedNodeId === null) {
vis = true;
} else {
const cn = this.nodes.find(x=>x.id===this.selectedNodeId);
if(cn) {
if(n.id===cn.id) vis=true;
else if(cn.links.includes(n.id)||n.links.includes(cn.id)||cn.tags.some(t=>n.tags.includes(t))) vis=true;
if(this.visibleRange > 1) vis = true;
} else vis = true;
}
if(vis && !this.collapsedGroups.has(n.group)) this.visibleNodeIds.add(n.id);
});
}
refresh() {
this.updateVisibility();
this.ui.updateLists();
if(this.mode !== 'list') this.visualizer.updateScene();
// saveData not called every frame, but on logical changes, which refresh covers.
}
explode() { this.physicsParams.repulsion = 3000; setTimeout(()=>this.physicsParams.repulsion=800, 500); }
getRandomColor() { return '#' + Math.floor(Math.random()*16777215).toString(16).padStart(6,'0'); }
getTagColor(t) { return this.tagColors[t] || '#888'; }
}
class Visualizer {
constructor(app) {
this.app = app;
this.container = document.getElementById('canvas-container');
this.scene = null;
this.meshMap = new Map();
this.groupMeshMap = new Map();
this.linkObjects = [];
this.raycaster = new THREE.Raycaster();
this.mouse = new THREE.Vector2();
this.texLoader = new THREE.TextureLoader();
this.textureCache = {};
}
init() {
this.scene = new THREE.Scene();
this.scene.fog = new THREE.FogExp2(0x0b0c10, 0.005);
const w = this.container.clientWidth; const h = this.container.clientHeight;
this.camera = new THREE.PerspectiveCamera(60, w/h, 0.1, 5000);
this.camera.position.set(0, 30, 60);
this.renderer = new THREE.WebGLRenderer({antialias: true, alpha: true, preserveDrawingBuffer: true});
this.renderer.setSize(w, h);
this.container.appendChild(this.renderer.domElement);
this.controls = new THREE.OrbitControls(this.camera, this.renderer.domElement);
// High brightness lighting
this.scene.add(new THREE.AmbientLight(0xffffff, 1.2)); // Bright Ambient
const dl = new THREE.DirectionalLight(0xffffff, 0.8); dl.position.set(20,50,20); this.scene.add(dl);
const grid = new THREE.GridHelper(500, 100, 0x333333, 0x111111);
grid.position.y = -50; grid.material.opacity = 0.3; grid.material.transparent=true;
this.scene.add(grid);
this.selectionRing = new THREE.Mesh(new THREE.RingGeometry(2.4, 2.6, 32), new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.DoubleSide, transparent:true }));
this.scene.add(this.selectionRing);
window.addEventListener('resize', () => {
this.camera.aspect = this.container.clientWidth/this.container.clientHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(this.container.clientWidth, this.container.clientHeight);
});
this.renderer.domElement.addEventListener('pointerdown', (e) => this.onClick(e));
document.addEventListener('keydown', (e) => {
if(e.code === 'PageUp') this.zoom(1);
if(e.code === 'PageDown') this.zoom(-1);
if(e.code === 'Home') this.resetCamera();
});
this.animate();
}
setCamMode(m) {
const el = document.getElementById('cam-mode-indicator');
if(m==='rotate') { this.controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.PAN }; el.innerText = "Mode: Rotate"; }
if(m==='pan') { this.controls.mouseButtons = { LEFT: THREE.MOUSE.PAN, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.ROTATE }; el.innerText = "Mode: Pan"; }
if(m==='zoom') { this.controls.mouseButtons = { LEFT: THREE.MOUSE.DOLLY, MIDDLE: THREE.MOUSE.ROTATE, RIGHT: THREE.MOUSE.PAN }; el.innerText = "Mode: Zoom"; }
}
focusSelection() {
if(this.app.selectedNodeId) {
const n = this.app.nodes.find(x=>x.id===this.app.selectedNodeId);
if(n) {
// Smoothly move target
this.controls.target.set(n.x, n.y, n.z);
this.controls.update();
}
}
}
zoom(dir) {
const target = this.controls.target; const camPos = this.camera.position;
const vec = new THREE.Vector3().subVectors(camPos, target);
const len = vec.length();
let newLen = dir > 0 ? Math.max(5, len * 0.8) : Math.min(1000, len * 1.2);
vec.setLength(newLen); this.camera.position.copy(target).add(vec); this.controls.update();
}
capture(targetW, targetH) {
const originalSize = new THREE.Vector2(); this.renderer.getSize(originalSize);
const originalAspect = this.camera.aspect;
this.renderer.setSize(targetW, targetH); this.camera.aspect = targetW / targetH; this.camera.updateProjectionMatrix();
if(this.app.selectedNodeId) { const n = this.app.nodes.find(x=>x.id===this.app.selectedNodeId); if(n) this.controls.target.set(n.x, n.y, n.z); }
this.controls.update(); this.renderer.render(this.scene, this.camera);
const link = document.createElement('a'); link.download = `graph_capture_${Date.now()}.png`; link.href = this.renderer.domElement.toDataURL('image/png'); link.click();
this.renderer.setSize(originalSize.x, originalSize.y); this.camera.aspect = originalAspect; this.camera.updateProjectionMatrix(); this.controls.update();
}
updateScene() {
const visibleSet = this.app.visibleNodeIds;
this.meshMap.forEach((obj, id) => { if(!visibleSet.has(id)) { this.scene.remove(obj.mesh); this.scene.remove(obj.label); this.meshMap.delete(id); } });
const geo = new THREE.SphereGeometry(1, 32, 32);
visibleSet.forEach(id => {
const n = this.app.nodes.find(x=>x.id===id);
const size = this.app.config.nodeSize;
if(!this.meshMap.has(id)) {
const mat = new THREE.MeshPhongMaterial({
color: n.color,
emissive: n.color,
emissiveIntensity: 0.5,
shininess: 30
});
if(n.icon) {
if(!this.textureCache[n.icon]) this.textureCache[n.icon] = this.texLoader.load(n.icon);
mat.map = this.textureCache[n.icon];
mat.color.setHex(0xffffff); // White base for texture
mat.emissive.setHex(0x333333);
}
const mesh = new THREE.Mesh(geo, mat);
mesh.userData = { id: id, type: 'node' };
mesh.scale.setScalar(size);
this.scene.add(mesh);
const label = this.createLabel(n.name, this.app.config.textSize, "#ffffff");
this.scene.add(label);
this.meshMap.set(id, {mesh, label});
} else {
const obj = this.meshMap.get(id);
if(n.icon) {
obj.mesh.material.color.setHex(0xffffff);
// handle tex update if needed... simplified
} else {
obj.mesh.material.color.set(n.color);
obj.mesh.material.emissive.set(n.color);
}
obj.mesh.scale.setScalar(size);
}
});
this.groupMeshMap.forEach((mesh, gName) => { if(!this.app.collapsedGroups.has(gName) || !this.app.groupCenters[gName]) { this.scene.remove(mesh); this.groupMeshMap.delete(gName); } });
this.app.collapsedGroups.forEach(gName => {
if(!this.groupMeshMap.has(gName)) {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(4,4,4), new THREE.MeshPhongMaterial({color:0xffaa00, wireframe:true, emissive:0xffaa00}));
mesh.userData = { group: gName, type: 'group' };
const l = this.createLabel(`[G] ${gName}`, this.app.config.textSize*1.2, '#ffcc00'); l.position.y=3; mesh.add(l);
this.scene.add(mesh); this.groupMeshMap.set(gName, mesh);
}
});
this.linkObjects.forEach(l => { this.scene.remove(l.obj); if(l.sprite) this.scene.remove(l.sprite); });
this.linkObjects = [];
const visArr = Array.from(visibleSet);
const getPos = (nid) => { const n = this.app.nodes.find(x=>x.id===nid); return this.app.collapsedGroups.has(n.group) ? this.app.groupCenters[n.group] : n; };
const selId = this.app.selectedNodeId;
visArr.forEach(nid => {
const n1 = this.app.nodes.find(x=>x.id===nid); const p1 = getPos(nid);
n1.links.forEach(tid => {
const n2 = this.app.nodes.find(x=>x.id===tid);
if(!n2 || (!visibleSet.has(tid) && !this.app.collapsedGroups.has(n2.group))) return;
const p2 = getPos(tid);
if(p1 !== p2) {
const isSel = (n1.id === selId || n2.id === selId);
this.createLink(p1, p2, 0xffffff, isSel ? 1.0 : 0.4, isSel, null);
}
});
});
for(let i=0; i<visArr.length; i++) {
for(let j=i+1; j<visArr.length; j++) {
const n1 = this.app.nodes.find(x=>x.id===visArr[i]); const n2 = this.app.nodes.find(x=>x.id===visArr[j]);
const shared = n1.tags.filter(t => n2.tags.includes(t));
if(shared.length > 0) {
const p1 = getPos(n1.id); const p2 = getPos(n2.id);
if(p1!==p2) {
const isSel = (n1.id === selId || n2.id === selId);
this.createLink(p1, p2, this.app.getTagColor(shared[0]), isSel?0.9:0.5, isSel, shared[0]);
}
}
}
}
}
createLink(p1, p2, colorHex, opacity, isLaser, labelText) {
let obj;
if (isLaser) {
const dist = new THREE.Vector3(p1.x,p1.y,p1.z).distanceTo(new THREE.Vector3(p2.x,p2.y,p2.z));
const geo = new THREE.CylinderGeometry(0.3, 0.3, dist, 8, 1, true); geo.rotateX(Math.PI / 2);
const mat = new THREE.MeshBasicMaterial({ color: colorHex, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending });
obj = new THREE.Mesh(geo, mat);
} else {
const pts = [new THREE.Vector3(p1.x,p1.y,p1.z), new THREE.Vector3(p2.x,p2.y,p2.z)];
const geo = new THREE.BufferGeometry().setFromPoints(pts);
obj = new THREE.Line(geo, new THREE.LineBasicMaterial({ color: colorHex, transparent: true, opacity: opacity }));
}
this.scene.add(obj);
let sprite = null;
if(labelText) {
sprite = this.createLabel(labelText, this.app.config.textSize*0.8, colorHex);
sprite.visible = false;
this.scene.add(sprite);
}
this.linkObjects.push({ obj, sprite, p1, p2, isLaser, labelText });
}
createLabel(text, scaleMod, color="#ffffff") {
const cvs = document.createElement('canvas'); const ctx = cvs.getContext('2d');
cvs.width=512; cvs.height=128; ctx.font="Bold 70px Arial";
ctx.shadowColor="black"; ctx.shadowBlur=6; ctx.shadowOffsetX=3; ctx.shadowOffsetY=3;
ctx.fillStyle=color; ctx.textAlign="center"; ctx.fillText(text, 256, 90);
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map:new THREE.CanvasTexture(cvs), transparent:true, depthTest:false }));
sp.scale.set(8 * scaleMod, 2 * scaleMod, 1);
sp.renderOrder = 999;
return sp;
}
sync() {
if(!this.scene) return;
const size = this.app.config.nodeSize;
this.meshMap.forEach((obj, id) => {
const n = this.app.nodes.find(x => x.id === id);
if(n) { obj.mesh.position.set(n.x, n.y, n.z); obj.label.position.set(n.x, n.y + size + 1.5, n.z); }
});
this.groupMeshMap.forEach((mesh, gName) => { const c = this.app.groupCenters[gName]; if(c) mesh.position.set(c.x, c.y, c.z); });
if(this.app.selectedNodeId) {
const n = this.app.nodes.find(x=>x.id===this.app.selectedNodeId);
if(n && this.meshMap.has(n.id)) {
this.selectionRing.visible = true;
this.selectionRing.position.set(n.x, n.y, n.z);
this.selectionRing.lookAt(this.camera.position);
this.selectionRing.scale.setScalar(size);
} else this.selectionRing.visible = false;
} else this.selectionRing.visible = false;
this.linkObjects.forEach(l => {
const v1 = new THREE.Vector3(l.p1.x, l.p1.y, l.p1.z); const v2 = new THREE.Vector3(l.p2.x, l.p2.y, l.p2.z);
const mid = v1.clone().add(v2).multiplyScalar(0.5);
if(l.isLaser) { l.obj.position.copy(mid); l.obj.lookAt(v2); const dist = v1.distanceTo(v2); l.obj.scale.set(1, 1, dist); }
else { const pos = l.obj.geometry.attributes.position.array; pos[0]=v1.x; pos[1]=v1.y; pos[2]=v1.z; pos[3]=v2.x; pos[4]=v2.y; pos[5]=v2.z; l.obj.geometry.attributes.position.needsUpdate = true; }
if(l.sprite) l.sprite.position.copy(mid);
});
this.updateHighlights();
}
updateHighlights() {
const selId = this.app.selectedNodeId; const selNode = selId ? this.app.nodes.find(x=>x.id===selId) : null; const baseText = this.app.config.textSize;
this.meshMap.forEach((obj, id) => { if(id === selId) obj.label.scale.set(10*baseText, 2.5*baseText, 1); else obj.label.scale.set(7*baseText, 1.75*baseText, 1); });
this.linkObjects.forEach(l => { if(l.sprite) { let c = false; if(selNode) { if(l.p1 === selNode || l.p2 === selNode) c = true; } if(c) { l.sprite.visible = true; l.sprite.scale.set(5*baseText, 1.25*baseText, 1); } else l.sprite.visible = false; } });
}
onClick(e) {
const rect = this.renderer.domElement.getBoundingClientRect();
this.mouse.x = ((e.clientX - rect.left)/rect.width)*2 - 1; this.mouse.y = -((e.clientY - rect.top)/rect.height)*2 + 1;
this.raycaster.setFromCamera(this.mouse, this.camera);
const targets = []; this.meshMap.forEach(v=>targets.push(v.mesh)); this.groupMeshMap.forEach(v=>targets.push(v));
const intersects = this.raycaster.intersectObjects(targets);
if(intersects.length > 0) { const ud = intersects[0].object.userData; if(ud.type === 'node') this.app.selectNode(ud.id); else if (ud.type === 'group') this.app.toggleGroup(ud.group); } else this.app.selectNode(null);
}
resetCamera() { this.camera.position.set(0,30,60); this.controls.reset(); }
animate() { requestAnimationFrame(()=>this.animate()); this.controls.update(); this.renderer.render(this.scene, this.camera); }
}
class UIManager {
constructor(app) {
this.app = app;
this.inputs = { name: document.getElementById('node-name'), group: document.getElementById('node-group'), color: document.getElementById('node-color'), icon: document.getElementById('node-icon'), tags: document.getElementById('node-tags'), body: document.getElementById('node-body'), linkSelect: document.getElementById('link-select') };
this.iSearch = document.getElementById('search-input'); this.mainListView = document.getElementById('main-list-view');
this.bindEvents();
}
setStatus(msg) { document.getElementById('status-bar').innerText = msg; }
bindEvents() {
['name','group','color','icon','tags','body'].forEach(k => { this.inputs[k].addEventListener('change', () => this.app.updateNodeProp()); });
document.getElementById('range-slider').oninput = (e) => { this.app.visibleRange = parseInt(e.target.value); document.getElementById('range-val').innerText = this.app.visibleRange; this.app.refresh(); };
this.iSearch.oninput = () => this.app.refresh();
document.getElementById('file-input').onchange = (e) => {
const file = e.target.files[0];
if(!file) return;
const r = new FileReader();
r.onload = (ev) => { this.app.loadJsonData(ev.target.result); this.setStatus("Loaded: " + file.name); };
r.readAsText(file);
};
document.addEventListener('click', (e) => {
if(!e.target.closest('.menu-item')) document.querySelectorAll('.dropdown').forEach(d => d.style.display='none');
else {
const dd = e.target.closest('.menu-item').querySelector('.dropdown');
document.querySelectorAll('.dropdown').forEach(d => { if(d!==dd) d.style.display='none'; });
if(dd) dd.style.display = (dd.style.display==='block' ? 'none' : 'block');
}
});
const cm = document.getElementById('context-menu'); document.addEventListener('click', () => cm.style.display='none');
document.getElementById('ctx-connect').onclick = () => this.app.addManualLink(this.ctxId);
document.getElementById('ctx-delete').onclick = () => this.app.deleteNode(this.ctxId);
}
switchLeftTab(mode) {
document.querySelectorAll('#left-panel .tab').forEach(e=>e.classList.remove('active'));
document.querySelectorAll('#left-panel .tab')[mode==='nodes'?0:1].classList.add('active');
document.getElementById('tab-nodes').classList.toggle('hidden', mode!=='nodes');
document.getElementById('tab-groups').classList.toggle('hidden', mode!=='groups');
}
switchRightTab(mode) {
document.querySelectorAll('#right-panel .tab').forEach(e=>e.classList.remove('active'));
const idx = {'prop':0, 'tags':1, 'global':2}[mode];
document.querySelectorAll('#right-panel .tab')[idx].classList.add('active');
['prop','tags','global'].forEach(m => document.getElementById('tab-'+m).classList.add('hidden'));
document.getElementById('tab-'+mode).classList.remove('hidden');
}
updateConfigUI() {
document.getElementById('conf-link').value = this.app.config.linkLength; document.getElementById('conf-link-val').innerText = this.app.config.linkLength;
document.getElementById('conf-size').value = this.app.config.nodeSize; document.getElementById('conf-size-val').innerText = this.app.config.nodeSize;
document.getElementById('conf-text').value = this.app.config.textSize; document.getElementById('conf-text-val').innerText = this.app.config.textSize;
}
updateSelection() {
const nid = this.app.selectedNodeId;
if(nid) {
document.getElementById('prop-node').classList.remove('hidden'); document.getElementById('prop-empty').classList.add('hidden');
const n = this.app.nodes.find(x=>x.id===nid);
this.inputs.name.value = n.name; this.inputs.group.value = n.group; this.inputs.color.value = n.color; this.inputs.icon.value = n.icon || "";
this.inputs.tags.value = n.tags.join(', '); this.inputs.body.value = n.body;
this.inputs.linkSelect.innerHTML = '<option value="">+ 接続先...</option>';
this.app.nodes.forEach(o => { if(o.id !== nid && !n.links.includes(o.id)) this.inputs.linkSelect.appendChild(new Option(o.name, o.id)); });
const list = document.getElementById('manual-links-list'); list.innerHTML = '';
n.links.forEach(lid => { const t = this.app.nodes.find(x=>x.id===lid); if(t) list.innerHTML += `<div style="font-size:0.8em; padding:2px; border-bottom:1px solid #333;">🔗 ${t.name} <span style="color:#f55; cursor:pointer; float:right;" onclick="app.removeManualLink(${lid})">×</span></div>`; });
} else { document.getElementById('prop-node').classList.add('hidden'); document.getElementById('prop-empty').classList.remove('hidden'); }
this.updateLists();
}
updateLists() {
const genItem = (n) => {
let iconHtml = n.icon ? `<div class="node-icon-visual" style="background-image:url('${n.icon}')"></div>` : `<div class="node-icon-visual"><div class="node-ball" style="color:${n.color}"></div></div>`;
return `${iconHtml}<div class="node-info"><div class="node-name">${n.name}</div><div class="node-meta">${n.group}</div></div>`;
};
const nl = document.getElementById('node-list'); nl.innerHTML = '';
const term = this.iSearch.value.toLowerCase(); const groups = new Set();
this.app.nodes.forEach(n => {
groups.add(n.group); if(term && !n.name.toLowerCase().includes(term)) return;
const el = document.createElement('div'); el.className = 'list-box-item'; if(n.id === this.app.selectedNodeId) el.classList.add('active');
el.innerHTML = genItem(n); el.onclick = () => this.app.selectNode(n.id);
el.oncontextmenu = (e) => { e.preventDefault(); this.ctxId=n.id; const cm=document.getElementById('context-menu'); cm.style.display='block'; cm.style.left=e.pageX+'px'; cm.style.top=e.pageY+'px'; };
nl.appendChild(el);
});
if(this.app.mode === 'list') {
this.mainListView.innerHTML = '';
this.app.nodes.forEach(n => {
if(term && !n.name.toLowerCase().includes(term)) return;
const el = document.createElement('div'); el.className = 'list-box-item'; if(n.id === this.app.selectedNodeId) el.classList.add('active');
el.innerHTML = genItem(n); el.onclick = () => this.app.selectNode(n.id);
this.mainListView.appendChild(el);
});
}
const gl = document.getElementById('group-list'); gl.innerHTML = '';
Array.from(groups).sort().forEach(g => {
const isCollapsed = this.app.collapsedGroups.has(g); const div = document.createElement('div'); div.className = 'group-item';
div.innerHTML = `<div class="group-header" onclick="app.toggleGroup('${g}')"><span><span class="group-toggle">[${isCollapsed?'+':'-'}]</span> ${g}</span></div>`; gl.appendChild(div);
});
const dl = document.getElementById('group-suggestions'); dl.innerHTML = ''; groups.forEach(g => dl.appendChild(new Option(g)));
const tl = document.getElementById('tag-list'); tl.innerHTML = '';
for(let tag in this.app.tagColors) {
const row = document.createElement('div'); row.className = 'tag-row'; const col = this.app.tagColors[tag];
row.innerHTML = `<input type="color" value="${col}" onchange="app.tagColors['${tag}']=this.value; app.refresh();" class="color-preview" style="padding:0; border:none;"><span style="flex:1; color:${col}">${tag}</span>`;
tl.appendChild(row);
}
}
}
class AIManager {
constructor(app) { this.app = app; this.modal = document.getElementById('ai-modal'); }
open() { this.modal.style.display = 'flex'; } close() { this.modal.style.display = 'none'; }
generatePrompt() { const req = document.getElementById('ai-input').value; const p = `あなたはデータ構造化のプロです。ユーザーの要望に基づき、ネットワークグラフのJSON配列を作成してください。\n【要望】\n${req}\n【必須フォーマット】\n[\n {\n "name": "ノード名",\n "group": "グループ名",\n "tags": ["タグ1"],\n "color": "#HEX",\n "icon": "URL",\n "body": "Markdown",\n "connects_to": ["接続先名"]\n }\n]\nMarkdownコードブロックは不要。純粋なJSONのみ。`; document.getElementById('ai-prompt-output').value = p; }
copyPrompt() { document.getElementById('ai-prompt-output').select(); document.execCommand('copy'); }
apply() { try { let raw = document.getElementById('ai-json-input').value.trim(); raw = raw.replace(/```json/g, '').replace(/```/g, '').trim(); const data = JSON.parse(raw); if(!Array.isArray(data)) throw new Error("Not Array"); const nameMap = {}; data.forEach(d => { const id = this.app.nextId++; const node = { id: id, name: d.name, group: d.group || "Imported", tags: d.tags || [], color: d.color || "#00e5ff", icon: d.icon || "", body: d.body || "", links: [], x: (Math.random()-0.5)*20, y: (Math.random()-0.5)*20, z: (Math.random()-0.5)*20, vx:0, vy:0, vz:0 }; this.app.nodes.push(node); nameMap[node.name] = id; node.tags.forEach(t => { if(!this.app.tagColors[t]) this.app.tagColors[t] = this.app.getRandomColor(); }); }); data.forEach(d => { if(d.connects_to) { const src = this.app.nodes.find(n=>n.id===nameMap[d.name]); d.connects_to.forEach(tn => { const tid = nameMap[tn]; if(tid) src.links.push(tid); }); } }); this.app.refresh(); this.app.explode(); this.close(); } catch(e) { alert("JSON Error: "+e.message); } }
}
var app, aiManager, ui; window.onload = () => { app = new App(); ui = app.ui; aiManager = new AIManager(app); window.app = app; window.aiManager = aiManager; window.ui = ui; };
</script>
</body>
</html>

コメント