Todoの検索ツール「Visible Grid」
・ダウンロードされる方はこちら。↓
・ソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>3D Neural Workspace v3.1 (Fixed)</title>
<style>
/* --- Base & Layout --- */
body, html { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background-color: #111; color: #ccc; font-family: 'Segoe UI', sans-serif; user-select: none; }
/* Menu Bar */
#menubar { height: 35px; background: #222; display: flex; align-items: center; padding: 0 15px; border-bottom: 1px solid #333; }
.brand { font-weight: bold; color: #00bcd4; margin-right: 20px; }
.menu-item { margin-right: 15px; cursor: pointer; font-size: 0.9em; position: relative; }
.menu-item:hover { color: #fff; }
/* Main Container */
#container { display: flex; height: calc(100% - 35px); }
/* Left Panel */
#left-panel { width: 250px; background: #1a1a1a; border-right: 1px solid #333; display: flex; flex-direction: column; }
.panel-header { padding: 10px; background: #222; font-weight: bold; border-bottom: 1px solid #333; font-size: 0.85em; text-transform: uppercase; letter-spacing: 1px; }
#node-list { flex: 1; overflow-y: auto; }
.list-item { padding: 8px 12px; cursor: pointer; border-bottom: 1px solid #222; font-size: 0.9em; border-left: 3px solid transparent; }
.list-item:hover { background: #2a2a2a; }
.list-item.active { background: #223; color: #4db8ff; border-left: 3px solid #4db8ff; }
/* Context Menu */
#context-menu { position: absolute; display: none; background: #333; border: 1px solid #555; box-shadow: 2px 2px 10px rgba(0,0,0,0.5); z-index: 1000; min-width: 120px; }
.ctx-item { padding: 8px 15px; cursor: pointer; color: #ddd; font-size: 0.9em; }
.ctx-item:hover { background: #0078d4; color: white; }
.ctx-separator { border-top: 1px solid #555; margin: 2px 0; }
/* Center Panel */
#center-panel { flex: 1; position: relative; background: radial-gradient(circle at center, #222 0%, #000 100%); overflow: hidden; }
#canvas-container { width: 100%; height: 100%; }
/* Right Panel */
#right-panel { width: 300px; background: #1a1a1a; border-left: 1px solid #333; display: flex; flex-direction: column; overflow-y: auto; }
.prop-group { padding: 15px; border-bottom: 1px solid #2a2a2a; }
.prop-label { display: block; margin-bottom: 5px; font-size: 0.8em; color: #888; }
input[type="text"], input[type="number"], textarea { width: 100%; background: #252525; border: 1px solid #444; color: #eee; padding: 6px; margin-bottom: 10px; box-sizing: border-box; }
textarea { height: 150px; resize: vertical; }
button.action-btn { width: 100%; padding: 8px; background: #333; border: 1px solid #444; color: #eee; cursor: pointer; margin-top: 5px; }
button.action-btn:hover { background: #444; }
.hidden { display: none !important; }
/* Helper for range slider */
input[type=range] { width: 100%; }
</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">NEURAL NET v3</div>
<div class="menu-item" onclick="if(window.app) window.app.addNode()">+ New Node</div>
<div class="menu-item" onclick="if(window.app) window.app.saveFile()">Save JSON</div>
<div class="menu-item" onclick="document.getElementById('file-input').click()">Load JSON</div>
<div style="flex:1;"></div>
<div style="font-size:0.8em; color:#666;">Right-click list for options</div>
</div>
<div id="container">
<div id="left-panel">
<div class="panel-header">Nodes</div>
<div id="node-list"></div>
</div>
<div id="center-panel">
<div id="canvas-container"></div>
</div>
<div id="right-panel">
<div id="prop-node" class="hidden">
<div class="panel-header">Node Properties</div>
<div class="prop-group">
<label class="prop-label">Name</label>
<input type="text" id="node-name">
<label class="prop-label">Tags (comma separated)</label>
<input type="text" id="node-tags">
<label class="prop-label">Content</label>
<textarea id="node-body"></textarea>
</div>
</div>
<div id="prop-global">
<div class="panel-header">System Settings</div>
<div class="prop-group">
<label class="prop-label">Search / Filter</label>
<input type="text" id="search-input" placeholder="Type to filter...">
</div>
<div class="prop-group">
<label class="prop-label">Visible Range (Hops)</label>
<div style="display:flex; gap:10px; align-items:center;">
<input type="range" id="range-slider" min="1" max="5" value="2" step="1">
<span id="range-val">2</span>
</div>
<div style="font-size:0.7em; color:#666; margin-top:5px;">
Shows nodes within N steps from the selected node.
</div>
</div>
<div class="prop-group">
<label class="prop-label">Physics: Repulsion</label>
<input type="range" id="repulsion-slider" min="100" max="2000" value="500">
</div>
<div class="prop-group">
<button class="action-btn" onclick="if(window.app) window.app.visualizer.resetCamera()">Reset Camera View</button>
</div>
</div>
</div>
</div>
<div id="context-menu">
<div class="ctx-item" id="ctx-cut">Cut</div>
<div class="ctx-item" id="ctx-copy">Copy</div>
<div class="ctx-item" id="ctx-paste">Paste</div>
<div class="ctx-separator"></div>
<div class="ctx-item" id="ctx-delete" style="color:#ff6666;">Delete</div>
</div>
<input type="file" id="file-input" style="display:none" accept=".json">
<script>
/**
* Application Logic
*/
class App {
constructor() {
this.nodes = [];
this.nextId = 1;
this.selectedNodeId = null;
this.clipboard = null; // For copy/paste
// Settings
this.visibleRange = 2; // Default range
this.physicsParams = { repulsion: 500 };
// Runtime State
this.visibleNodeIds = new Set(); // IDs currently shown
this.activeLinks = []; // Links currently shown
this.initDemoData();
this.ui = new UIManager(this);
this.visualizer = new Visualizer(this);
// --- FIX: Initialization Order ---
// 1. Init Visualizer (Create Scene) first
this.visualizer.init();
// 2. Then calculate visibility (which adds meshes to Scene)
this.updateVisibility();
// 3. Then UI
this.ui.updateLeftPanel();
// 4. Start Physics
this.physicsLoop();
}
initDemoData() {
const tags = ["Root", "Idea", "Tech", "Art", "Science"];
for(let i=1; i<=15; i++) {
this.nodes.push({
id: i,
name: i === 1 ? "Center Node" : `Node ${i}`,
tags: i === 1 ? ["Root"] : [tags[Math.floor(Math.random() * tags.length)]],
body: "Content...",
x: (Math.random()-0.5)*10, y: (Math.random()-0.5)*10, z: (Math.random()-0.5)*10,
vx:0, vy:0, vz:0
});
}
this.nextId = 16;
this.selectedNodeId = 1; // Default select center
}
// --- Core Logic: Visibility Calculation (BFS) ---
updateVisibility() {
this.visibleNodeIds.clear();
this.activeLinks = [];
if (this.selectedNodeId === null) {
// Show all
this.nodes.forEach(n => this.visibleNodeIds.add(n.id));
} else {
// BFS for Range
const q = [{id: this.selectedNodeId, dist: 0}];
const visited = new Set([this.selectedNodeId]);
this.visibleNodeIds.add(this.selectedNodeId);
while(q.length > 0) {
const curr = q.shift();
if (curr.dist >= this.visibleRange) continue;
// Find neighbors via tags
const currNode = this.nodes.find(n => n.id === curr.id);
if (!currNode) continue;
this.nodes.forEach(other => {
if (!visited.has(other.id) && other.id !== curr.id) {
const shared = currNode.tags.filter(t => other.tags.includes(t));
if (shared.length > 0) {
visited.add(other.id);
this.visibleNodeIds.add(other.id);
q.push({id: other.id, dist: curr.dist + 1});
}
}
});
}
}
// Build links only for visible nodes
const visArray = Array.from(this.visibleNodeIds);
for(let i=0; i<visArray.length; i++) {
for(let j=i+1; j<visArray.length; j++) {
const n1 = this.nodes.find(n => n.id === visArray[i]);
const n2 = this.nodes.find(n => n.id === visArray[j]);
const shared = n1.tags.filter(t => n2.tags.includes(t));
if(shared.length > 0) {
this.activeLinks.push({source: n1, target: n2, strength: shared.length});
}
}
}
if(this.visualizer) this.visualizer.updateTopology();
}
// --- Data Operations ---
addNode() {
const id = this.nextId++;
this.nodes.push({
id: id, name: "New Node", tags: [], body: "",
x: (Math.random()-0.5)*5, y: (Math.random()-0.5)*5, z: (Math.random()-0.5)*5,
vx:0, vy:0, vz:0
});
this.selectNode(id);
this.refresh();
}
selectNode(id) {
this.selectedNodeId = id;
this.updateVisibility();
this.ui.updateSelection();
}
deselectNode() {
this.selectedNodeId = null;
this.updateVisibility();
this.ui.updateSelection();
}
updateNodeData(id, name, tagsStr, body) {
const n = this.nodes.find(x => x.id === id);
if(n) {
n.name = name;
const oldTags = JSON.stringify(n.tags);
n.tags = tagsStr.split(',').map(s=>s.trim()).filter(s=>s);
n.body = body;
if(oldTags !== JSON.stringify(n.tags)) this.updateVisibility();
this.ui.updateLeftPanel(); // Update list names
}
}
// --- Clipboard Operations ---
copyNode(id) {
const n = this.nodes.find(x => x.id === id);
if(n) {
this.clipboard = JSON.parse(JSON.stringify(n));
alert("Node copied!");
}
}
pasteNode() {
if(!this.clipboard) return;
const id = this.nextId++;
const newNode = {
...this.clipboard,
id: id,
name: this.clipboard.name + " (Copy)",
x: (Math.random()-0.5)*5, y: (Math.random()-0.5)*5, z: (Math.random()-0.5)*5,
vx:0, vy:0, vz:0
};
this.nodes.push(newNode);
this.selectNode(id);
this.refresh();
}
cutNode(id) {
this.copyNode(id);
this.deleteNode(id);
}
deleteNode(id) {
this.nodes = this.nodes.filter(n => n.id !== id);
if(this.selectedNodeId === id) this.deselectNode();
else this.refresh();
}
refresh() {
this.ui.updateLeftPanel();
this.updateVisibility();
}
// --- File IO ---
saveFile() {
const blob = new Blob([JSON.stringify(this.nodes,null,2)], {type:"application/json"});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = "graph.json";
a.click();
}
// --- Physics ---
physicsLoop() {
requestAnimationFrame(() => this.physicsLoop());
// Only simulate visible nodes
const activeNodes = this.nodes.filter(n => this.visibleNodeIds.has(n.id));
const dt = 0.05;
// 1. 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 dSq = dx*dx + dy*dy + dz*dz || 0.1;
let d = Math.sqrt(dSq);
let f = this.physicsParams.repulsion / dSq;
let fx = (dx/d)*f, fy = (dy/d)*f, fz = (dz/d)*f;
n1.vx += fx*dt; n1.vy += fy*dt; n1.vz += fz*dt;
n2.vx -= fx*dt; n2.vy -= fy*dt; n2.vz -= fz*dt;
}
}
// 2. Attraction (Links)
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 d = Math.sqrt(dx*dx+dy*dy+dz*dz) || 0.1;
// Hooke's Law
let f = (d - 10) * 0.05;
let fx=(dx/d)*f, fy=(dy/d)*f, fz=(dz/d)*f;
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;
});
// 3. Gravity to Center (Keep them in view) & Update
activeNodes.forEach(n => {
n.vx -= n.x * 0.02 * dt;
n.vy -= n.y * 0.02 * dt;
n.vz -= n.z * 0.02 * dt;
n.vx *= 0.9; // Friction
n.vy *= 0.9;
n.vz *= 0.9;
n.x += n.vx;
n.y += n.vy;
n.z += n.vz;
});
if(this.visualizer) this.visualizer.syncPositions();
}
}
/**
* Visualization (Three.js)
*/
class Visualizer {
constructor(app) {
this.app = app;
this.container = document.getElementById('canvas-container');
this.meshMap = new Map();
this.linkMeshes = [];
this.raycaster = new THREE.Raycaster();
this.mouse = new THREE.Vector2();
this.scene = null; // Initialize to null
}
init() {
// Scene & Camera
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x050505);
this.scene.fog = new THREE.FogExp2(0x050505, 0.015);
this.camera = new THREE.PerspectiveCamera(60, this.container.clientWidth/this.container.clientHeight, 0.1, 1000);
this.camera.position.set(0, 20, 40);
// Renderer
this.renderer = new THREE.WebGLRenderer({antialias:true});
this.renderer.setSize(this.container.clientWidth, this.container.clientHeight);
this.container.appendChild(this.renderer.domElement);
// Controls
this.controls = new THREE.OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
// Lighting
this.scene.add(new THREE.AmbientLight(0x555555));
const pl = new THREE.PointLight(0xffffff, 1, 100);
pl.position.set(10,20,10);
this.scene.add(pl);
// Events
window.addEventListener('resize', ()=>this.onResize());
this.renderer.domElement.addEventListener('pointerdown', (e)=>this.onClick(e));
this.animate();
}
resetCamera() {
this.camera.position.set(0, 20, 40);
this.camera.lookAt(0,0,0);
this.controls.reset();
}
updateTopology() {
// GUARD: If scene is not ready, do nothing (Prevents crash)
if (!this.scene) return;
// Rebuild Meshes based on app.visibleNodeIds
// 1. Remove invalid meshes
const currentIds = new Set(this.meshMap.keys());
currentIds.forEach(id => {
if(!this.app.visibleNodeIds.has(id)) {
const m = this.meshMap.get(id);
this.scene.remove(m);
this.meshMap.delete(id);
}
});
// 2. Add new meshes
const geo = new THREE.BoxGeometry(1.5, 1.5, 1.5);
this.app.visibleNodeIds.forEach(id => {
if(!this.meshMap.has(id)) {
const node = this.app.nodes.find(n => n.id === id);
const mat = new THREE.MeshLambertMaterial({color: 0x0088ff});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(node.x, node.y, node.z);
mesh.userData = {id: id};
this.scene.add(mesh);
this.meshMap.set(id, mesh);
}
});
// 3. Rebuild Links
this.linkMeshes.forEach(l => this.scene.remove(l));
this.linkMeshes = [];
const lineMat = new THREE.LineBasicMaterial({color: 0x00ffff, transparent:true, opacity:0.3});
this.app.activeLinks.forEach(link => {
const g = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]);
const l = new THREE.Line(g, lineMat);
l.userData = {link: link};
this.scene.add(l);
this.linkMeshes.push(l);
});
this.updateHighlights();
}
updateHighlights() {
if (!this.scene) return;
this.meshMap.forEach((mesh, id) => {
if(id === this.app.selectedNodeId) {
mesh.material.color.setHex(0xffff00); // Yellow
mesh.scale.setScalar(1.5);
} else {
mesh.material.color.setHex(0x0088ff); // Blue
mesh.scale.setScalar(1);
}
});
}
syncPositions() {
if (!this.scene) return;
this.meshMap.forEach((mesh, id) => {
const n = this.app.nodes.find(x => x.id === id);
if(n) {
mesh.position.set(n.x, n.y, n.z);
mesh.rotation.x += 0.01;
mesh.rotation.y += 0.01;
}
});
this.linkMeshes.forEach(line => {
const link = line.userData.link;
const pos = line.geometry.attributes.position.array;
pos[0]=link.source.x; pos[1]=link.source.y; pos[2]=link.source.z;
pos[3]=link.target.x; pos[4]=link.target.y; pos[5]=link.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()));
if(intersects.length > 0) {
this.app.selectNode(intersects[0].object.userData.id);
} else {
// Clicked on empty space
this.app.deselectNode();
}
}
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 Logic
*/
class UIManager {
constructor(app) {
this.app = app;
this.listEl = document.getElementById('node-list');
// Prop Panels
this.panelNode = document.getElementById('prop-node');
this.panelGlobal = document.getElementById('prop-global');
// Inputs
this.iName = document.getElementById('node-name');
this.iTags = document.getElementById('node-tags');
this.iBody = document.getElementById('node-body');
// Global Inputs
this.iRange = document.getElementById('range-slider');
this.iRangeVal = document.getElementById('range-val');
this.iRepulsion = document.getElementById('repulsion-slider');
// Context Menu
this.ctxMenu = document.getElementById('context-menu');
this.ctxTargetId = null;
this.bindEvents();
}
bindEvents() {
// Node Props
const saver = () => {
if(this.app.selectedNodeId) {
this.app.updateNodeData(this.app.selectedNodeId, this.iName.value, this.iTags.value, this.iBody.value);
}
};
this.iName.oninput = saver;
this.iTags.onchange = saver;
this.iBody.oninput = saver;
// Global Props
this.iRange.oninput = (e) => {
this.app.visibleRange = parseInt(e.target.value);
this.iRangeVal.innerText = this.app.visibleRange;
this.app.updateVisibility();
};
this.iRepulsion.oninput = (e) => {
this.app.physicsParams.repulsion = parseInt(e.target.value);
};
// Context Menu Global Click (Close)
document.addEventListener('click', (e) => {
this.ctxMenu.style.display = 'none';
});
// Context Menu Actions
document.getElementById('ctx-cut').onclick = () => { if(this.ctxTargetId) this.app.cutNode(this.ctxTargetId); };
document.getElementById('ctx-copy').onclick = () => { if(this.ctxTargetId) this.app.copyNode(this.ctxTargetId); };
document.getElementById('ctx-paste').onclick = () => { this.app.pasteNode(); };
document.getElementById('ctx-delete').onclick = () => { if(this.ctxTargetId) this.app.deleteNode(this.ctxTargetId); };
// File Load
document.getElementById('file-input').onchange = (e) => {
const reader = new FileReader();
reader.onload = (ev) => {
this.app.nodes = JSON.parse(ev.target.result);
this.app.nextId = Math.max(...this.app.nodes.map(n=>n.id)) + 1;
this.app.updateVisibility();
this.updateLeftPanel();
};
reader.readAsText(e.target.files[0]);
};
}
updateSelection() {
this.updateLeftPanel(); // Refresh highlight
this.app.visualizer.updateHighlights();
if(this.app.selectedNodeId) {
this.panelNode.classList.remove('hidden');
this.panelGlobal.classList.add('hidden');
const n = this.app.nodes.find(x => x.id === this.app.selectedNodeId);
this.iName.value = n.name;
this.iTags.value = n.tags.join(', ');
this.iBody.value = n.body;
} else {
this.panelNode.classList.add('hidden');
this.panelGlobal.classList.remove('hidden');
}
}
updateLeftPanel() {
this.listEl.innerHTML = '';
this.app.nodes.forEach(node => {
const div = document.createElement('div');
div.className = 'list-item';
if(node.id === this.app.selectedNodeId) div.classList.add('active');
div.innerText = node.name;
div.onclick = (e) => {
e.stopPropagation();
this.app.selectNode(node.id);
};
div.oncontextmenu = (e) => {
e.preventDefault();
this.ctxTargetId = node.id;
this.ctxMenu.style.left = e.pageX + 'px';
this.ctxMenu.style.top = e.pageY + 'px';
this.ctxMenu.style.display = 'block';
};
this.listEl.appendChild(div);
});
}
}
// --- FIX: Global Instance Assignment ---
// Ensuring app is accessible from inline onclick handlers
var app;
window.onload = () => {
app = new App();
window.app = app; // Explicit global exposure
};
</script>
</body>
</html>

コメント