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>

いいなと思ったら応援しよう!

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
小ぶりなプログラムを試しに作っているんですが、 ここではその説明書きをしていこうと思います。 こういう機能をつけてみてほしいだとかいった要望があれば コメント欄に書いてみて下さい。 ひまをみて対応します。
Todoの検索ツール「Visible Grid 1.1」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1