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>

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

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
Todoの検索ツール「Visible Grid」|古井和雄
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