Todoの検索ツール「Visible Grid 1.1」
・ダウンロードされる方はこちら。↓
・ソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Visible Grid 1.1</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', Roboto, Helvetica, sans-serif; user-select: none; }
/* Menu Bar */
#menubar { height: 40px; background: #1f1f1f; display: flex; align-items: center; padding: 0 15px; border-bottom: 1px solid #333; box-shadow: 0 2px 5px rgba(0,0,0,0.5); z-index: 10; }
.brand { font-weight: bold; color: #00bcd4; margin-right: 20px; font-size: 1.1em; letter-spacing: 1px; }
.menu-btn { background: #333; border: 1px solid #444; color: #eee; padding: 4px 10px; margin-right: 10px; cursor: pointer; border-radius: 3px; font-size: 0.85em; transition: all 0.2s; }
.menu-btn:hover { background: #444; border-color: #666; }
.menu-spacer { flex: 1; }
.status-msg { font-size: 0.8em; color: #888; margin-right: 10px; }
/* Main Container */
#container { display: flex; height: calc(100% - 40px); }
/* Left Panel */
#left-panel { width: 260px; background: #181818; border-right: 1px solid #333; display: flex; flex-direction: column; z-index: 5; }
.panel-header { padding: 12px; background: #222; font-weight: bold; border-bottom: 1px solid #333; font-size: 0.8em; text-transform: uppercase; letter-spacing: 1px; color: #888; }
#node-list { flex: 1; overflow-y: auto; }
.list-item { padding: 10px 15px; cursor: pointer; border-bottom: 1px solid #222; font-size: 0.9em; border-left: 3px solid transparent; transition: background 0.1s; }
.list-item:hover { background: #252525; }
.list-item.active { background: #2a2a3a; color: #4db8ff; border-left: 3px solid #4db8ff; }
.tag-pill { display: inline-block; background: #333; padding: 1px 5px; border-radius: 3px; font-size: 0.75em; color: #aaa; margin-right: 3px; }
/* Center Panel */
#center-panel { flex: 1; position: relative; background: radial-gradient(circle at center, #222 0%, #050505 100%); overflow: hidden; }
#canvas-container { width: 100%; height: 100%; outline: none; }
/* Right Panel */
#right-panel { width: 320px; background: #181818; border-left: 1px solid #333; display: flex; flex-direction: column; overflow-y: auto; z-index: 5; }
.prop-group { padding: 15px; border-bottom: 1px solid #252525; }
.prop-label { display: block; margin-bottom: 6px; font-size: 0.75em; color: #888; text-transform: uppercase; letter-spacing: 0.5px; }
input[type="text"], input[type="number"], textarea { width: 100%; background: #222; border: 1px solid #333; color: #eee; padding: 8px; margin-bottom: 10px; box-sizing: border-box; border-radius: 3px; font-family: inherit; font-size: 0.9em; }
input:focus, textarea:focus { border-color: #00bcd4; outline: none; }
textarea { height: 120px; resize: vertical; line-height: 1.4; }
button.action-btn { width: 100%; padding: 8px; background: #333; border: 1px solid #444; color: #eee; cursor: pointer; margin-top: 5px; border-radius: 3px; transition: background 0.2s; }
button.action-btn:hover { background: #444; }
button.btn-danger { background: #522; border-color: #733; color: #faa; }
button.btn-danger:hover { background: #733; }
button.btn-primary { background: #005a9e; border-color: #0078d4; }
button.btn-primary:hover { background: #0078d4; }
/* Markdown Preview */
#md-preview { background: #1e1e1e; padding: 10px; border: 1px solid #333; border-radius: 3px; font-size: 0.9em; line-height: 1.5; color: #ddd; max-height: 200px; overflow-y: auto; }
#md-preview h1 { font-size: 1.2em; border-bottom: 1px solid #444; margin-top:0; }
#md-preview a { color: #4db8ff; }
.hidden { display: none !important; }
input[type=range] { width: 100%; cursor: pointer; }
/* Context Menu */
#context-menu { position: absolute; display: none; background: #2a2a2a; border: 1px solid #444; box-shadow: 4px 4px 12px rgba(0,0,0,0.5); z-index: 2000; min-width: 140px; border-radius: 3px; }
.ctx-item { padding: 8px 15px; cursor: pointer; color: #ddd; font-size: 0.9em; }
.ctx-item:hover { background: #00bcd4; color: #000; }
.ctx-sep { height: 1px; background: #444; margin: 2px 0; }
</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 PRO</div>
<button class="menu-btn" onclick="app.addNode()">+ Node</button>
<button class="menu-btn" onclick="app.saveFile()">Export JSON</button>
<button class="menu-btn" onclick="document.getElementById('file-input').click()">Import JSON</button>
<button class="menu-btn" onclick="app.clearAllData()">Clear All</button>
<div class="menu-spacer"></div>
<div id="status-bar" class="status-msg">Ready</div>
</div>
<div id="container">
<div id="left-panel">
<div class="panel-header">Knowledge Base</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" placeholder="e.g. concept, task">
<label class="prop-label">Image URL (Optional)</label>
<input type="text" id="node-image" placeholder="https://...">
<label class="prop-label">Content (Markdown)</label>
<textarea id="node-body"></textarea>
<div id="md-preview"></div>
</div>
<div class="prop-group">
<button class="action-btn" onclick="app.setPathTarget()">Set as Path Target</button>
<button class="action-btn btn-danger" onclick="app.deleteNode(app.selectedNodeId)">Delete Node</button>
</div>
</div>
<div id="prop-global">
<div class="panel-header">Settings & Tools</div>
<div class="prop-group">
<label class="prop-label">Search / Filter</label>
<input type="text" id="search-input" placeholder="Tag or keyword...">
</div>
<div class="prop-group">
<label class="prop-label">Visible Range: <span id="range-val">2</span></label>
<input type="range" id="range-slider" min="1" max="6" value="2">
</div>
<div class="prop-group">
<label class="prop-label">Pathfinding Analysis</label>
<div id="path-status" style="font-size:0.85em; color:#888; margin-bottom:5px;">Select a start node, then click "Set as Target" on another node.</div>
<button class="action-btn" onclick="app.clearPath()">Clear Path</button>
</div>
<div class="prop-group">
<button class="action-btn" onclick="app.visualizer.resetCamera()">Reset Camera</button>
<button class="action-btn" onclick="app.physicsParams.repulsion=1000; app.visualizer.animate()">Explode Layout</button>
</div>
</div>
</div>
</div>
<div id="context-menu">
<div class="ctx-item" id="ctx-copy">Copy Node</div>
<div class="ctx-item" id="ctx-paste">Paste Node</div>
<div class="ctx-sep"></div>
<div class="ctx-item" id="ctx-target">Set Target</div>
<div class="ctx-sep"></div>
<div class="ctx-item" id="ctx-delete" style="color:#ff6b6b;">Delete</div>
</div>
<input type="file" id="file-input" style="display:none" accept=".json">
<script>
/**
* Core Application Logic
*/
class App {
constructor() {
this.nodes = [];
this.nextId = 1;
this.selectedNodeId = null;
this.targetNodeId = null; // For pathfinding
this.clipboard = null;
this.visibleRange = 2;
this.searchQuery = "";
this.physicsParams = { repulsion: 600, spring: 0.05, damping: 0.9 };
this.visibleNodeIds = new Set();
this.activeLinks = [];
this.pathLinks = new Set(); // Links part of the shortest path
// Modules
this.ui = new UIManager(this);
this.visualizer = new Visualizer(this);
// Init Sequence
this.visualizer.init();
this.loadData(); // Load from LocalStorage or Demo
// Physics Loop
this.physicsLoop();
}
// --- Data Management & Persistence ---
loadData() {
const saved = localStorage.getItem('neural_workspace_data');
if (saved) {
try {
const data = JSON.parse(saved);
this.nodes = data.nodes;
this.nextId = data.nextId || 1;
this.ui.setStatus("Loaded from auto-save");
} catch (e) { console.error("Save load error", 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_data', JSON.stringify(data));
this.ui.setStatus("Auto-saved");
}
initDemoData() {
this.nodes = [
{ id: 1, name: "Neural Core", tags: ["System", "Root"], body: "# Welcome\nThis is the core of your graph.", image: "", x:0, y:0, z:0, vx:0, vy:0, vz:0 },
{ id: 2, name: "Features", tags: ["System"], body: "List of features", image: "", x:5, y:0, z:0, vx:0, vy:0, vz:0 },
{ id: 3, name: "3D View", tags: ["UI", "Features"], body: "Powered by Three.js", image: "", x:0, y:5, z:0, vx:0, vy:0, vz:0 },
{ id: 4, name: "Markdown", tags: ["Features", "Text"], body: "**Bold** and *Italic* support.", image: "", x:0, y:0, z:5, vx:0, vy:0, vz:0 },
{ id: 5, name: "Images", tags: ["Features", "Media"], body: "Paste URLs to textures.", image: "https://threejs.org/files/examples/textures/crate.gif", x:-5, y:2, z:2, vx:0, vy:0, vz:0 }
];
this.nextId = 6;
}
clearAllData() {
if(confirm("Clear all data? This cannot be undone.")) {
this.nodes = [];
this.nextId = 1;
this.selectedNodeId = null;
this.targetNodeId = null;
localStorage.removeItem('neural_workspace_data');
this.addNode();
}
}
// --- Node Operations ---
addNode() {
const id = this.nextId++;
this.nodes.push({
id: id, name: "New Node " + id, tags: [], body: "", image: "",
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.selectNode(id);
this.refresh();
}
updateNode(id, data) {
const n = this.nodes.find(x => x.id === id);
if(n) {
Object.assign(n, data);
// Parse tags string to array
if(typeof data.tags === 'string') {
n.tags = data.tags.split(',').map(t => t.trim()).filter(t => t);
}
this.refresh();
}
}
deleteNode(id) {
this.nodes = this.nodes.filter(n => n.id !== id);
if(this.selectedNodeId === id) this.selectedNodeId = null;
this.refresh();
}
selectNode(id) {
this.selectedNodeId = id;
this.pathLinks.clear(); // Reset path on new selection
// If target exists, calc path
if (this.targetNodeId && this.targetNodeId !== id) {
this.calculatePath(id, this.targetNodeId);
}
this.refresh();
this.ui.updateSelection();
}
// --- Pathfinding & Visibility ---
setPathTarget() {
if(this.selectedNodeId) {
this.targetNodeId = this.selectedNodeId;
this.ui.setStatus(`Target set: Node ${this.targetNodeId}`);
this.ui.updatePathStatus();
}
}
clearPath() {
this.targetNodeId = null;
this.pathLinks.clear();
this.refresh();
this.ui.updatePathStatus();
}
calculatePath(startId, endId) {
// Simple BFS for shortest path (unweighted)
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;
}
// Get neighbors
const currNode = this.nodes.find(n => n.id === currId);
if(!currNode) continue;
this.nodes.forEach(n => {
if (!visited.has(n.id)) {
// Check link
const shared = currNode.tags.filter(t => n.tags.includes(t));
if (shared.length > 0) {
visited.add(n.id);
const newPath = [...currentPath, n.id];
queue.push(newPath);
}
}
});
}
this.pathLinks.clear();
if (path) {
this.ui.setStatus(`Path found: ${path.length - 1} hops`);
// Mark links as path
for(let i=0; i<path.length-1; i++) {
const u = path[i];
const v = path[i+1];
const key1 = `${u}-${v}`;
const key2 = `${v}-${u}`;
this.pathLinks.add(key1);
this.pathLinks.add(key2);
}
} else {
this.ui.setStatus("No path found");
}
}
// BFS for Visibility (What to draw)
updateVisibility() {
this.visibleNodeIds.clear();
this.activeLinks = [];
if (this.selectedNodeId === null) {
this.nodes.forEach(n => this.visibleNodeIds.add(n.id)); // Show all if nothing selected
} else {
// Start BFS
const q = [{id: this.selectedNodeId, dist: 0}];
const visited = new Set([this.selectedNodeId]);
this.visibleNodeIds.add(this.selectedNodeId);
// Also force include Target node if set
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 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});
}
}
});
}
}
// Create link objects
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]);
// Check link
const shared = n1.tags.filter(t => n2.tags.includes(t));
if(shared.length > 0) {
const isPath = this.pathLinks.has(`${n1.id}-${n2.id}`);
this.activeLinks.push({source: n1, target: n2, tags: shared, isPath: isPath});
}
}
}
}
refresh() {
this.updateVisibility();
this.ui.updateList();
if(this.visualizer) this.visualizer.updateScene();
this.saveData();
}
// --- Physics ---
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.01;
let dist = Math.sqrt(distSq);
let force = this.physicsParams.repulsion / distSq;
let fx = (dx/dist)*force, fy = (dy/dist)*force, fz = (dz/dist)*force;
n1.vx += fx*dt; n1.vy += fy*dt; n1.vz += fz*dt;
n2.vx -= fx*dt; n2.vy -= fy*dt; n2.vz -= fz*dt;
}
}
// Springs (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 dist = Math.sqrt(dx*dx + dy*dy + dz*dz) || 0.1;
// Path links are tighter
let targetLen = l.isPath ? 5 : 12;
let k = l.isPath ? 0.2 : 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;
});
// Gravity & Integration
activeNodes.forEach(n => {
n.vx -= n.x * 0.01 * dt; n.vy -= n.y * 0.01 * dt; n.vz -= n.z * 0.01 * 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;
});
if(this.visualizer) this.visualizer.sync();
}
// --- IO ---
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 = "neural_knowledge.json";
a.click();
}
}
/**
* 3D Visualizer (Three.js)
*/
class Visualizer {
constructor(app) {
this.app = app;
this.container = document.getElementById('canvas-container');
this.meshMap = new Map();
this.labelMap = new Map();
this.linkMeshes = [];
this.scene = null;
this.raycaster = new THREE.Raycaster();
this.mouse = new THREE.Vector2();
this.texLoader = new THREE.TextureLoader();
}
init() {
this.scene = new THREE.Scene();
this.scene.fog = new THREE.FogExp2(0x050505, 0.015);
// Camera
this.camera = new THREE.PerspectiveCamera(60, this.container.clientWidth/this.container.clientHeight, 0.1, 1000);
this.camera.position.set(0, 20, 50);
// Renderer
this.renderer = new THREE.WebGLRenderer({antialias: true, alpha: 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;
// Lights
const amb = new THREE.AmbientLight(0xffffff, 0.6);
const dir = new THREE.DirectionalLight(0xffffff, 0.8);
dir.position.set(10, 20, 10);
this.scene.add(amb);
this.scene.add(dir);
// Grid
const grid = new THREE.GridHelper(200, 50, 0x222222, 0x111111);
grid.position.y = -20;
this.scene.add(grid);
// Events
window.addEventListener('resize', () => this.onResize());
this.renderer.domElement.addEventListener('pointerdown', (e) => this.onClick(e));
this.animate();
}
// Color generation by tag
getColor(tags) {
if(!tags || tags.length === 0) return 0x0088ff;
// Hash the first tag string
let hash = 0;
let str = 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;
}
// Create Text Sprite
createLabel(text) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = 256; canvas.height = 64;
ctx.font = "Bold 32px Arial";
ctx.fillStyle = "rgba(0,0,0,0.5)";
ctx.fillRect(0,0, 256, 64);
ctx.fillStyle = "white";
ctx.textAlign = "center";
ctx.fillText(text, 128, 42);
const tex = new THREE.CanvasTexture(canvas);
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true });
const sprite = new THREE.Sprite(mat);
sprite.scale.set(8, 2, 1);
return sprite;
}
updateScene() {
if(!this.scene) return;
// 1. Remove stale
const currentIds = new Set(this.meshMap.keys());
currentIds.forEach(id => {
if(!this.app.visibleNodeIds.has(id)) {
this.scene.remove(this.meshMap.get(id));
this.meshMap.delete(id);
// Label remove
this.scene.remove(this.labelMap.get(id));
this.labelMap.delete(id);
}
});
// 2. Add new
const geo = new THREE.BoxGeometry(2, 2, 2);
this.app.visibleNodeIds.forEach(id => {
if(!this.meshMap.has(id)) {
const node = this.app.nodes.find(n => n.id === id);
// Material (Image or Color)
let mat;
if(node.image && node.image.startsWith('http')) {
const tex = this.texLoader.load(node.image);
mat = new THREE.MeshBasicMaterial({ map: tex });
} else {
mat = new THREE.MeshLambertMaterial({ color: this.getColor(node.tags) });
}
const mesh = new THREE.Mesh(geo, mat);
mesh.userData = { id: id };
this.scene.add(mesh);
this.meshMap.set(id, mesh);
// Label
const label = this.createLabel(node.name);
this.scene.add(label);
this.labelMap.set(id, label);
} else {
// Update existing material if needed (e.g. image changed)
// Simplified: assuming just color/pos update in sync()
}
});
// 3. Links
this.linkMeshes.forEach(l => this.scene.remove(l));
this.linkMeshes = [];
const normalMat = new THREE.LineBasicMaterial({ color: 0x444444, transparent: true, opacity: 0.3 });
const pathMat = new THREE.LineBasicMaterial({ color: 0xff0000, linewidth: 3 });
this.app.activeLinks.forEach(link => {
const g = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]);
const l = new THREE.Line(g, link.isPath ? pathMat : normalMat);
l.userData = { link: link };
this.scene.add(l);
this.linkMeshes.push(l);
});
this.updateHighlights();
}
updateHighlights() {
this.meshMap.forEach((mesh, id) => {
const isSel = id === this.app.selectedNodeId;
const isTarget = id === this.app.targetNodeId;
if (isSel) {
mesh.scale.setScalar(1.5);
// Add wireframe selection box if not present? Too complex for single file.
// Just rely on scale and maybe color overlay?
mesh.material.emissive = new THREE.Color(0x333333);
} else if (isTarget) {
mesh.scale.setScalar(1.3);
mesh.material.emissive = new THREE.Color(0x550000);
} else {
mesh.scale.setScalar(1);
mesh.material.emissive = new THREE.Color(0x000000);
}
});
}
sync() {
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.y += 0.005;
// Sync Label
const lbl = this.labelMap.get(id);
if(lbl) lbl.position.set(n.x, n.y + 2.5, n.z);
}
});
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()));
if(intersects.length > 0) {
this.app.selectNode(intersects[0].object.userData.id);
} else {
this.app.selectNode(null);
}
}
resetCamera() {
this.camera.position.set(0,20,50);
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
*/
class UIManager {
constructor(app) {
this.app = app;
this.listEl = document.getElementById('node-list');
this.statusEl = document.getElementById('status-bar');
// Panels
this.pNode = document.getElementById('prop-node');
this.pGlobal = document.getElementById('prop-global');
// Inputs
this.iName = document.getElementById('node-name');
this.iTags = document.getElementById('node-tags');
this.iImage = document.getElementById('node-image');
this.iBody = document.getElementById('node-body');
this.mdPreview = document.getElementById('md-preview');
this.bindEvents();
}
setStatus(msg) {
this.statusEl.innerText = msg;
setTimeout(()=>this.statusEl.innerText="Ready", 3000);
}
bindEvents() {
// Inputs
const saver = () => {
if(this.app.selectedNodeId) {
this.app.updateNode(this.app.selectedNodeId, {
name: this.iName.value,
tags: this.iTags.value,
image: this.iImage.value,
body: this.iBody.value
});
this.renderMarkdown(this.iBody.value);
}
};
[this.iName, this.iTags, this.iImage, this.iBody].forEach(el => el.addEventListener('input', saver));
// Global Inputs
document.getElementById('range-slider').oninput = (e) => {
this.app.visibleRange = parseInt(e.target.value);
document.getElementById('range-val').innerText = this.app.visibleRange;
this.app.refresh();
};
document.getElementById('search-input').oninput = (e) => {
const val = e.target.value.toLowerCase();
Array.from(this.listEl.children).forEach(el => {
const txt = el.innerText.toLowerCase();
el.style.display = txt.includes(val) ? 'block' : 'none';
});
};
// File Import
document.getElementById('file-input').onchange = (e) => {
const r = new FileReader();
r.onload = (ev) => {
try {
const d = JSON.parse(ev.target.result);
this.app.nodes = d.nodes || d; // Handle old/new format
this.app.nextId = d.nextId || 999;
this.app.refresh();
this.setStatus("Imported successfully");
} catch(err) { alert("Invalid JSON"); }
};
r.readAsText(e.target.files[0]);
};
// Context Menu
const cm = document.getElementById('context-menu');
document.addEventListener('click', () => cm.style.display='none');
// Context actions
document.getElementById('ctx-copy').onclick = () => {
if(this.ctxId) {
const n = this.app.nodes.find(x=>x.id===this.ctxId);
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)", x:0, y:0, z:0};
this.app.nodes.push(n);
this.app.selectNode(n.id);
}
};
document.getElementById('ctx-target').onclick = () => {
this.app.targetNodeId = this.ctxId;
this.app.selectNode(this.app.selectedNodeId); // Trigger recalc
this.updatePathStatus();
};
document.getElementById('ctx-delete').onclick = () => this.app.deleteNode(this.ctxId);
}
renderMarkdown(txt) {
let html = txt
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
.replace(/\*\*(.*)\*\*/gim, '<b>$1</b>')
.replace(/\*(.*)\*/gim, '<i>$1</i>')
.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" target="_blank">$1</a>')
.replace(/\n/g, '<br>');
this.mdPreview.innerHTML = html;
}
updatePathStatus() {
const s = document.getElementById('path-status');
if(this.app.targetNodeId) {
const t = this.app.nodes.find(n=>n.id===this.app.targetNodeId);
s.innerHTML = `Target: <b>${t ? t.name : 'Unknown'}</b>`;
} else {
s.innerHTML = "No target set.";
}
}
updateSelection() {
this.updateList(); // Highlights active class
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.iImage.value = n.image || "";
this.iBody.value = n.body;
this.renderMarkdown(n.body);
} else {
this.pNode.classList.add('hidden');
this.pGlobal.classList.remove('hidden');
}
}
updateList() {
this.listEl.innerHTML = '';
this.app.nodes.forEach(n => {
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:2px;">${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);
});
}
}
// --- Start ---
var app;
window.onload = () => {
app = new App();
window.app = app;
};
</script>
</body>
</html>

コメント