Todoの検索ツール「Visible Grid」
【更新履歴】
・2026/2/11 1.0公開。
・2026/2/12 1.2日本語版を公開。(AI連携など追加)
使い方のヒント
画像の貼り付け:
ノードを選択し、右側の「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.2 日本語版</title>
<style>
/* --- Base & Layout --- */
:root { --accent: #00e5ff; --bg-dark: #0b0c10; --panel-bg: rgba(20, 24, 30, 0.95); --text-main: #c5c6c7; }
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 */
#menubar { height: 45px; background: linear-gradient(90deg, #1f2833 0%, #0b0c10 100%); display: flex; align-items: center; padding: 0 20px; border-bottom: 1px solid #333; z-index: 10; }
.brand { font-weight: 800; color: var(--accent); margin-right: 30px; font-size: 1.2em; letter-spacing: 1.5px; text-shadow: 0 0 10px rgba(0,229,255,0.3); }
.menu-btn { background: #2b3540; border: 1px solid #45a29e; color: #66fcf1; padding: 5px 15px; margin-right: 10px; cursor: pointer; border-radius: 4px; font-size: 0.8em; font-weight: bold; transition: all 0.2s; }
.menu-btn:hover { background: #45a29e; color: #0b0c10; box-shadow: 0 0 8px rgba(69,162,158,0.5); }
/* AI Button Special Style */
.btn-ai { background: linear-gradient(45deg, #7b1fa2, #4a148c); border-color: #ba68c8; color: #e1bee7; }
.btn-ai:hover { background: linear-gradient(45deg, #9c27b0, #6a1b9a); box-shadow: 0 0 10px rgba(186,104,200, 0.6); color: #fff; }
.menu-spacer { flex: 1; }
.status-msg { font-size: 0.85em; color: #45a29e; margin-right: 15px; font-family: monospace; }
/* Main Container */
#container { display: flex; height: calc(100% - 45px); }
/* Panels */
.panel { width: 280px; 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: 15px; background: rgba(255,255,255,0.03); font-weight: bold; border-bottom: 1px solid #333; font-size: 0.75em; text-transform: uppercase; letter-spacing: 1.2px; color: #66fcf1; }
/* List */
#node-list { flex: 1; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #333 transparent; }
.list-item { padding: 12px 15px; cursor: pointer; border-bottom: 1px solid #222; font-size: 0.9em; border-left: 3px solid transparent; transition: all 0.2s; }
.list-item:hover { background: rgba(102, 252, 241, 0.1); padding-left: 18px; }
.list-item.active { background: rgba(102, 252, 241, 0.15); color: #fff; border-left: 3px solid var(--accent); }
.tag-pill { display: inline-block; background: #222; padding: 2px 6px; border-radius: 4px; font-size: 0.7em; color: #888; margin-right: 4px; border: 1px solid #333; }
/* Canvas */
#center-panel { flex: 1; position: relative; background: radial-gradient(circle at center, #1f2833 0%, #0b0c10 80%); overflow: hidden; }
#canvas-container { width: 100%; height: 100%; outline: none; }
/* Forms */
.prop-group { padding: 15px; border-bottom: 1px solid #333; }
.prop-label { display: block; margin-bottom: 8px; font-size: 0.7em; color: #888; text-transform: uppercase; letter-spacing: 0.5px; }
input, textarea, select { width: 100%; background: #181b21; border: 1px solid #333; color: #eee; padding: 10px; margin-bottom: 12px; box-sizing: border-box; border-radius: 4px; font-family: inherit; font-size: 0.9em; transition: border 0.2s; }
input:focus, textarea:focus, select:focus { border-color: var(--accent); outline: none; box-shadow: 0 0 5px rgba(0,229,255,0.2); }
textarea { height: 100px; resize: vertical; line-height: 1.5; }
button.action-btn { width: 100%; padding: 10px; background: #2b3540; border: 1px solid #333; color: #ddd; cursor: pointer; margin-top: 5px; border-radius: 4px; transition: all 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-primary:hover { opacity: 0.9; }
button.btn-danger { background: #3a1c1c; border-color: #522; color: #ff8888; }
button.btn-danger:hover { background: #522; }
/* Linked Nodes List */
.linked-node-item { display: flex; align-items: center; justify-content: space-between; background: #1a1a1a; padding: 6px 10px; margin-bottom: 4px; border-radius: 3px; font-size: 0.85em; }
.remove-link-btn { color: #ff6b6b; cursor: pointer; font-weight: bold; padding: 0 5px; }
.hidden { display: none !important; }
/* Context Menu */
#context-menu { position: absolute; display: none; background: rgba(30,30,30,0.95); border: 1px solid #444; box-shadow: 0 10px 30px rgba(0,0,0,0.8); z-index: 2000; min-width: 160px; border-radius: 6px; backdrop-filter: blur(10px); overflow: hidden; }
.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(--accent); color: #000; }
.ctx-item:last-child { border-bottom: none; }
/* --- 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; transition: 0.3s; }
.ai-step.active { border-left-color: #ba68c8; }
.ai-step-title { font-weight: bold; color: #ccc; margin-bottom: 8px; font-size: 0.9em; text-transform: uppercase; }
.ai-desc { font-size: 0.85em; color: #888; margin-bottom: 10px; }
.code-box { position: relative; }
.code-copy-btn { position: absolute; top: 5px; right: 5px; background: rgba(0,0,0,0.6); color: #fff; border: 1px solid #555; padding: 2px 8px; font-size: 0.75em; border-radius: 4px; cursor: pointer; }
.code-copy-btn:hover { background: #444; }
textarea.code-area { font-family: 'Consolas', monospace; font-size: 0.85em; color: #a5d6a7; background: #0f1115; line-height: 1.4; border-color: #333; }
.close-btn { background: none; border: none; color: #aaa; cursor: pointer; font-size: 1.5em; line-height: 1; }
.close-btn:hover { color: #fff; }
</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">GRID PRO <span style="font-size:0.5em; opacity:0.6; vertical-align:middle;">AI</span></div>
<button class="menu-btn" onclick="app.addNode()">+ ノード</button>
<button class="menu-btn" onclick="app.saveFile()">JSON保存</button>
<button class="menu-btn" onclick="document.getElementById('file-input').click()">JSON読込</button>
<button class="menu-btn btn-ai" onclick="aiManager.open()">✨ AI 設計</button>
<div class="menu-spacer"></div>
<button class="menu-btn" onclick="app.clearAllData()" style="border-color:#522; color:#faa;">リセット</button>
<div id="status-bar" class="status-msg">System 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 active">
<div class="ai-step-title">Step 1: どのようなグラフを作りますか?</div>
<div class="ai-desc">作りたい相関図、マインドマップ、ナレッジグラフの内容を自然言語で入力してください。</div>
<textarea id="ai-input" placeholder="例: ファンタジーRPGの世界設定を作って。国、種族、魔法体系を含めて。国同士は敵対関係や同盟関係で繋いで。"></textarea>
<button class="action-btn btn-primary" onclick="aiManager.generatePrompt()">プロンプト生成</button>
</div>
<div class="ai-step">
<div class="ai-step-title">Step 2: AIへの指示 (コピー & 送信)</div>
<div class="ai-desc">以下のテキストをコピーして、ChatGPT, Gemini, Claude 等に貼り付けてください。</div>
<div class="code-box">
<textarea id="ai-prompt-output" class="code-area" readonly style="height:120px;"></textarea>
<button class="code-copy-btn" onclick="aiManager.copyPrompt()">コピー</button>
</div>
</div>
<div class="ai-step">
<div class="ai-step-title">Step 3: 結果の反映</div>
<div class="ai-desc">AIからの回答(JSON部分)をここに貼り付けて「反映」を押してください。</div>
<textarea id="ai-json-input" class="code-area" placeholder='[ { "name": "...", ... } ]' style="height:120px; color:#fff;"></textarea>
<button class="action-btn" style="background: linear-gradient(90deg, #7b1fa2, #4a148c); color:white; border:none;" onclick="aiManager.apply()">グラフを生成して反映</button>
</div>
</div>
</div>
</div>
<div id="container">
<div id="left-panel" class="panel">
<div class="panel-header">Knowledge Nodes</div>
<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="center-panel">
<div id="canvas-container"></div>
</div>
<div id="right-panel" class="panel">
<div id="prop-node" class="hidden">
<div class="panel-header">Edit Node</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-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:8px;"></div>
<label class="prop-label" style="margin-top:15px;">メモ (Markdown)</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-global">
<div class="panel-header">Settings</div>
<div class="prop-group">
<label class="prop-label">表示深度: <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">
<label class="prop-label">経路探索</label>
<div id="path-status" style="font-size:0.8em; color:#888; margin-bottom:8px;">開始ノード選択後、ゴールを設定してください。</div>
<button class="action-btn" onclick="app.clearPath()">パスをクリア</button>
</div>
<div class="prop-group">
<button class="action-btn btn-primary" onclick="app.visualizer.resetCamera()">カメラリセット</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>
/**
* Core Application Logic 2.1 (AI Enhanced)
*/
class App {
constructor() {
this.nodes = [];
this.nextId = 1;
this.selectedNodeId = null;
this.targetNodeId = null;
this.clipboard = null;
this.visibleRange = 2;
this.searchQuery = "";
this.physicsParams = { repulsion: 800, spring: 0.03, damping: 0.92 };
this.visibleNodeIds = new Set();
this.activeLinks = [];
this.pathLinks = new Set();
this.ui = new UIManager(this);
this.visualizer = new Visualizer(this);
this.visualizer.init();
this.loadData();
this.physicsLoop();
}
// --- Data ---
loadData() {
const saved = localStorage.getItem('neural_workspace_v2');
if (saved) {
try {
const data = JSON.parse(saved);
this.nodes = data.nodes;
this.nodes.forEach(n => { if(!n.links) n.links = []; });
this.nextId = data.nextId || 1;
this.ui.setStatus("Workspace Loaded.");
} catch (e) { this.initDemoData(); }
} else {
this.initDemoData();
}
if(this.nodes.length > 0) this.selectNode(this.nodes[0].id);
else this.refresh();
}
saveData() {
const data = { nodes: this.nodes, nextId: this.nextId };
localStorage.setItem('neural_workspace_v2', JSON.stringify(data));
this.ui.setStatus("Auto-saved.");
}
initDemoData() {
this.nodes = [
{ id: 1, name: "プロジェクト・アルファ", tags: ["Project"], links: [2,3], body: "メインプロジェクト。", x:0, y:0, z:0, vx:0, vy:0, vz:0 },
{ id: 2, name: "要件定義書", tags: ["Doc", "Important"], links: [], body: "顧客要望リスト。", x:5, y:2, z:0, vx:0, vy:0, vz:0 },
{ id: 3, name: "UIデザイン", tags: ["Design"], links: [4], body: "Figmaリンクなど。", x:-5, y:2, z:0, vx:0, vy:0, vz:0 },
{ id: 4, name: "カラーパレット", tags: ["Design", "Asset"], links: [], body: "#00E5FF", x:-8, y:5, z:2, vx:0, vy:0, vz:0 },
{ id: 5, name: "バックエンドAPI", tags: ["Dev"], links: [1], body: "Node.js Server", x:5, y:-5, z:2, vx:0, vy:0, vz:0 }
];
this.nextId = 6;
}
clearAllData() {
if(confirm("全データを削除しますか?")) {
this.nodes = [];
this.nextId = 1;
this.selectedNodeId = null;
this.targetNodeId = null;
localStorage.removeItem('neural_workspace_v2');
this.addNode();
}
}
// --- Actions ---
addNode() {
const id = this.nextId++;
this.nodes.push({
id: id, name: "新規ノード " + id, tags: [], links: [], body: "",
x: (Math.random()-0.5)*15, y: (Math.random()-0.5)*15, z: (Math.random()-0.5)*15,
vx:0, vy:0, vz:0
});
this.selectNode(id);
}
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();
}
addManualLink(targetIdStr) {
if(!this.selectedNodeId || !targetIdStr) return;
const targetId = parseInt(targetIdStr);
const source = this.nodes.find(n => n.id === this.selectedNodeId);
if(source && targetId !== source.id && !source.links.includes(targetId)) {
source.links.push(targetId);
this.refresh();
this.ui.updateSelection();
}
}
removeManualLink(targetId) {
const source = this.nodes.find(n => n.id === this.selectedNodeId);
if(source) {
source.links = source.links.filter(id => id !== targetId);
this.refresh();
this.ui.updateSelection();
}
}
selectNode(id) {
this.selectedNodeId = id;
this.pathLinks.clear();
if (this.targetNodeId && this.targetNodeId !== id) {
this.calculatePath(id, this.targetNodeId);
}
this.refresh();
this.ui.updateSelection();
}
setPathTarget() {
if(this.selectedNodeId) {
this.targetNodeId = this.selectedNodeId;
this.ui.setStatus(`Goal Set: ${this.targetNodeId}`);
this.ui.updatePathStatus();
this.refresh();
}
}
clearPath() {
this.targetNodeId = null;
this.pathLinks.clear();
this.refresh();
this.ui.updatePathStatus();
}
// --- Logic ---
calculatePath(startId, endId) {
const queue = [[startId]];
const visited = new Set([startId]);
let path = null;
while (queue.length > 0) {
const currentPath = queue.shift();
const currId = currentPath[currentPath.length - 1];
if (currId === endId) {
path = currentPath;
break;
}
const currNode = this.nodes.find(n => n.id === currId);
if(!currNode) continue;
this.nodes.forEach(n => {
if (!visited.has(n.id)) {
const isLinked = currNode.links.includes(n.id) || n.links.includes(currNode.id);
const shared = currNode.tags.filter(t => n.tags.includes(t)).length > 0;
if (isLinked || shared) {
visited.add(n.id);
queue.push([...currentPath, n.id]);
}
}
});
}
if (path) {
this.ui.setStatus(`Path Found: ${path.length - 1} steps`);
for(let i=0; i<path.length-1; i++) {
this.pathLinks.add(`${path[i]}-${path[i+1]}`);
this.pathLinks.add(`${path[i+1]}-${path[i]}`);
}
} else {
this.ui.setStatus("No Path Found");
}
}
updateVisibility() {
this.visibleNodeIds.clear();
this.activeLinks = [];
const searchVal = this.ui.iSearch.value.toLowerCase();
if(searchVal.length > 0) {
this.nodes.forEach(n => {
const hit = n.name.toLowerCase().includes(searchVal) || n.tags.some(t=>t.toLowerCase().includes(searchVal));
if(hit) this.visibleNodeIds.add(n.id);
});
}
else if (this.selectedNodeId === null) {
this.nodes.forEach(n => this.visibleNodeIds.add(n.id));
} else {
const q = [{id: this.selectedNodeId, dist: 0}];
const visited = new Set([this.selectedNodeId]);
this.visibleNodeIds.add(this.selectedNodeId);
if(this.targetNodeId) this.visibleNodeIds.add(this.targetNodeId);
while(q.length > 0) {
const curr = q.shift();
if (curr.dist >= this.visibleRange) continue;
const currNode = this.nodes.find(n => n.id === curr.id);
if (!currNode) continue;
this.nodes.forEach(other => {
if (!visited.has(other.id)) {
const isLinked = currNode.links.includes(other.id) || other.links.includes(currNode.id);
const shared = currNode.tags.some(t => other.tags.includes(t));
if (isLinked || shared) {
visited.add(other.id);
this.visibleNodeIds.add(other.id);
q.push({id: other.id, dist: curr.dist + 1});
}
}
});
}
}
const visArr = Array.from(this.visibleNodeIds);
for(let i=0; i<visArr.length; i++) {
for(let j=i+1; j<visArr.length; j++) {
const n1 = this.nodes.find(n => n.id === visArr[i]);
const n2 = this.nodes.find(n => n.id === visArr[j]);
const isManual = n1.links.includes(n2.id) || n2.links.includes(n1.id);
const sharedTags = n1.tags.filter(t => n2.tags.includes(t));
if(isManual || sharedTags.length > 0) {
const isPath = this.pathLinks.has(`${n1.id}-${n2.id}`);
this.activeLinks.push({
source: n1, target: n2,
type: isManual ? 'manual' : 'tag',
isPath: isPath
});
}
}
}
}
refresh() {
this.updateVisibility();
this.ui.updateList();
this.visualizer.updateScene();
this.saveData();
}
explode() {
this.physicsParams.repulsion = 2000;
setTimeout(() => this.physicsParams.repulsion = 800, 500);
}
physicsLoop() {
requestAnimationFrame(() => this.physicsLoop());
const activeNodes = this.nodes.filter(n => this.visibleNodeIds.has(n.id));
const dt = 0.05;
// Repulsion
for(let i=0; i<activeNodes.length; i++) {
for(let j=i+1; j<activeNodes.length; j++) {
const n1 = activeNodes[i];
const n2 = activeNodes[j];
let dx = n1.x - n2.x, dy = n1.y - n2.y, dz = n1.z - n2.z;
let distSq = dx*dx + dy*dy + dz*dz || 0.1;
let force = this.physicsParams.repulsion / distSq;
let dist = Math.sqrt(distSq);
n1.vx += (dx/dist)*force*dt; n1.vy += (dy/dist)*force*dt; n1.vz += (dz/dist)*force*dt;
n2.vx -= (dx/dist)*force*dt; n2.vy -= (dy/dist)*force*dt; n2.vz -= (dz/dist)*force*dt;
}
}
// Attraction
this.activeLinks.forEach(l => {
let dx = l.target.x - l.source.x, dy = l.target.y - l.source.y, dz = l.target.z - l.source.z;
let dist = Math.sqrt(dx*dx + dy*dy + dz*dz) || 0.1;
let targetLen = l.isPath ? 4 : (l.type === 'manual' ? 8 : 12);
let k = l.isPath ? 0.3 : (l.type === 'manual' ? 0.15 : this.physicsParams.spring);
let force = (dist - targetLen) * k;
let fx = (dx/dist)*force, fy = (dy/dist)*force, fz = (dz/dist)*force;
l.source.vx += fx*dt; l.source.vy += fy*dt; l.source.vz += fz*dt;
l.target.vx -= fx*dt; l.target.vy -= fy*dt; l.target.vz -= fz*dt;
});
// Damping
activeNodes.forEach(n => {
n.vx -= n.x * 0.005 * dt; n.vy -= n.y * 0.005 * dt; n.vz -= n.z * 0.005 * dt;
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;
});
this.visualizer.sync();
}
saveFile() {
const blob = new Blob([JSON.stringify({nodes:this.nodes, nextId:this.nextId}, null, 2)], {type:"application/json"});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = "grid_data.json";
a.click();
}
}
/**
* AI Manager (New)
*/
class AIManager {
constructor(app) {
this.app = app;
this.modal = document.getElementById('ai-modal');
this.input = document.getElementById('ai-input');
this.output = document.getElementById('ai-prompt-output');
this.jsonInput = document.getElementById('ai-json-input');
}
open() { this.modal.style.display = 'flex'; }
close() { this.modal.style.display = 'none'; }
generatePrompt() {
const userReq = this.input.value;
if(!userReq) return alert("要望を入力してください。");
const prompt = `あなたはデータ構造化の専門家です。以下の要望に基づき、ナレッジグラフ用のデータをJSON形式で作成してください。
【ユーザー要望】
${userReq}
【制約・ルール】
1. 配列形式 \`[ ... ]\` のJSONのみを出力してください。Markdownのコードブロック( \`\`\`json )は含めないでください。
2. 各オブジェクトは以下の構造にしてください:
{
"name": "ノード名 (必須)",
"tags": ["タグ1", "タグ2"],
"body": "説明文 (Markdown対応)",
"connects_to": ["接続先のノード名1", "接続先のノード名2"]
}
3. "connects_to" を使って、関連するノード同士を明示的に繋いでください(名前で指定)。
4. "tags" は、属性やカテゴリ(例: "国", "人物", "概念")を入れてください。同じタグを持つものは自動的に緩く繋がります。
【出力JSON例】
[
{ "name": "勇者", "tags": ["人物", "主人公"], "body": "伝説の剣を持つ。", "connects_to": ["魔王"] },
{ "name": "魔王", "tags": ["人物", "敵"], "body": "世界征服を企む。", "connects_to": [] }
]`;
this.output.value = prompt;
}
copyPrompt() {
this.output.select();
document.execCommand('copy');
this.app.ui.setStatus("プロンプトをコピーしました! AIに貼り付けてください。");
}
apply() {
let raw = this.jsonInput.value.trim();
// Remove code blocks if AI included them despite instructions
raw = raw.replace(/```json/g, '').replace(/```/g, '').trim();
try {
const data = JSON.parse(raw);
if(!Array.isArray(data)) throw new Error("ルートは配列である必要があります");
// 1. Create Nodes
const newNodes = [];
const nameToId = {};
data.forEach(item => {
const id = this.app.nextId++;
const node = {
id: id,
name: item.name || "名称未設定",
tags: item.tags || [],
links: [],
body: item.body || "",
// Random position near center but dispersed
x: (Math.random()-0.5)*10,
y: (Math.random()-0.5)*10,
z: (Math.random()-0.5)*10,
vx:0, vy:0, vz:0
};
newNodes.push(node);
nameToId[node.name] = id;
this.app.nodes.push(node);
});
// 2. Resolve Links (connects_to names -> ids)
data.forEach((item, index) => {
if(item.connects_to && Array.isArray(item.connects_to)) {
const sourceId = newNodes[index].id;
const sourceNode = this.app.nodes.find(n => n.id === sourceId);
item.connects_to.forEach(targetName => {
const targetId = nameToId[targetName];
// Also try case-insensitive search if direct lookup fails
if(!targetId) {
const match = this.app.nodes.find(n => n.name.toLowerCase() === targetName.toLowerCase());
if(match) sourceNode.links.push(match.id);
} else {
sourceNode.links.push(targetId);
}
});
}
});
this.app.refresh();
this.app.explode(); // scatter new nodes
this.close();
this.app.ui.setStatus(`${newNodes.length}個のノードを生成しました。`);
this.input.value = "";
this.jsonInput.value = "";
} catch(e) {
alert("JSONの解析に失敗しました。形式を確認してください。\n" + e.message);
}
}
}
/**
* 3D Visualizer 2.1
*/
class Visualizer {
constructor(app) {
this.app = app;
this.container = document.getElementById('canvas-container');
this.meshMap = new Map();
this.linkMeshes = [];
this.selectionRing = null;
this.scene = null;
this.raycaster = new THREE.Raycaster();
this.mouse = new THREE.Vector2();
}
init() {
this.scene = new THREE.Scene();
this.scene.fog = new THREE.FogExp2(0x0b0c10, 0.02);
this.camera = new THREE.PerspectiveCamera(60, this.container.clientWidth/this.container.clientHeight, 0.1, 1000);
this.camera.position.set(0, 20, 40);
this.renderer = new THREE.WebGLRenderer({antialias: true, alpha: true});
this.renderer.setSize(this.container.clientWidth, this.container.clientHeight);
this.renderer.setPixelRatio(window.devicePixelRatio);
this.container.appendChild(this.renderer.domElement);
this.controls = new THREE.OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.05;
const amb = new THREE.AmbientLight(0xffffff, 0.4);
const pLight = new THREE.PointLight(0x00e5ff, 1, 100);
pLight.position.set(10, 20, 10);
this.scene.add(amb);
this.scene.add(pLight);
const grid = new THREE.GridHelper(200, 40, 0x1f2833, 0x0b0c10);
grid.position.y = -20;
grid.material.opacity = 0.2;
grid.material.transparent = true;
this.scene.add(grid);
const ringGeo = new THREE.RingGeometry(2.2, 2.4, 32);
const ringMat = new THREE.MeshBasicMaterial({ color: 0x00e5ff, side: THREE.DoubleSide, transparent:true, opacity:0.8 });
this.selectionRing = new THREE.Mesh(ringGeo, ringMat);
this.selectionRing.visible = false;
this.scene.add(this.selectionRing);
window.addEventListener('resize', () => this.onResize());
this.renderer.domElement.addEventListener('pointerdown', (e) => this.onClick(e));
this.animate();
}
updateScene() {
if(!this.scene) return;
const currentIds = new Set(this.meshMap.keys());
currentIds.forEach(id => {
if(!this.app.visibleNodeIds.has(id)) {
const obj = this.meshMap.get(id);
this.scene.remove(obj.mesh);
this.scene.remove(obj.label);
this.meshMap.delete(id);
}
});
const geo = new THREE.IcosahedronGeometry(1, 1);
this.app.visibleNodeIds.forEach(id => {
if(!this.meshMap.has(id)) {
const node = this.app.nodes.find(n => n.id === id);
const color = this.getColor(node);
const mat = new THREE.MeshPhongMaterial({
color: color,
emissive: color,
emissiveIntensity: 0.3,
shininess: 50
});
const mesh = new THREE.Mesh(geo, mat);
mesh.userData = { id: id };
this.scene.add(mesh);
const label = this.createLabel(node.name);
this.scene.add(label);
this.meshMap.set(id, {mesh, label});
}
});
this.linkMeshes.forEach(l => this.scene.remove(l));
this.linkMeshes = [];
const manualMat = new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.4 });
const tagMat = new THREE.LineBasicMaterial({ color: 0x45a29e, transparent: true, opacity: 0.15 });
const pathMat = new THREE.LineBasicMaterial({ color: 0xff0055, linewidth: 3, opacity: 0.8 });
this.app.activeLinks.forEach(link => {
const g = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]);
let mat = tagMat;
if(link.isPath) mat = pathMat;
else if(link.type === 'manual') mat = manualMat;
const l = new THREE.Line(g, mat);
l.userData = { link: link };
this.scene.add(l);
this.linkMeshes.push(l);
});
this.updateHighlights();
}
getColor(node) {
if(!node.tags || node.tags.length === 0) return 0xaaaaaa;
let hash = 0;
let str = node.tags[0];
for (let i = 0; i < str.length; i++) hash = str.charCodeAt(i) + ((hash << 5) - hash);
const c = (hash & 0x00FFFFFF).toString(16).toUpperCase();
return "#" + "00000".substring(0, 6 - c.length) + c;
}
createLabel(text) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = 300; canvas.height = 80;
ctx.font = "Bold 36px sans-serif";
ctx.fillStyle = "rgba(0,0,0,0.6)";
ctx.roundRect(10, 10, 280, 60, 10);
ctx.fill();
ctx.fillStyle = "#ffffff";
ctx.textAlign = "center";
ctx.fillText(text, 150, 52);
const tex = new THREE.CanvasTexture(canvas);
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false });
const sprite = new THREE.Sprite(mat);
sprite.scale.set(6, 1.6, 1);
sprite.renderOrder = 999;
return sprite;
}
updateHighlights() {
const isSearching = this.app.ui.iSearch.value.length > 0;
this.meshMap.forEach((obj, id) => {
const isSel = id === this.app.selectedNodeId;
const isTarget = id === this.app.targetNodeId;
if(isSearching) obj.mesh.scale.setScalar(1);
if (isSel) {
this.selectionRing.visible = true;
obj.mesh.scale.setScalar(1.3);
} else if (isTarget) {
obj.mesh.scale.setScalar(1.2);
obj.mesh.material.emissive.setHex(0xff0000);
} else {
obj.mesh.scale.setScalar(1);
obj.mesh.material.emissiveIntensity = 0.3;
}
});
if(!this.app.selectedNodeId) this.selectionRing.visible = false;
}
sync() {
if(!this.scene) return;
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 + 1.8, n.z);
}
if(id === this.app.selectedNodeId) {
this.selectionRing.position.set(n.x, n.y, n.z);
this.selectionRing.lookAt(this.camera.position);
}
});
this.linkMeshes.forEach(line => {
const l = line.userData.link;
const pos = line.geometry.attributes.position.array;
pos[0]=l.source.x; pos[1]=l.source.y; pos[2]=l.source.z;
pos[3]=l.target.x; pos[4]=l.target.y; pos[5]=l.target.z;
line.geometry.attributes.position.needsUpdate = true;
});
}
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 intersects = this.raycaster.intersectObjects(
Array.from(this.meshMap.values()).map(o=>o.mesh)
);
if(intersects.length > 0) {
this.app.selectNode(intersects[0].object.userData.id);
}
}
resetCamera() {
this.camera.position.set(0,20,40);
this.controls.reset();
}
onResize() {
this.camera.aspect = this.container.clientWidth/this.container.clientHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(this.container.clientWidth, this.container.clientHeight);
}
animate() {
requestAnimationFrame(()=>this.animate());
this.controls.update();
this.renderer.render(this.scene, this.camera);
}
}
/**
* UI Manager 2.1
*/
class UIManager {
constructor(app) {
this.app = app;
this.listEl = document.getElementById('node-list');
this.statusEl = document.getElementById('status-bar');
this.pNode = document.getElementById('prop-node');
this.pGlobal = document.getElementById('prop-global');
this.iName = document.getElementById('node-name');
this.iTags = document.getElementById('node-tags');
this.iBody = document.getElementById('node-body');
this.iSearch = document.getElementById('search-input');
this.linkSelect = document.getElementById('link-select');
this.linkList = document.getElementById('manual-links-list');
this.bindEvents();
}
setStatus(msg) {
this.statusEl.innerText = msg;
this.statusEl.style.opacity = 1;
setTimeout(()=>this.statusEl.style.opacity = 0.5, 3000);
}
bindEvents() {
const saver = () => {
if(this.app.selectedNodeId) {
const n = this.app.nodes.find(x => x.id === this.app.selectedNodeId);
n.name = this.iName.value;
n.tags = this.iTags.value.split(',').map(t => t.trim()).filter(t => t);
n.body = this.iBody.value;
this.app.refresh();
}
};
[this.iName, this.iTags, this.iBody].forEach(el => el.addEventListener('input', saver));
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();
// Context Menu
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-copy').onclick = () => {
const n = this.app.nodes.find(x=>x.id===this.ctxId);
if(n) {
this.app.clipboard = JSON.parse(JSON.stringify(n));
this.setStatus("Copied to clipboard");
}
};
document.getElementById('ctx-paste').onclick = () => {
if(this.app.clipboard) {
const n = {...this.app.clipboard, id: this.app.nextId++, name: this.app.clipboard.name+" (Copy)", links: [], x:0, y:0, z:0};
this.app.nodes.push(n);
this.app.selectNode(n.id);
}
};
document.getElementById('ctx-delete').onclick = () => this.app.deleteNode(this.ctxId);
}
updatePathStatus() {
const s = document.getElementById('path-status');
if(this.app.targetNodeId) {
s.innerText = `Goal: Node ${this.app.targetNodeId}`;
s.style.color = "#45a29e";
} else {
s.innerText = "No goal set.";
s.style.color = "#888";
}
}
updateSelection() {
this.updateList();
this.app.visualizer.updateHighlights();
if(this.app.selectedNodeId) {
this.pNode.classList.remove('hidden');
this.pGlobal.classList.add('hidden');
const n = this.app.nodes.find(x => x.id === this.app.selectedNodeId);
this.iName.value = n.name;
this.iTags.value = Array.isArray(n.tags) ? n.tags.join(', ') : '';
this.iBody.value = n.body;
// Update Link Selector
this.linkSelect.innerHTML = '<option value="">+ 接続先を選択...</option>';
this.app.nodes.forEach(other => {
if(other.id !== n.id && !n.links.includes(other.id)) {
const opt = document.createElement('option');
opt.value = other.id;
opt.innerText = other.name;
this.linkSelect.appendChild(opt);
}
});
// Update Connected List
this.linkList.innerHTML = '';
n.links.forEach(lid => {
const target = this.app.nodes.find(t => t.id === lid);
if(target) {
const div = document.createElement('div');
div.className = 'linked-node-item';
div.innerHTML = `<span onclick="app.selectNode(${lid})" style="cursor:pointer; text-decoration:underline;">${target.name}</span> <span class="remove-link-btn" onclick="app.removeManualLink(${lid})">×</span>`;
this.linkList.appendChild(div);
}
});
} else {
this.pNode.classList.add('hidden');
this.pGlobal.classList.remove('hidden');
}
}
updateList() {
this.listEl.innerHTML = '';
const term = this.iSearch.value.toLowerCase();
this.app.nodes.forEach(n => {
if(term && !n.name.toLowerCase().includes(term) && !n.tags.some(t=>t.toLowerCase().includes(term))) return;
const el = document.createElement('div');
el.className = 'list-item';
if(n.id === this.app.selectedNodeId) el.classList.add('active');
let tagHtml = n.tags.slice(0,3).map(t => `<span class="tag-pill">${t}</span>`).join('');
el.innerHTML = `<div>${n.name}</div><div style="margin-top:4px;">${tagHtml}</div>`;
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';
}
this.listEl.appendChild(el);
});
}
}
// --- Init ---
var app, aiManager;
window.onload = () => {
app = new App();
aiManager = new AIManager(app);
window.app = app;
window.aiManager = aiManager;
};
</script>
</body>
</html>

コメント