Todoの検索ツール「Visible Grid」


【更新履歴】

・2026/2/11 1.0公開。
・2026/2/12 1.2日本語版を公開。(AI連携など追加)


画像

使い方のヒント

  1. 画像の貼り付け:

    • ノードを選択し、右側の「Image URL」欄に画像のURL(例: https://imgur.com/example.jpg など)を入力してください。キューブがその画像に変わります。

    • 注意: CORS(セキュリティ制限)により、一部のサイトの画像は表示されない場合があります。

  2. 最短経路(Pathfinding):

    • ステップ1: ノードA(ゴールにしたいノード)を選択します。

    • ステップ2: 右パネルの「Set as Path Target」ボタンを押します(またはリストで右クリックしてSet Target)。

    • ステップ3: 別のノードB(スタート地点)を選択します。

    • 結果: AとBをつなぐ最短のルートが、赤い太線で表示されます。

  3. Markdown:

    • Content欄に **太字** や URL を入力すると、すぐ下のプレビュー欄で見やすく表示され、リンクもクリックできるようになります。

・ダウンロードされる方はこちら。↓

・ソースコードはこちら。↓

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Visible Grid 1.2 日本語版</title>
    <style>
        /* --- Base & Layout --- */
        :root { --accent: #00e5ff; --bg-dark: #0b0c10; --panel-bg: rgba(20, 24, 30, 0.95); --text-main: #c5c6c7; }
        body, html { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background-color: var(--bg-dark); color: var(--text-main); font-family: 'Inter', 'Yu Gothic UI', sans-serif; user-select: none; }
        
        /* Menu Bar */
        #menubar { height: 45px; background: linear-gradient(90deg, #1f2833 0%, #0b0c10 100%); display: flex; align-items: center; padding: 0 20px; border-bottom: 1px solid #333; z-index: 10; }
        .brand { font-weight: 800; color: var(--accent); margin-right: 30px; font-size: 1.2em; letter-spacing: 1.5px; text-shadow: 0 0 10px rgba(0,229,255,0.3); }
        .menu-btn { background: #2b3540; border: 1px solid #45a29e; color: #66fcf1; padding: 5px 15px; margin-right: 10px; cursor: pointer; border-radius: 4px; font-size: 0.8em; font-weight: bold; transition: all 0.2s; }
        .menu-btn:hover { background: #45a29e; color: #0b0c10; box-shadow: 0 0 8px rgba(69,162,158,0.5); }
        /* AI Button Special Style */
        .btn-ai { background: linear-gradient(45deg, #7b1fa2, #4a148c); border-color: #ba68c8; color: #e1bee7; }
        .btn-ai:hover { background: linear-gradient(45deg, #9c27b0, #6a1b9a); box-shadow: 0 0 10px rgba(186,104,200, 0.6); color: #fff; }

        .menu-spacer { flex: 1; }
        .status-msg { font-size: 0.85em; color: #45a29e; margin-right: 15px; font-family: monospace; }

        /* Main Container */
        #container { display: flex; height: calc(100% - 45px); }

        /* Panels */
        .panel { width: 280px; background: var(--panel-bg); border-right: 1px solid #333; display: flex; flex-direction: column; z-index: 5; backdrop-filter: blur(5px); }
        #right-panel { border-left: 1px solid #333; border-right: none; }
        
        .panel-header { padding: 15px; background: rgba(255,255,255,0.03); font-weight: bold; border-bottom: 1px solid #333; font-size: 0.75em; text-transform: uppercase; letter-spacing: 1.2px; color: #66fcf1; }
        
        /* List */
        #node-list { flex: 1; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #333 transparent; }
        .list-item { padding: 12px 15px; cursor: pointer; border-bottom: 1px solid #222; font-size: 0.9em; border-left: 3px solid transparent; transition: all 0.2s; }
        .list-item:hover { background: rgba(102, 252, 241, 0.1); padding-left: 18px; }
        .list-item.active { background: rgba(102, 252, 241, 0.15); color: #fff; border-left: 3px solid var(--accent); }
        .tag-pill { display: inline-block; background: #222; padding: 2px 6px; border-radius: 4px; font-size: 0.7em; color: #888; margin-right: 4px; border: 1px solid #333; }

        /* Canvas */
        #center-panel { flex: 1; position: relative; background: radial-gradient(circle at center, #1f2833 0%, #0b0c10 80%); overflow: hidden; }
        #canvas-container { width: 100%; height: 100%; outline: none; }
        
        /* Forms */
        .prop-group { padding: 15px; border-bottom: 1px solid #333; }
        .prop-label { display: block; margin-bottom: 8px; font-size: 0.7em; color: #888; text-transform: uppercase; letter-spacing: 0.5px; }
        
        input, textarea, select { width: 100%; background: #181b21; border: 1px solid #333; color: #eee; padding: 10px; margin-bottom: 12px; box-sizing: border-box; border-radius: 4px; font-family: inherit; font-size: 0.9em; transition: border 0.2s; }
        input:focus, textarea:focus, select:focus { border-color: var(--accent); outline: none; box-shadow: 0 0 5px rgba(0,229,255,0.2); }
        textarea { height: 100px; resize: vertical; line-height: 1.5; }
        
        button.action-btn { width: 100%; padding: 10px; background: #2b3540; border: 1px solid #333; color: #ddd; cursor: pointer; margin-top: 5px; border-radius: 4px; transition: all 0.2s; font-size: 0.85em; }
        button.action-btn:hover { background: #3a4755; border-color: #555; }
        button.btn-primary { background: linear-gradient(45deg, #1e3c72, #2a5298); border: none; color: white; }
        button.btn-primary:hover { opacity: 0.9; }
        button.btn-danger { background: #3a1c1c; border-color: #522; color: #ff8888; }
        button.btn-danger:hover { background: #522; }

        /* Linked Nodes List */
        .linked-node-item { display: flex; align-items: center; justify-content: space-between; background: #1a1a1a; padding: 6px 10px; margin-bottom: 4px; border-radius: 3px; font-size: 0.85em; }
        .remove-link-btn { color: #ff6b6b; cursor: pointer; font-weight: bold; padding: 0 5px; }

        .hidden { display: none !important; }
        
        /* Context Menu */
        #context-menu { position: absolute; display: none; background: rgba(30,30,30,0.95); border: 1px solid #444; box-shadow: 0 10px 30px rgba(0,0,0,0.8); z-index: 2000; min-width: 160px; border-radius: 6px; backdrop-filter: blur(10px); overflow: hidden; }
        .ctx-item { padding: 10px 15px; cursor: pointer; color: #ddd; font-size: 0.9em; border-bottom: 1px solid rgba(255,255,255,0.05); }
        .ctx-item:hover { background: var(--accent); color: #000; }
        .ctx-item:last-child { border-bottom: none; }

        /* --- AI Modal --- */
        #ai-modal { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 9000; display: none; align-items: center; justify-content: center; backdrop-filter: blur(5px); }
        #ai-modal-content { width: 600px; max-width: 90%; background: #1a1c23; border: 1px solid #444; border-radius: 8px; box-shadow: 0 0 30px rgba(0,0,0,0.8); display: flex; flex-direction: column; overflow: hidden; }
        .ai-header { padding: 15px 20px; background: linear-gradient(90deg, #4a148c, #1a1c23); color: #fff; font-weight: bold; display: flex; justify-content: space-between; align-items: center; }
        .ai-body { padding: 20px; max-height: 80vh; overflow-y: auto; }
        .ai-step { margin-bottom: 25px; border-left: 2px solid #555; padding-left: 15px; transition: 0.3s; }
        .ai-step.active { border-left-color: #ba68c8; }
        .ai-step-title { font-weight: bold; color: #ccc; margin-bottom: 8px; font-size: 0.9em; text-transform: uppercase; }
        .ai-desc { font-size: 0.85em; color: #888; margin-bottom: 10px; }
        
        .code-box { position: relative; }
        .code-copy-btn { position: absolute; top: 5px; right: 5px; background: rgba(0,0,0,0.6); color: #fff; border: 1px solid #555; padding: 2px 8px; font-size: 0.75em; border-radius: 4px; cursor: pointer; }
        .code-copy-btn:hover { background: #444; }
        textarea.code-area { font-family: 'Consolas', monospace; font-size: 0.85em; color: #a5d6a7; background: #0f1115; line-height: 1.4; border-color: #333; }
        
        .close-btn { background: none; border: none; color: #aaa; cursor: pointer; font-size: 1.5em; line-height: 1; }
        .close-btn:hover { color: #fff; }

    </style>
    
    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
</head>
<body>

    <div id="menubar">
        <div class="brand">GRID PRO <span style="font-size:0.5em; opacity:0.6; vertical-align:middle;">AI</span></div>
        <button class="menu-btn" onclick="app.addNode()">+ ノード</button>
        <button class="menu-btn" onclick="app.saveFile()">JSON保存</button>
        <button class="menu-btn" onclick="document.getElementById('file-input').click()">JSON読込</button>
        <button class="menu-btn btn-ai" onclick="aiManager.open()">✨ AI 設計</button>
        
        <div class="menu-spacer"></div>
        <button class="menu-btn" onclick="app.clearAllData()" style="border-color:#522; color:#faa;">リセット</button>
        <div id="status-bar" class="status-msg">System Ready</div>
    </div>

    <div id="ai-modal">
        <div id="ai-modal-content">
            <div class="ai-header">
                <span>AI Architect (構造化アシスタント)</span>
                <button class="close-btn" onclick="aiManager.close()">×</button>
            </div>
            <div class="ai-body">
                <div class="ai-step active">
                    <div class="ai-step-title">Step 1: どのようなグラフを作りますか?</div>
                    <div class="ai-desc">作りたい相関図、マインドマップ、ナレッジグラフの内容を自然言語で入力してください。</div>
                    <textarea id="ai-input" placeholder="例: ファンタジーRPGの世界設定を作って。国、種族、魔法体系を含めて。国同士は敵対関係や同盟関係で繋いで。"></textarea>
                    <button class="action-btn btn-primary" onclick="aiManager.generatePrompt()">プロンプト生成</button>
                </div>

                <div class="ai-step">
                    <div class="ai-step-title">Step 2: AIへの指示 (コピー & 送信)</div>
                    <div class="ai-desc">以下のテキストをコピーして、ChatGPT, Gemini, Claude 等に貼り付けてください。</div>
                    <div class="code-box">
                        <textarea id="ai-prompt-output" class="code-area" readonly style="height:120px;"></textarea>
                        <button class="code-copy-btn" onclick="aiManager.copyPrompt()">コピー</button>
                    </div>
                </div>

                <div class="ai-step">
                    <div class="ai-step-title">Step 3: 結果の反映</div>
                    <div class="ai-desc">AIからの回答(JSON部分)をここに貼り付けて「反映」を押してください。</div>
                    <textarea id="ai-json-input" class="code-area" placeholder='[ { "name": "...", ... } ]' style="height:120px; color:#fff;"></textarea>
                    <button class="action-btn" style="background: linear-gradient(90deg, #7b1fa2, #4a148c); color:white; border:none;" onclick="aiManager.apply()">グラフを生成して反映</button>
                </div>
            </div>
        </div>
    </div>

    <div id="container">
        <div id="left-panel" class="panel">
            <div class="panel-header">Knowledge Nodes</div>
            <div style="padding:10px; border-bottom:1px solid #333;">
                <input type="text" id="search-input" placeholder="検索 (名前/タグ)..." style="margin-bottom:0;">
            </div>
            <div id="node-list"></div>
        </div>

        <div id="center-panel">
            <div id="canvas-container"></div>
        </div>

        <div id="right-panel" class="panel">
            <div id="prop-node" class="hidden">
                <div class="panel-header">Edit Node</div>
                <div class="prop-group">
                    <label class="prop-label">ノード名</label>
                    <input type="text" id="node-name">
                    
                    <label class="prop-label">タグ (自動リンク)</label>
                    <input type="text" id="node-tags" placeholder="例: 企画, 重要">

                    <label class="prop-label">直接リンク (手動接続)</label>
                    <select id="link-select" onchange="app.addManualLink(this.value)">
                        <option value="">+ ノードを選択して接続...</option>
                    </select>
                    <div id="manual-links-list" style="margin-top:8px;"></div>
                    
                    <label class="prop-label" style="margin-top:15px;">メモ (Markdown)</label>
                    <textarea id="node-body"></textarea>
                </div>
                <div class="prop-group">
                    <button class="action-btn" onclick="app.setPathTarget()">経路探索ゴールに設定</button>
                    <button class="action-btn btn-danger" onclick="app.deleteNode(app.selectedNodeId)">このノードを削除</button>
                </div>
            </div>

            <div id="prop-global">
                <div class="panel-header">Settings</div>
                
                <div class="prop-group">
                    <label class="prop-label">表示深度: <span id="range-val" style="color:var(--accent)">2</span></label>
                    <input type="range" id="range-slider" min="1" max="10" value="2">
                </div>
                
                <div class="prop-group">
                    <label class="prop-label">経路探索</label>
                    <div id="path-status" style="font-size:0.8em; color:#888; margin-bottom:8px;">開始ノード選択後、ゴールを設定してください。</div>
                    <button class="action-btn" onclick="app.clearPath()">パスをクリア</button>
                </div>

                <div class="prop-group">
                    <button class="action-btn btn-primary" onclick="app.visualizer.resetCamera()">カメラリセット</button>
                    <button class="action-btn" onclick="app.explode()">レイアウト再計算</button>
                </div>
            </div>
        </div>
    </div>

    <div id="context-menu">
        <div class="ctx-item" id="ctx-connect">ここに接続 (リンク)</div>
        <div class="ctx-item" id="ctx-copy">コピー</div>
        <div class="ctx-item" id="ctx-paste">貼り付け</div>
        <div class="ctx-item" id="ctx-delete" style="color:#ff6b6b;">削除</div>
    </div>
    <input type="file" id="file-input" style="display:none" accept=".json">

    <script>
        /**
         * Core Application Logic 2.1 (AI Enhanced)
         */
        class App {
            constructor() {
                this.nodes = [];
                this.nextId = 1;
                this.selectedNodeId = null;
                this.targetNodeId = null;
                this.clipboard = null;
                
                this.visibleRange = 2;
                this.searchQuery = "";
                this.physicsParams = { repulsion: 800, spring: 0.03, damping: 0.92 };

                this.visibleNodeIds = new Set();
                this.activeLinks = [];
                this.pathLinks = new Set();

                this.ui = new UIManager(this);
                this.visualizer = new Visualizer(this);

                this.visualizer.init();
                this.loadData();
                this.physicsLoop();
            }

            // --- Data ---
            loadData() {
                const saved = localStorage.getItem('neural_workspace_v2');
                if (saved) {
                    try {
                        const data = JSON.parse(saved);
                        this.nodes = data.nodes;
                        this.nodes.forEach(n => { if(!n.links) n.links = []; });
                        this.nextId = data.nextId || 1;
                        this.ui.setStatus("Workspace Loaded.");
                    } catch (e) { this.initDemoData(); }
                } else {
                    this.initDemoData();
                }
                
                if(this.nodes.length > 0) this.selectNode(this.nodes[0].id);
                else this.refresh();
            }

            saveData() {
                const data = { nodes: this.nodes, nextId: this.nextId };
                localStorage.setItem('neural_workspace_v2', JSON.stringify(data));
                this.ui.setStatus("Auto-saved.");
            }

            initDemoData() {
                this.nodes = [
                    { id: 1, name: "プロジェクト・アルファ", tags: ["Project"], links: [2,3], body: "メインプロジェクト。", x:0, y:0, z:0, vx:0, vy:0, vz:0 },
                    { id: 2, name: "要件定義書", tags: ["Doc", "Important"], links: [], body: "顧客要望リスト。", x:5, y:2, z:0, vx:0, vy:0, vz:0 },
                    { id: 3, name: "UIデザイン", tags: ["Design"], links: [4], body: "Figmaリンクなど。", x:-5, y:2, z:0, vx:0, vy:0, vz:0 },
                    { id: 4, name: "カラーパレット", tags: ["Design", "Asset"], links: [], body: "#00E5FF", x:-8, y:5, z:2, vx:0, vy:0, vz:0 },
                    { id: 5, name: "バックエンドAPI", tags: ["Dev"], links: [1], body: "Node.js Server", x:5, y:-5, z:2, vx:0, vy:0, vz:0 }
                ];
                this.nextId = 6;
            }

            clearAllData() {
                if(confirm("全データを削除しますか?")) {
                    this.nodes = [];
                    this.nextId = 1;
                    this.selectedNodeId = null;
                    this.targetNodeId = null;
                    localStorage.removeItem('neural_workspace_v2');
                    this.addNode();
                }
            }

            // --- Actions ---
            addNode() {
                const id = this.nextId++;
                this.nodes.push({
                    id: id, name: "新規ノード " + id, tags: [], links: [], body: "",
                    x: (Math.random()-0.5)*15, y: (Math.random()-0.5)*15, z: (Math.random()-0.5)*15,
                    vx:0, vy:0, vz:0
                });
                this.selectNode(id);
            }

            deleteNode(id) {
                this.nodes.forEach(n => {
                    n.links = n.links.filter(lid => lid != id);
                });
                this.nodes = this.nodes.filter(n => n.id !== id);
                if(this.selectedNodeId === id) this.selectedNodeId = null;
                this.refresh();
            }

            addManualLink(targetIdStr) {
                if(!this.selectedNodeId || !targetIdStr) return;
                const targetId = parseInt(targetIdStr);
                const source = this.nodes.find(n => n.id === this.selectedNodeId);
                
                if(source && targetId !== source.id && !source.links.includes(targetId)) {
                    source.links.push(targetId);
                    this.refresh();
                    this.ui.updateSelection();
                }
            }

            removeManualLink(targetId) {
                const source = this.nodes.find(n => n.id === this.selectedNodeId);
                if(source) {
                    source.links = source.links.filter(id => id !== targetId);
                    this.refresh();
                    this.ui.updateSelection();
                }
            }

            selectNode(id) {
                this.selectedNodeId = id;
                this.pathLinks.clear();
                if (this.targetNodeId && this.targetNodeId !== id) {
                    this.calculatePath(id, this.targetNodeId);
                }
                this.refresh();
                this.ui.updateSelection();
            }

            setPathTarget() {
                if(this.selectedNodeId) {
                    this.targetNodeId = this.selectedNodeId;
                    this.ui.setStatus(`Goal Set: ${this.targetNodeId}`);
                    this.ui.updatePathStatus();
                    this.refresh();
                }
            }
            
            clearPath() {
                this.targetNodeId = null;
                this.pathLinks.clear();
                this.refresh();
                this.ui.updatePathStatus();
            }

            // --- Logic ---
            calculatePath(startId, endId) {
                const queue = [[startId]];
                const visited = new Set([startId]);
                let path = null;

                while (queue.length > 0) {
                    const currentPath = queue.shift();
                    const currId = currentPath[currentPath.length - 1];

                    if (currId === endId) {
                        path = currentPath;
                        break;
                    }

                    const currNode = this.nodes.find(n => n.id === currId);
                    if(!currNode) continue;

                    this.nodes.forEach(n => {
                        if (!visited.has(n.id)) {
                            const isLinked = currNode.links.includes(n.id) || n.links.includes(currNode.id);
                            const shared = currNode.tags.filter(t => n.tags.includes(t)).length > 0;
                            if (isLinked || shared) {
                                visited.add(n.id);
                                queue.push([...currentPath, n.id]);
                            }
                        }
                    });
                }

                if (path) {
                    this.ui.setStatus(`Path Found: ${path.length - 1} steps`);
                    for(let i=0; i<path.length-1; i++) {
                        this.pathLinks.add(`${path[i]}-${path[i+1]}`);
                        this.pathLinks.add(`${path[i+1]}-${path[i]}`);
                    }
                } else {
                    this.ui.setStatus("No Path Found");
                }
            }

            updateVisibility() {
                this.visibleNodeIds.clear();
                this.activeLinks = [];
                const searchVal = this.ui.iSearch.value.toLowerCase();

                if(searchVal.length > 0) {
                    this.nodes.forEach(n => {
                        const hit = n.name.toLowerCase().includes(searchVal) || n.tags.some(t=>t.toLowerCase().includes(searchVal));
                        if(hit) this.visibleNodeIds.add(n.id);
                    });
                } 
                else if (this.selectedNodeId === null) {
                    this.nodes.forEach(n => this.visibleNodeIds.add(n.id));
                } else {
                    const q = [{id: this.selectedNodeId, dist: 0}];
                    const visited = new Set([this.selectedNodeId]);
                    this.visibleNodeIds.add(this.selectedNodeId);
                    if(this.targetNodeId) this.visibleNodeIds.add(this.targetNodeId);

                    while(q.length > 0) {
                        const curr = q.shift();
                        if (curr.dist >= this.visibleRange) continue;
                        
                        const currNode = this.nodes.find(n => n.id === curr.id);
                        if (!currNode) continue;

                        this.nodes.forEach(other => {
                            if (!visited.has(other.id)) {
                                const isLinked = currNode.links.includes(other.id) || other.links.includes(currNode.id);
                                const shared = currNode.tags.some(t => other.tags.includes(t));
                                
                                if (isLinked || shared) {
                                    visited.add(other.id);
                                    this.visibleNodeIds.add(other.id);
                                    q.push({id: other.id, dist: curr.dist + 1});
                                }
                            }
                        });
                    }
                }

                const visArr = Array.from(this.visibleNodeIds);
                for(let i=0; i<visArr.length; i++) {
                    for(let j=i+1; j<visArr.length; j++) {
                        const n1 = this.nodes.find(n => n.id === visArr[i]);
                        const n2 = this.nodes.find(n => n.id === visArr[j]);
                        
                        const isManual = n1.links.includes(n2.id) || n2.links.includes(n1.id);
                        const sharedTags = n1.tags.filter(t => n2.tags.includes(t));
                        
                        if(isManual || sharedTags.length > 0) {
                            const isPath = this.pathLinks.has(`${n1.id}-${n2.id}`);
                            this.activeLinks.push({
                                source: n1, target: n2, 
                                type: isManual ? 'manual' : 'tag', 
                                isPath: isPath
                            });
                        }
                    }
                }
            }

            refresh() {
                this.updateVisibility();
                this.ui.updateList();
                this.visualizer.updateScene();
                this.saveData();
            }

            explode() {
                this.physicsParams.repulsion = 2000;
                setTimeout(() => this.physicsParams.repulsion = 800, 500);
            }

            physicsLoop() {
                requestAnimationFrame(() => this.physicsLoop());
                
                const activeNodes = this.nodes.filter(n => this.visibleNodeIds.has(n.id));
                const dt = 0.05;

                // Repulsion
                for(let i=0; i<activeNodes.length; i++) {
                    for(let j=i+1; j<activeNodes.length; j++) {
                        const n1 = activeNodes[i];
                        const n2 = activeNodes[j];
                        let dx = n1.x - n2.x, dy = n1.y - n2.y, dz = n1.z - n2.z;
                        let distSq = dx*dx + dy*dy + dz*dz || 0.1;
                        let force = this.physicsParams.repulsion / distSq;
                        let dist = Math.sqrt(distSq);
                        
                        n1.vx += (dx/dist)*force*dt; n1.vy += (dy/dist)*force*dt; n1.vz += (dz/dist)*force*dt;
                        n2.vx -= (dx/dist)*force*dt; n2.vy -= (dy/dist)*force*dt; n2.vz -= (dz/dist)*force*dt;
                    }
                }

                // Attraction
                this.activeLinks.forEach(l => {
                    let dx = l.target.x - l.source.x, dy = l.target.y - l.source.y, dz = l.target.z - l.source.z;
                    let dist = Math.sqrt(dx*dx + dy*dy + dz*dz) || 0.1;
                    
                    let targetLen = l.isPath ? 4 : (l.type === 'manual' ? 8 : 12);
                    let k = l.isPath ? 0.3 : (l.type === 'manual' ? 0.15 : this.physicsParams.spring);
                    
                    let force = (dist - targetLen) * k;
                    let fx = (dx/dist)*force, fy = (dy/dist)*force, fz = (dz/dist)*force;

                    l.source.vx += fx*dt; l.source.vy += fy*dt; l.source.vz += fz*dt;
                    l.target.vx -= fx*dt; l.target.vy -= fy*dt; l.target.vz -= fz*dt;
                });

                // Damping
                activeNodes.forEach(n => {
                    n.vx -= n.x * 0.005 * dt; n.vy -= n.y * 0.005 * dt; n.vz -= n.z * 0.005 * dt;
                    n.vx *= this.physicsParams.damping; n.vy *= this.physicsParams.damping; n.vz *= this.physicsParams.damping;
                    n.x += n.vx; n.y += n.vy; n.z += n.vz;
                });

                this.visualizer.sync();
            }

            saveFile() {
                const blob = new Blob([JSON.stringify({nodes:this.nodes, nextId:this.nextId}, null, 2)], {type:"application/json"});
                const a = document.createElement('a');
                a.href = URL.createObjectURL(blob);
                a.download = "grid_data.json";
                a.click();
            }
        }

        /**
         * AI Manager (New)
         */
        class AIManager {
            constructor(app) {
                this.app = app;
                this.modal = document.getElementById('ai-modal');
                this.input = document.getElementById('ai-input');
                this.output = document.getElementById('ai-prompt-output');
                this.jsonInput = document.getElementById('ai-json-input');
            }

            open() { this.modal.style.display = 'flex'; }
            close() { this.modal.style.display = 'none'; }

            generatePrompt() {
                const userReq = this.input.value;
                if(!userReq) return alert("要望を入力してください。");

                const prompt = `あなたはデータ構造化の専門家です。以下の要望に基づき、ナレッジグラフ用のデータをJSON形式で作成してください。

【ユーザー要望】
${userReq}

【制約・ルール】
1. 配列形式 \`[ ... ]\` のJSONのみを出力してください。Markdownのコードブロック( \`\`\`json )は含めないでください。
2. 各オブジェクトは以下の構造にしてください:
   {
     "name": "ノード名 (必須)",
     "tags": ["タグ1", "タグ2"],
     "body": "説明文 (Markdown対応)",
     "connects_to": ["接続先のノード名1", "接続先のノード名2"] 
   }
3. "connects_to" を使って、関連するノード同士を明示的に繋いでください(名前で指定)。
4. "tags" は、属性やカテゴリ(例: "国", "人物", "概念")を入れてください。同じタグを持つものは自動的に緩く繋がります。

【出力JSON例】
[
  { "name": "勇者", "tags": ["人物", "主人公"], "body": "伝説の剣を持つ。", "connects_to": ["魔王"] },
  { "name": "魔王", "tags": ["人物", "敵"], "body": "世界征服を企む。", "connects_to": [] }
]`;
                this.output.value = prompt;
            }

            copyPrompt() {
                this.output.select();
                document.execCommand('copy');
                this.app.ui.setStatus("プロンプトをコピーしました! AIに貼り付けてください。");
            }

            apply() {
                let raw = this.jsonInput.value.trim();
                // Remove code blocks if AI included them despite instructions
                raw = raw.replace(/```json/g, '').replace(/```/g, '').trim();
                
                try {
                    const data = JSON.parse(raw);
                    if(!Array.isArray(data)) throw new Error("ルートは配列である必要があります");

                    // 1. Create Nodes
                    const newNodes = [];
                    const nameToId = {};
                    
                    data.forEach(item => {
                        const id = this.app.nextId++;
                        const node = {
                            id: id,
                            name: item.name || "名称未設定",
                            tags: item.tags || [],
                            links: [],
                            body: item.body || "",
                            // Random position near center but dispersed
                            x: (Math.random()-0.5)*10, 
                            y: (Math.random()-0.5)*10, 
                            z: (Math.random()-0.5)*10,
                            vx:0, vy:0, vz:0
                        };
                        newNodes.push(node);
                        nameToId[node.name] = id;
                        this.app.nodes.push(node);
                    });

                    // 2. Resolve Links (connects_to names -> ids)
                    data.forEach((item, index) => {
                        if(item.connects_to && Array.isArray(item.connects_to)) {
                            const sourceId = newNodes[index].id;
                            const sourceNode = this.app.nodes.find(n => n.id === sourceId);
                            
                            item.connects_to.forEach(targetName => {
                                const targetId = nameToId[targetName];
                                // Also try case-insensitive search if direct lookup fails
                                if(!targetId) {
                                    const match = this.app.nodes.find(n => n.name.toLowerCase() === targetName.toLowerCase());
                                    if(match) sourceNode.links.push(match.id);
                                } else {
                                    sourceNode.links.push(targetId);
                                }
                            });
                        }
                    });

                    this.app.refresh();
                    this.app.explode(); // scatter new nodes
                    this.close();
                    this.app.ui.setStatus(`${newNodes.length}個のノードを生成しました。`);
                    this.input.value = "";
                    this.jsonInput.value = "";

                } catch(e) {
                    alert("JSONの解析に失敗しました。形式を確認してください。\n" + e.message);
                }
            }
        }

        /**
         * 3D Visualizer 2.1
         */
        class Visualizer {
            constructor(app) {
                this.app = app;
                this.container = document.getElementById('canvas-container');
                this.meshMap = new Map();
                this.linkMeshes = [];
                this.selectionRing = null;
                this.scene = null;
                this.raycaster = new THREE.Raycaster();
                this.mouse = new THREE.Vector2();
            }

            init() {
                this.scene = new THREE.Scene();
                this.scene.fog = new THREE.FogExp2(0x0b0c10, 0.02); 

                this.camera = new THREE.PerspectiveCamera(60, this.container.clientWidth/this.container.clientHeight, 0.1, 1000);
                this.camera.position.set(0, 20, 40);

                this.renderer = new THREE.WebGLRenderer({antialias: true, alpha: true});
                this.renderer.setSize(this.container.clientWidth, this.container.clientHeight);
                this.renderer.setPixelRatio(window.devicePixelRatio);
                this.container.appendChild(this.renderer.domElement);

                this.controls = new THREE.OrbitControls(this.camera, this.renderer.domElement);
                this.controls.enableDamping = true;
                this.controls.dampingFactor = 0.05;

                const amb = new THREE.AmbientLight(0xffffff, 0.4);
                const pLight = new THREE.PointLight(0x00e5ff, 1, 100);
                pLight.position.set(10, 20, 10);
                this.scene.add(amb);
                this.scene.add(pLight);

                const grid = new THREE.GridHelper(200, 40, 0x1f2833, 0x0b0c10);
                grid.position.y = -20;
                grid.material.opacity = 0.2;
                grid.material.transparent = true;
                this.scene.add(grid);

                const ringGeo = new THREE.RingGeometry(2.2, 2.4, 32);
                const ringMat = new THREE.MeshBasicMaterial({ color: 0x00e5ff, side: THREE.DoubleSide, transparent:true, opacity:0.8 });
                this.selectionRing = new THREE.Mesh(ringGeo, ringMat);
                this.selectionRing.visible = false;
                this.scene.add(this.selectionRing);

                window.addEventListener('resize', () => this.onResize());
                this.renderer.domElement.addEventListener('pointerdown', (e) => this.onClick(e));

                this.animate();
            }

            updateScene() {
                if(!this.scene) return;

                const currentIds = new Set(this.meshMap.keys());
                currentIds.forEach(id => {
                    if(!this.app.visibleNodeIds.has(id)) {
                        const obj = this.meshMap.get(id);
                        this.scene.remove(obj.mesh);
                        this.scene.remove(obj.label);
                        this.meshMap.delete(id);
                    }
                });

                const geo = new THREE.IcosahedronGeometry(1, 1);
                
                this.app.visibleNodeIds.forEach(id => {
                    if(!this.meshMap.has(id)) {
                        const node = this.app.nodes.find(n => n.id === id);
                        
                        const color = this.getColor(node);
                        const mat = new THREE.MeshPhongMaterial({ 
                            color: color, 
                            emissive: color,
                            emissiveIntensity: 0.3,
                            shininess: 50
                        });

                        const mesh = new THREE.Mesh(geo, mat);
                        mesh.userData = { id: id };
                        this.scene.add(mesh);

                        const label = this.createLabel(node.name);
                        this.scene.add(label);

                        this.meshMap.set(id, {mesh, label});
                    }
                });

                this.linkMeshes.forEach(l => this.scene.remove(l));
                this.linkMeshes = [];
                
                const manualMat = new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.4 });
                const tagMat = new THREE.LineBasicMaterial({ color: 0x45a29e, transparent: true, opacity: 0.15 });
                const pathMat = new THREE.LineBasicMaterial({ color: 0xff0055, linewidth: 3, opacity: 0.8 });

                this.app.activeLinks.forEach(link => {
                    const g = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]);
                    let mat = tagMat;
                    if(link.isPath) mat = pathMat;
                    else if(link.type === 'manual') mat = manualMat;

                    const l = new THREE.Line(g, mat);
                    l.userData = { link: link };
                    this.scene.add(l);
                    this.linkMeshes.push(l);
                });

                this.updateHighlights();
            }

            getColor(node) {
                if(!node.tags || node.tags.length === 0) return 0xaaaaaa;
                let hash = 0;
                let str = node.tags[0];
                for (let i = 0; i < str.length; i++) hash = str.charCodeAt(i) + ((hash << 5) - hash);
                const c = (hash & 0x00FFFFFF).toString(16).toUpperCase();
                return "#" + "00000".substring(0, 6 - c.length) + c;
            }

            createLabel(text) {
                const canvas = document.createElement('canvas');
                const ctx = canvas.getContext('2d');
                canvas.width = 300; canvas.height = 80;
                ctx.font = "Bold 36px sans-serif";
                ctx.fillStyle = "rgba(0,0,0,0.6)"; 
                ctx.roundRect(10, 10, 280, 60, 10);
                ctx.fill();
                ctx.fillStyle = "#ffffff";
                ctx.textAlign = "center";
                ctx.fillText(text, 150, 52);
                
                const tex = new THREE.CanvasTexture(canvas);
                const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false });
                const sprite = new THREE.Sprite(mat);
                sprite.scale.set(6, 1.6, 1);
                sprite.renderOrder = 999;
                return sprite;
            }

            updateHighlights() {
                const isSearching = this.app.ui.iSearch.value.length > 0;
                this.meshMap.forEach((obj, id) => {
                    const isSel = id === this.app.selectedNodeId;
                    const isTarget = id === this.app.targetNodeId;
                    
                    if(isSearching) obj.mesh.scale.setScalar(1); 

                    if (isSel) {
                        this.selectionRing.visible = true;
                        obj.mesh.scale.setScalar(1.3);
                    } else if (isTarget) {
                        obj.mesh.scale.setScalar(1.2);
                        obj.mesh.material.emissive.setHex(0xff0000);
                    } else {
                        obj.mesh.scale.setScalar(1);
                        obj.mesh.material.emissiveIntensity = 0.3;
                    }
                });
                if(!this.app.selectedNodeId) this.selectionRing.visible = false;
            }

            sync() {
                if(!this.scene) return;
                this.meshMap.forEach((obj, id) => {
                    const n = this.app.nodes.find(x => x.id === id);
                    if(n) {
                        obj.mesh.position.set(n.x, n.y, n.z);
                        obj.label.position.set(n.x, n.y + 1.8, n.z);
                    }
                    if(id === this.app.selectedNodeId) {
                        this.selectionRing.position.set(n.x, n.y, n.z);
                        this.selectionRing.lookAt(this.camera.position);
                    }
                });

                this.linkMeshes.forEach(line => {
                    const l = line.userData.link;
                    const pos = line.geometry.attributes.position.array;
                    pos[0]=l.source.x; pos[1]=l.source.y; pos[2]=l.source.z;
                    pos[3]=l.target.x; pos[4]=l.target.y; pos[5]=l.target.z;
                    line.geometry.attributes.position.needsUpdate = true;
                });
            }

            onClick(e) {
                const rect = this.renderer.domElement.getBoundingClientRect();
                this.mouse.x = ((e.clientX - rect.left)/rect.width)*2 - 1;
                this.mouse.y = -((e.clientY - rect.top)/rect.height)*2 + 1;
                this.raycaster.setFromCamera(this.mouse, this.camera);
                const intersects = this.raycaster.intersectObjects(
                    Array.from(this.meshMap.values()).map(o=>o.mesh)
                );
                
                if(intersects.length > 0) {
                    this.app.selectNode(intersects[0].object.userData.id);
                }
            }

            resetCamera() {
                this.camera.position.set(0,20,40);
                this.controls.reset();
            }

            onResize() {
                this.camera.aspect = this.container.clientWidth/this.container.clientHeight;
                this.camera.updateProjectionMatrix();
                this.renderer.setSize(this.container.clientWidth, this.container.clientHeight);
            }

            animate() {
                requestAnimationFrame(()=>this.animate());
                this.controls.update();
                this.renderer.render(this.scene, this.camera);
            }
        }

        /**
         * UI Manager 2.1
         */
        class UIManager {
            constructor(app) {
                this.app = app;
                this.listEl = document.getElementById('node-list');
                this.statusEl = document.getElementById('status-bar');
                
                this.pNode = document.getElementById('prop-node');
                this.pGlobal = document.getElementById('prop-global');
                
                this.iName = document.getElementById('node-name');
                this.iTags = document.getElementById('node-tags');
                this.iBody = document.getElementById('node-body');
                this.iSearch = document.getElementById('search-input');
                this.linkSelect = document.getElementById('link-select');
                this.linkList = document.getElementById('manual-links-list');

                this.bindEvents();
            }

            setStatus(msg) {
                this.statusEl.innerText = msg;
                this.statusEl.style.opacity = 1;
                setTimeout(()=>this.statusEl.style.opacity = 0.5, 3000);
            }

            bindEvents() {
                const saver = () => {
                    if(this.app.selectedNodeId) {
                        const n = this.app.nodes.find(x => x.id === this.app.selectedNodeId);
                        n.name = this.iName.value;
                        n.tags = this.iTags.value.split(',').map(t => t.trim()).filter(t => t);
                        n.body = this.iBody.value;
                        this.app.refresh();
                    }
                };
                [this.iName, this.iTags, this.iBody].forEach(el => el.addEventListener('input', saver));

                document.getElementById('range-slider').oninput = (e) => {
                    this.app.visibleRange = parseInt(e.target.value);
                    document.getElementById('range-val').innerText = this.app.visibleRange;
                    this.app.refresh();
                };

                this.iSearch.oninput = () => this.app.refresh();

                // Context Menu
                const cm = document.getElementById('context-menu');
                document.addEventListener('click', () => cm.style.display='none');
                
                document.getElementById('ctx-connect').onclick = () => {
                   this.app.addManualLink(this.ctxId);
                };
                document.getElementById('ctx-copy').onclick = () => {
                    const n = this.app.nodes.find(x=>x.id===this.ctxId);
                    if(n) {
                        this.app.clipboard = JSON.parse(JSON.stringify(n));
                        this.setStatus("Copied to clipboard");
                    }
                };
                document.getElementById('ctx-paste').onclick = () => {
                    if(this.app.clipboard) {
                        const n = {...this.app.clipboard, id: this.app.nextId++, name: this.app.clipboard.name+" (Copy)", links: [], x:0, y:0, z:0};
                        this.app.nodes.push(n);
                        this.app.selectNode(n.id);
                    }
                };
                document.getElementById('ctx-delete').onclick = () => this.app.deleteNode(this.ctxId);
            }

            updatePathStatus() {
                const s = document.getElementById('path-status');
                if(this.app.targetNodeId) {
                    s.innerText = `Goal: Node ${this.app.targetNodeId}`;
                    s.style.color = "#45a29e";
                } else {
                    s.innerText = "No goal set.";
                    s.style.color = "#888";
                }
            }

            updateSelection() {
                this.updateList();
                this.app.visualizer.updateHighlights();
                
                if(this.app.selectedNodeId) {
                    this.pNode.classList.remove('hidden');
                    this.pGlobal.classList.add('hidden');
                    const n = this.app.nodes.find(x => x.id === this.app.selectedNodeId);
                    this.iName.value = n.name;
                    this.iTags.value = Array.isArray(n.tags) ? n.tags.join(', ') : '';
                    this.iBody.value = n.body;
                    
                    // Update Link Selector
                    this.linkSelect.innerHTML = '<option value="">+ 接続先を選択...</option>';
                    this.app.nodes.forEach(other => {
                        if(other.id !== n.id && !n.links.includes(other.id)) {
                            const opt = document.createElement('option');
                            opt.value = other.id;
                            opt.innerText = other.name;
                            this.linkSelect.appendChild(opt);
                        }
                    });

                    // Update Connected List
                    this.linkList.innerHTML = '';
                    n.links.forEach(lid => {
                        const target = this.app.nodes.find(t => t.id === lid);
                        if(target) {
                            const div = document.createElement('div');
                            div.className = 'linked-node-item';
                            div.innerHTML = `<span onclick="app.selectNode(${lid})" style="cursor:pointer; text-decoration:underline;">${target.name}</span> <span class="remove-link-btn" onclick="app.removeManualLink(${lid})">×</span>`;
                            this.linkList.appendChild(div);
                        }
                    });

                } else {
                    this.pNode.classList.add('hidden');
                    this.pGlobal.classList.remove('hidden');
                }
            }

            updateList() {
                this.listEl.innerHTML = '';
                const term = this.iSearch.value.toLowerCase();
                
                this.app.nodes.forEach(n => {
                    if(term && !n.name.toLowerCase().includes(term) && !n.tags.some(t=>t.toLowerCase().includes(term))) return;

                    const el = document.createElement('div');
                    el.className = 'list-item';
                    if(n.id === this.app.selectedNodeId) el.classList.add('active');
                    
                    let tagHtml = n.tags.slice(0,3).map(t => `<span class="tag-pill">${t}</span>`).join('');
                    el.innerHTML = `<div>${n.name}</div><div style="margin-top:4px;">${tagHtml}</div>`;
                    
                    el.onclick = () => this.app.selectNode(n.id);
                    el.oncontextmenu = (e) => {
                        e.preventDefault();
                        this.ctxId = n.id;
                        const cm = document.getElementById('context-menu');
                        cm.style.display = 'block';
                        cm.style.left = e.pageX + 'px';
                        cm.style.top = e.pageY + 'px';
                    }
                    this.listEl.appendChild(el);
                });
            }
        }

        // --- Init ---
        var app, aiManager;
        window.onload = () => {
            app = new App();
            aiManager = new AIManager(app);
            window.app = app;
            window.aiManager = aiManager;
        };

    </script>
</body>
</html>

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

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
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