Todoの検索ツール「Visible Grid」
【更新履歴】
・2026/2/11 1.0公開。
・2026/2/12 1.2日本語版を公開。(AI連携など追加)
・2026/2/12 1.3日本語版を公開。(表示モードなど追加)
・2026/2/12 1.4日本語版を公開。(フォルダ形式に変更)
Visible Grid 取扱説明書
概要
・Visible Grid は、
アイデアやデータを
3次元空間で構造化するツールです。
・従来のナレッジグラフツールと異なり、
「1つのノードを1つのJSONファイル」として
PC上のフォルダで直接管理する仕組みを採用しています。
・これにより、OSの検索機能との親和性を高め、
大量のデータを安全かつ柔軟に管理できます。
動作環境
必須ブラウザ:
・Google Chrome または Microsoft Edge(PC版)
※「File System Access API」を使用するため、
これら以外のブラウザやスマホでは
一部機能(フォルダ直接編集)が制限されます。
基本操作(マウス・キーボード)
・左クリック
… ノードの選択(カメラは動きません)
・ダブルクリック
… ノードの選択 + フォーカス(対象を画面中央に捉えます)
・右ドラッグ
… カメラの移動(パン)
・左ドラッグ
… カメラの回転(※モードにより変更可)
・ホイール
… ズームイン・アウト
・PageUp / PageDown
… ズームイン・アウト(キーボード操作)
・Home
… カメラ位置を初期状態にリセット
ファイル管理(フォルダ・ワークスペース)
・本ツールは、指定したフォルダ内にある .json ファイルを
… すべて読み込み、グラフとして構築します。
• 新規ワークスペース:
… 空のフォルダを選択して、新しい作業を開始します。
• 開く (フォルダ):
… JSONファイルが入ったフォルダを選択して読み込みます。
サブフォルダ(グループ)も読み込みます。
• すべて保存:
… 現在の状態をフォルダに一括保存します。
ファイル名は ノード名_タグ_タグ_ID.json の形式で保存され、
OSのエクスプローラー上でも検索しやすくなっています。
• JSONファイルに保存:
… バックアップ用として、全データをまとめた
単一のJSONファイルをダウンロードします。
ノードの編集とプロパティ
・ノードを選択すると、右パネルで詳細を編集できます。
・変更はリアルタイムにプレビューされます。
• 名前: ノードの名称。
• グループ:
… 所属グループ。
保存時、自動的に同名のサブフォルダが作成され、
そこにファイルが格納されます。
• 色設定:
… カラーピッカーで色を変更できます。
ドラッグ中も即座に横線や3Dモデルに色が反映されます。
• アイコン:
… 「選択...」ボタンから画像ファイルを読み込めます。
画像データはファイル内に埋め込まれます(DataURL)。
• メモ (Markdown):
… 「編集」ボタンを押すと、エディタ画面が開きます。
ビュー・ドック(表示モード切替)
・画面下部のボタンでレイアウトを瞬時に切り替えます。
1. 🕸️ 3D Graph: … 物理演算による力学モデル配置。
2. 🌳 Tree: … 選択中のノードを頂点とした階層ツリー表示。
3. 🎯 Radial: … 選択中のノードを画面中央 (0,0,0) に固定し、
接続ノードを円形に配置します。
4. 📝 List: … 3D表示をオフにし、2Dのリストボックス形式で
高速に閲覧・編集します。
メニュー詳細
• カメラ (Camera)
・回転 / 移動 / ズーム:
… 左ドラッグ時の挙動を切り替えます。
・注視してズーム:
… 画面中央(選択ノード)に向かって一直線にズームします。
・注視して旋回:
… 選択中のノードをロックオンし、
その周囲を回り込むようにカメラを動かします。
• ツール (Tool)
・パスをクリア:
… 経路探索のハイライトを消去します。
・画面キャプチャ:
… 現在の表示を高解像度画像として保存します。
• ✨ AI 設計:
… AIに「〇〇の相関図を作って」と指示し、
生成されたデータを貼り付けることで
グラフを自動構築します。
最短経路探索
1. ゴール設定:
… 目標のノードを選択し、
右パネルの「経路探索ゴールに設定」を押します。
2. スタート選択:
… 任意のノードをクリックすると、
ゴールまでの最短ルートが赤く光ります。
・ダウンロードされる方はこちら。↓
・ソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Visible Grid 1.4</title>
<style>
/* --- Base & Layout --- */
:root { --accent: #00e5ff; --bg-dark: #0b0c10; --panel-bg: rgba(20, 24, 30, 0.98); --text-main: #ffffff; --menu-bg: #1a1c23; --menu-hover: #2979ff; }
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: 40px; background: #15171e; display: flex; align-items: center; padding: 0 10px; border-bottom: 1px solid #333; z-index: 100; position: relative; }
.brand { font-weight: 800; color: var(--accent); margin-right: 20px; font-size: 1.1em; letter-spacing: 1px; padding: 0 10px; cursor: default; }
.menu-item { position: relative; padding: 0 15px; height: 100%; display: flex; align-items: center; cursor: pointer; font-size: 0.9em; transition: background 0.2s; color: #ccc; }
.menu-item:hover { background: #2a2d35; color: #fff; }
.menu-item:hover .dropdown { display: block; }
.dropdown { display: none; position: absolute; top: 100%; left: 0; background: var(--menu-bg); min-width: 240px; box-shadow: 0 4px 12px rgba(0,0,0,0.5); border: 1px solid #333; border-top: none; z-index: 101; }
.dd-item { padding: 10px 15px; font-size: 0.9em; color: #ddd; cursor: pointer; display: flex; justify-content: space-between; border-bottom: 1px solid rgba(255,255,255,0.05); }
.dd-item:hover { background: var(--menu-hover); color: #fff; }
.dd-sep { height: 1px; background: #333; margin: 2px 0; }
.shortcut { font-size: 0.8em; color: #888; margin-left: 10px; }
.menu-spacer { flex: 1; }
.status-msg { font-size: 0.85em; color: var(--accent); margin-right: 15px; font-family: monospace; }
.ai-btn { background: linear-gradient(45deg, #7b1fa2, #4a148c); border: 1px solid #ba68c8; color: #fff; padding: 4px 12px; border-radius: 4px; font-size: 0.85em; cursor: pointer; margin-left: 10px; }
/* Panels */
#container { display: flex; height: calc(100% - 40px); }
.panel { width: 320px; 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: 10px 15px; background: rgba(255,255,255,0.05); font-weight: bold; border-bottom: 1px solid #333; font-size: 0.75em; text-transform: uppercase; color: var(--accent); }
/* Tabs */
.tab-bar { display: flex; background: #15171e; }
.tab { flex: 1; text-align: center; padding: 10px; cursor: pointer; font-size: 0.8em; color: #888; border-bottom: 2px solid transparent; transition: 0.2s; }
.tab.active { color: var(--accent); border-bottom-color: var(--accent); background: #1e222b; color: #fff; }
/* Lists */
#node-list, #group-list, #tag-list, #main-list-view { flex: 1; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #333 transparent; background: #121418; }
.list-box-item {
padding: 8px 12px; cursor: pointer; border-bottom: 1px solid #222;
display: flex; align-items: center; gap: 12px; transition: background 0.1s;
border-left: 6px solid transparent;
}
.list-box-item:hover { background: #1f232a; }
.list-box-item.active { background: #2a303b; padding-left: 12px; }
.node-icon-visual { width: 32px; height: 32px; flex-shrink: 0; border-radius: 50%; background-size: cover; background-position: center; box-shadow: 0 0 5px rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; background-color: #000; }
.node-ball { width: 100%; height: 100%; border-radius: 50%; background: radial-gradient(circle at 30% 30%, rgba(255,255,255,1), rgba(255,255,255,0) 30%), radial-gradient(circle at 50% 50%, currentColor, #222); }
.node-info { flex: 1; overflow: hidden; }
.node-name { font-size: 0.95em; font-weight: bold; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.node-meta { font-size: 0.75em; color: #aaa; margin-top: 2px; }
.group-item { background: #1a1c23; margin-bottom: 1px; }
.group-header { padding: 8px 15px; display: flex; align-items: center; justify-content: space-between; font-weight: bold; font-size: 0.85em; cursor: pointer; color: #ddd; }
.tag-row { padding: 8px 15px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid #222; }
/* Canvas */
#center-panel { flex: 1; position: relative; background: #000; overflow: hidden; display: flex; flex-direction: column; }
#canvas-container { width: 100%; flex: 1; outline: none; }
#main-list-view { display: none; width: 100%; height: 100%; background: #0b0c10; padding: 20px; box-sizing: border-box; }
#main-list-view .list-box-item { background: #15181e; margin-bottom: 5px; border-radius: 4px; border: 1px solid #222; padding: 15px; border-left-width: 6px; }
/* View Dock */
#view-dock {
position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
height: 40px; background: rgba(20, 24, 30, 0.9); border: 1px solid #444; border-radius: 20px;
display: flex; align-items: center; justify-content: center; gap: 10px; z-index: 20; padding: 0 15px;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
}
.view-btn { background: transparent; border: 1px solid transparent; color: #aaa; padding: 4px 12px; border-radius: 15px; cursor: pointer; font-size: 0.8em; display: flex; align-items: center; gap: 5px; transition: 0.2s; }
.view-btn:hover { color: #fff; background: rgba(255,255,255,0.1); }
.view-btn.active { color: #000; background: var(--accent); border-color: var(--accent); font-weight: bold; }
/* Forms */
.prop-group { padding: 15px; border-bottom: 1px solid #333; }
.prop-label { display: flex; justify-content: space-between; align-items: center; margin-bottom: 5px; font-size: 0.7em; color: #aaa; text-transform: uppercase; letter-spacing: 0.5px; }
input, textarea, select { width: 100%; background: #1f232a; border: 1px solid #444; color: #fff; padding: 8px; margin-bottom: 10px; box-sizing: border-box; border-radius: 4px; font-family: inherit; font-size: 0.9em; }
input:focus, textarea:focus, select:focus { border-color: var(--accent); outline: none; }
textarea { height: 80px; resize: vertical; }
input[type="range"] { width: 100%; cursor: pointer; }
input[type="color"] { -webkit-appearance: none; padding: 0; border: 1px solid #444; height: 35px; cursor: pointer; background: none; width: 100%; }
input[type="color"]::-webkit-color-swatch-wrapper { padding: 0; }
input[type="color"]::-webkit-color-swatch { border: none; border-radius: 3px; }
button.action-btn { width: 100%; padding: 8px; background: #2b3540; border: 1px solid #333; color: #ddd; cursor: pointer; margin-top: 5px; border-radius: 4px; transition: 0.2s; font-size: 0.85em; }
button.btn-primary { background: linear-gradient(45deg, #1e3c72, #2a5298); border: none; color: white; }
button.btn-danger { background: #3a1c1c; border-color: #522; color: #ff8888; }
.btn-small { width: auto; padding: 2px 8px; font-size: 0.85em; margin: 0; }
.hidden { display: none !important; }
#context-menu { position: absolute; display: none; background: rgba(30,30,30,0.98); border: 1px solid #444; box-shadow: 0 10px 30px rgba(0,0,0,0.8); z-index: 2000; min-width: 160px; border-radius: 6px; }
.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(--menu-hover); color: #fff; }
/* Modal */
.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); }
.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; max-height: 90vh; }
.modal-header { padding: 15px 20px; background: linear-gradient(90deg, #4a148c, #1a1c23); color: #fff; font-weight: bold; display: flex; justify-content: space-between; align-items: center; }
.modal-body { padding: 20px; overflow-y: auto; }
.ai-step { margin-bottom: 25px; border-left: 2px solid #555; padding-left: 15px; }
textarea.code-area { font-family: 'Consolas', monospace; font-size: 0.85em; color: #a5d6a7; background: #0f1115; }
.close-btn { background: none; border: none; color: #aaa; cursor: pointer; font-size: 1.5em; }
#note-editor-area { width: 100%; height: 400px; background: #121418; color: #ddd; font-family: 'Consolas', monospace; padding: 10px; border: 1px solid #444; resize: none; font-size: 1em; line-height: 1.5; }
#cam-mode-indicator { position: absolute; top: 10px; right: 10px; background: rgba(0,0,0,0.5); padding: 5px 10px; border-radius: 4px; color: var(--accent); font-size: 0.8em; pointer-events: none; z-index:20; }
</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">VISIBLE GRID</div>
<div class="menu-item">
ファイル (File)
<div class="dropdown">
<div class="dd-item" onclick="app.newWorkspace()">新規ワークスペース</div>
<div class="dd-item" onclick="app.openFolder()">開く (フォルダ)</div>
<div class="dd-sep"></div>
<div class="dd-item" onclick="app.saveAllFiles()">すべて保存</div>
<div class="dd-item" onclick="app.downloadSingleJSON()">JSONファイルに保存</div>
</div>
</div>
<div class="menu-item">
カメラ (Camera)
<div class="dropdown">
<div class="dd-item" onclick="app.visualizer.setCamMode('rotate')">回転 (Rotate)</div>
<div class="dd-item" onclick="app.visualizer.setCamMode('pan')">移動 (Pan)</div>
<div class="dd-item" onclick="app.visualizer.setCamMode('zoom')">ズーム (Zoom)</div>
<div class="dd-item" onclick="app.visualizer.setCamMode('focus-zoom')">注視してズーム (Focus Zoom)</div>
<div class="dd-sep"></div>
<div class="dd-item" onclick="app.visualizer.focusSelection()">注視して旋回 (Orbit Selection)</div>
<div class="dd-item" onclick="app.visualizer.resetCamera()">リセット (Home)</div>
</div>
</div>
<div class="menu-item">
ツール (Tool)
<div class="dropdown">
<div class="dd-item" onclick="app.clearPath()">パスをクリア</div>
<div class="dd-item" onclick="app.captureGraph()">画面キャプチャ</div>
</div>
</div>
<button class="ai-btn" onclick="aiManager.open()">✨ AI 設計</button>
<div class="menu-spacer"></div>
<div id="status-bar" class="status-msg">Ready</div>
</div>
<div id="ai-modal" class="modal">
<div class="modal-content">
<div class="modal-header"><span>AI Architect</span><button class="close-btn" onclick="aiManager.close()">×</button></div>
<div class="modal-body">
<div class="ai-step">
<div style="font-weight:bold; color:#ccc; margin-bottom:5px;">Step 1: 要望入力</div>
<textarea id="ai-input" placeholder="例: ファンタジー小説の人物相関図を作って..."></textarea>
<button class="action-btn btn-primary" onclick="aiManager.generatePrompt()">プロンプト生成</button>
</div>
<div class="ai-step">
<div style="font-weight:bold; color:#ccc; margin-bottom:5px;">Step 2: AIへ送信</div>
<textarea id="ai-prompt-output" class="code-area" readonly style="height:80px;"></textarea>
<button class="action-btn" onclick="aiManager.copyPrompt()">コピー</button>
</div>
<div class="ai-step">
<div style="font-weight:bold; color:#ccc; margin-bottom:5px;">Step 3: 結果反映</div>
<textarea id="ai-json-input" class="code-area" placeholder='[ { "name": "...", ... } ]' style="height:80px; color:#fff;"></textarea>
<button class="action-btn btn-primary" onclick="aiManager.apply()">グラフを生成</button>
</div>
</div>
</div>
</div>
<div id="note-modal" class="modal">
<div class="modal-content" style="height:80vh;">
<div class="modal-header" style="background: #333;"><span>メモ編集</span><button class="close-btn" onclick="ui.closeNoteEditor()">×</button></div>
<div class="modal-body" style="display:flex; flex-direction:column; height:100%;">
<textarea id="note-editor-area" placeholder="Markdown形式で入力..."></textarea>
<div style="display:flex; justify-content:flex-end; gap:10px; margin-top:10px;">
<button class="action-btn" onclick="ui.closeNoteEditor()">キャンセル</button>
<button class="action-btn btn-primary" onclick="ui.saveNoteEditor()">変更して閉じる</button>
</div>
</div>
</div>
</div>
<div id="container">
<div id="left-panel" class="panel">
<div class="tab-bar">
<div class="tab active" onclick="ui.switchLeftTab('nodes')">ノード</div>
<div class="tab" onclick="ui.switchLeftTab('groups')">グループ</div>
</div>
<div id="tab-nodes" style="display:flex; flex-direction:column; flex:1; overflow:hidden;">
<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="tab-groups" class="hidden" style="flex:1; overflow:hidden; display:flex; flex-direction:column;">
<div id="group-list"></div>
</div>
</div>
<div id="center-panel">
<div id="canvas-container"></div>
<div id="cam-mode-indicator">Mode: Rotate</div>
<div id="main-list-view"></div>
<div id="view-dock">
<button class="view-btn active" onclick="app.setMode('graph')" id="btn-mode-graph">🕸️ 3D Graph</button>
<button class="view-btn" onclick="app.setMode('tree')" id="btn-mode-tree">🌳 Tree</button>
<button class="view-btn" onclick="app.setMode('radial')" id="btn-mode-radial">🎯 Radial</button>
<button class="view-btn" onclick="app.setMode('list')" id="btn-mode-list">📝 List</button>
<button class="view-btn" onclick="app.captureGraph()">📷 Capture</button>
</div>
</div>
<div id="right-panel" class="panel">
<div class="tab-bar">
<div class="tab active" onclick="ui.switchRightTab('prop')">編集</div>
<div class="tab" onclick="ui.switchRightTab('tags')">タグ</div>
<div class="tab" onclick="ui.switchRightTab('global')">設定</div>
</div>
<div id="tab-prop" style="flex:1; overflow-y:auto;">
<div id="prop-node" class="hidden">
<div class="panel-header">選択中のノード</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-group" list="group-suggestions" placeholder="Group Name">
<datalist id="group-suggestions"></datalist>
<label class="prop-label">色設定</label>
<div style="display:flex; gap:5px;">
<input type="color" id="node-color" style="flex:1">
<button class="action-btn" style="margin-top:0; width:auto;" onclick="document.getElementById('node-color').value='#00e5ff'; app.updateNodeProp(true);">Reset</button>
</div>
<label class="prop-label">
アイコン
<div>
<input type="file" id="icon-file-input" style="display:none;" accept="image/*">
<button class="action-btn btn-small" onclick="document.getElementById('icon-file-input').click()">選択...</button>
</div>
</label>
<input type="text" id="node-icon" placeholder="">
<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:5px;"></div>
<label class="prop-label">
メモ (Markdown)
<button class="action-btn btn-small" onclick="ui.openNoteEditor()">編集</button>
</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-empty" style="padding:20px; text-align:center; color:#555;">ノードを選択してください</div>
</div>
<div id="tab-tags" class="hidden" style="flex:1; overflow-y:auto;">
<div class="panel-header">タグ配色</div>
<div id="tag-list"></div>
</div>
<div id="tab-global" class="hidden" style="flex:1; overflow-y:auto;">
<div class="panel-header">表示カスタマイズ</div>
<div class="prop-group">
<label class="prop-label">接続線長: <span id="conf-link-val">5</span></label>
<input type="range" id="conf-link" min="1" max="30" value="5" oninput="app.updateConfig('linkLength', this.value)">
<label class="prop-label">アイコンサイズ: <span id="conf-size-val">2.0</span></label>
<input type="range" id="conf-size" min="0.5" max="5.0" step="0.1" value="2.0" oninput="app.updateConfig('nodeSize', this.value)">
<label class="prop-label">文字サイズ: <span id="conf-text-val">3.0</span></label>
<input type="range" id="conf-text" min="0.5" max="20.0" step="0.5" value="3.0" oninput="app.updateConfig('textSize', this.value)">
</div>
<div class="panel-header">ビュー設定</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">
<button class="action-btn" onclick="app.visualizer.resetCamera()">視点リセット (Home)</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>
/**
* Logic 10.1: Ultimate (Selection/Focus Fix + Anti-Vanish)
*/
class App {
constructor() {
this.nodes = [];
this.nextId = 1;
this.selectedNodeId = null;
this.targetNodeId = null;
this.dirHandle = null;
this.visibleRange = 2;
this.searchQuery = "";
this.physicsParams = { repulsion: 1000, spring: 0.05, damping: 0.90 };
this.config = { linkLength: 5, nodeSize: 2.0, textSize: 3.0 };
this.visibleNodeIds = new Set();
this.collapsedGroups = new Set();
this.groupCenters = {};
this.pathLinks = new Set();
this.tagColors = {};
this.mode = 'graph';
this.layoutTargets = {};
this.ui = new UIManager(this);
this.visualizer = new Visualizer(this);
this.visualizer.init();
this.initDemoData();
this.physicsLoop();
}
async openFolder() {
if(!window.showDirectoryPicker) { alert("Chrome/Edge only"); return; }
try {
const handle = await window.showDirectoryPicker();
this.dirHandle = handle;
this.nodes = []; this.tagColors = {}; let maxId = 0;
this.ui.setStatus("Reading...");
await this.scanDirectory(handle);
if(this.nodes.length === 0) { this.initDemoData(); this.ui.setStatus("Empty folder"); }
else { this.nextId = maxId + 1; this.ui.setStatus(`Loaded ${this.nodes.length} nodes`); }
this.refresh();
} catch(e) { console.error(e); this.ui.setStatus("Cancelled"); }
}
async scanDirectory(dirHandle) {
for await (const entry of dirHandle.values()) {
if (entry.kind === 'file' && entry.name.endsWith('.json')) {
if(entry.name.startsWith('._')) continue;
try {
const file = await entry.getFile(); const text = await file.text(); const data = JSON.parse(text);
if(data.id && data.name) {
this.sanitizeNode(data);
this.nodes.push(data);
if(data.id >= this.nextId) this.nextId = data.id + 1;
data.tags.forEach(t => { if(!this.tagColors[t]) this.tagColors[t] = this.getRandomColor(); });
}
} catch(e) {}
} else if (entry.kind === 'directory') { await this.scanDirectory(entry); }
}
}
sanitizeNode(n) {
// NaN/Infinity Guard
if(!Number.isFinite(n.x)) n.x = (Math.random()-0.5)*20;
if(!Number.isFinite(n.y)) n.y = (Math.random()-0.5)*20;
if(!Number.isFinite(n.z)) n.z = (Math.random()-0.5)*20;
n.vx=0; n.vy=0; n.vz=0;
if(!n.color) n.color = "#00e5ff"; if(!n.links) n.links = []; if(!n.tags) n.tags = [];
if(n.icon && typeof n.icon === 'string') {
if(n.icon.includes('(') && n.icon.includes(')')) { const m = n.icon.match(/\((http.*?)\)/); if(m) n.icon = m[1]; }
n.icon = n.icon.replace(/[\[\]]/g, ''); if(n.icon.includes("example.com") || n.icon.includes("placeholder")) n.icon = "";
}
}
async newWorkspace() { if(confirm("Clear workspace?")) { this.nodes=[]; this.nextId=1; this.selectedNodeId=null; this.tagColors={}; this.dirHandle=null; await this.openFolder(); } }
async saveNodeFile(node) {
if(!this.dirHandle) return;
try {
const groupName = node.group ? node.group.replace(/[\/\\?%*:|"<>]/g, '_') : "Default";
const groupHandle = await this.dirHandle.getDirectoryHandle(groupName, { create: true });
const safeName = node.name.replace(/[^a-z0-9\u00a0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]/gi, '_') || 'node';
const safeTags = node.tags.map(t => t.replace(/[^a-z0-9\u00a0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]/gi, '_')).join('_');
const fileName = safeTags ? `${safeName}_${safeTags}_${node.id}.json` : `${safeName}_${node.id}.json`;
const fileHandle = await groupHandle.getFileHandle(fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(JSON.stringify(node, null, 2));
await writable.close();
} catch(e) { console.error("Save failed", e); }
}
async saveAllFiles() {
if(!this.dirHandle) { if(confirm("No folder set. Open folder?")) { await this.openFolder(); if(this.dirHandle) { for(const n of this.nodes) await this.saveNodeFile(n); this.ui.setStatus("Saved."); } } return; }
this.ui.setStatus("Saving all..."); for(const n of this.nodes) await this.saveNodeFile(n); this.ui.setStatus("All saved.");
}
downloadSingleJSON() { const blob = new Blob([JSON.stringify({nodes:this.nodes, config:this.config}, null, 2)], {type:"application/json"}); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download="grid_export.json"; a.click(); }
initDemoData() { this.nodes = [{ id: 1, name: "Start Node", group: "System", tags: ["Root"], links: [], body: "Welcome", x:0, y:0, z:0, vx:0, vy:0, vz:0, color:"#00e5ff", icon:"" }]; this.nextId = 2; this.assignAutoColors(); }
updateConfig(key, val) { this.config[key] = parseFloat(val); this.ui.updateConfigUI(); this.refresh(); }
addNode() { const id = this.nextId++; const node = { id: id, name: "Node " + id, group: "Default", tags: [], links: [], body: "", x: (Math.random()-0.5)*10, y: (Math.random()-0.5)*10, z: (Math.random()-0.5)*10, vx:0, vy:0, vz:0, color: "#00e5ff", icon: "" }; this.nodes.push(node); this.selectNode(id, true); this.saveNodeFile(node); if(this.mode !== 'graph' && this.mode !== 'list') this.calcLayout(); }
updateNodeProp(forceSave = false) {
if(!this.selectedNodeId) return;
const n = this.nodes.find(x => x.id === this.selectedNodeId);
if(n) {
n.name = document.getElementById('node-name').value;
n.group = document.getElementById('node-group').value || "Default";
n.color = document.getElementById('node-color').value;
n.icon = document.getElementById('node-icon').value;
n.body = document.getElementById('node-body').value;
const tagVal = document.getElementById('node-tags').value;
n.tags = tagVal.split(',').map(t => t.trim()).filter(t => t);
n.tags.forEach(t => { if(!this.tagColors[t]) this.tagColors[t] = this.getRandomColor(); });
this.sanitizeNode(n);
this.ui.updateListItemColor(n.id, n.color);
this.visualizer.updateNodeColor(n.id, n.color);
if(forceSave) { this.saveNodeFile(n); this.refresh(); }
}
}
deleteNode(id) {
this.nodes.forEach(n => { const oldLen = n.links.length; n.links = n.links.filter(lid => lid != id); if(n.links.length !== oldLen) this.saveNodeFile(n); });
this.nodes = this.nodes.filter(n => n.id !== id);
if(this.selectedNodeId === id) this.selectedNodeId = null;
this.refresh(); if(this.mode !== 'graph' && this.mode !== 'list') this.calcLayout();
}
addManualLink(tid) { if(!this.selectedNodeId || !tid) return; const n = this.nodes.find(x => x.id === this.selectedNodeId); const t = parseInt(tid); if(n && t !== n.id && !n.links.includes(t)) { n.links.push(t); this.saveNodeFile(n); this.refresh(); this.ui.updateSelection(); } }
removeManualLink(tid) { const n = this.nodes.find(x => x.id === this.selectedNodeId); if(n) { n.links = n.links.filter(id => id !== tid); this.saveNodeFile(n); this.refresh(); this.ui.updateSelection(); } }
setMode(m) {
this.mode = m; document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active')); document.getElementById('btn-mode-'+m).classList.add('active');
const isList = (m === 'list'); document.getElementById('canvas-container').style.display = isList ? 'none' : 'block'; document.getElementById('main-list-view').style.display = isList ? 'block' : 'none'; document.getElementById('cam-mode-indicator').style.display = isList ? 'none' : 'block';
if(m === 'graph') this.explode(); else if(!isList) this.calcLayout();
this.refresh();
}
calcLayout() {
let rootId = this.selectedNodeId || (this.nodes.length > 0 ? this.nodes[0].id : null); this.layoutTargets = {}; if(!rootId) return;
if(this.mode === 'radial') {
const rootNode = this.nodes.find(n => n.id === rootId); this.layoutTargets[rootId] = {x:0, y:0, z:0};
const neighbors = []; this.nodes.forEach(n => { if(n.id !== rootId && (rootNode.links.includes(n.id) || n.links.includes(rootId))) neighbors.push(n.id); });
const r1 = 20;
if(neighbors.length > 0) {
neighbors.forEach((nid, i) => { const angle = (Math.PI * 2 / neighbors.length) * i; this.layoutTargets[nid] = { x: Math.cos(angle) * r1, y: Math.sin(angle) * r1, z: 0 }; });
}
this.nodes.forEach(n => { if(n.id !== rootId && !neighbors.includes(n.id)) this.layoutTargets[n.id] = {x: (Math.random()-0.5)*200, y: (Math.random()-0.5)*200, z: -100}; });
} else if(this.mode === 'tree') {
const queue = [{id: rootId, level: 0}]; const visited = new Set([rootId]); this.layoutTargets[rootId] = {x:0, y:0, z:0};
while(queue.length > 0) {
const curr = queue.shift(); const n = this.nodes.find(x=>x.id===curr.id);
const children = []; this.nodes.forEach(other => { if((n.links.includes(other.id) || other.links.includes(n.id)) && !visited.has(other.id)) { children.push(other.id); visited.add(other.id); } });
const width = 12; const height = 12;
children.forEach((cid, i) => { const offset = (i - (children.length-1)/2) * width; const parentPos = this.layoutTargets[curr.id]; this.layoutTargets[cid] = { x: parentPos.x + offset, y: -(curr.level + 1) * height, z: 0 }; queue.push({id: cid, level: curr.level + 1}); });
}
this.nodes.forEach(n => { if(!visited.has(n.id)) this.layoutTargets[n.id] = {x:0, y:-100, z:0}; });
}
}
physicsLoop() {
requestAnimationFrame(() => this.physicsLoop());
const activeNodes = this.nodes.filter(n => this.visibleNodeIds.has(n.id)); const dt = 0.05;
if (this.mode === 'graph') {
for(let i=0; i<activeNodes.length; i++) { for(let j=i+1; j<activeNodes.length; j++) { const n1 = activeNodes[i], 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 f = this.physicsParams.repulsion / dSq; let d = Math.sqrt(dSq); n1.vx += (dx/d)*f*dt; n1.vy += (dy/d)*f*dt; n1.vz += (dz/d)*f*dt; n2.vx -= (dx/d)*f*dt; n2.vy -= (dy/d)*f*dt; n2.vz -= (dz/d)*f*dt; } }
activeNodes.forEach(n => { if(this.groupCenters[n.group] && this.groupCenters[n.group].count > 1) { const c = this.groupCenters[n.group]; n.vx -= (n.x - c.x)*0.02*dt; n.vy -= (n.y - c.y)*0.02*dt; n.vz -= (n.z - c.z)*0.02*dt; } });
activeNodes.forEach(n1 => { n1.links.forEach(tid => { const n2 = activeNodes.find(n => n.id === tid); if(n2) { let dx = n2.x - n1.x, dy = n2.y - n1.y, dz = n2.z - n1.z; let d = Math.sqrt(dx*dx + dy*dy + dz*dz) || 0.1; let f = (d - this.config.linkLength) * 0.1; n1.vx += (dx/d)*f*dt; n1.vy += (dy/d)*f*dt; n1.vz += (dz/d)*f*dt; n2.vx -= (dx/d)*f*dt; n2.vy -= (dy/d)*f*dt; n2.vz -= (dz/d)*f*dt; } }); });
} else if (this.mode !== 'list') {
activeNodes.forEach(n => { const t = this.layoutTargets[n.id]; if(t) { n.vx += (t.x - n.x) * 0.1; n.vy += (t.y - n.y) * 0.1; n.vz += (t.z - n.z) * 0.1; } });
}
activeNodes.forEach(n => {
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;
// Physics Guard (Prevents Vanish)
if(!Number.isFinite(n.x) || Math.abs(n.x)>1000) n.x=0;
if(!Number.isFinite(n.y) || Math.abs(n.y)>1000) n.y=0;
if(!Number.isFinite(n.z) || Math.abs(n.z)>1000) n.z=0;
if(!Number.isFinite(n.vx)) n.vx=0; if(!Number.isFinite(n.vy)) n.vy=0; if(!Number.isFinite(n.vz)) n.vz=0;
});
if(this.mode !== 'list') this.visualizer.sync();
}
selectNode(id, focus = false) {
this.selectedNodeId = id; this.pathLinks.clear();
if(this.targetNodeId && id) this.calculatePath(id, this.targetNodeId);
// For Radial/Tree, re-layout
if(id && (this.mode === 'radial' || this.mode === 'tree')) {
this.calcLayout();
}
this.refresh();
this.ui.updateSelection();
if(focus && id) this.visualizer.focusSelection();
}
setPathTarget() { if(this.selectedNodeId) { this.targetNodeId=this.selectedNodeId; this.refresh(); } }
clearPath() { this.targetNodeId=null; this.pathLinks.clear(); this.refresh(); }
getRandomColor() { return '#' + Math.floor(Math.random()*16777215).toString(16).padStart(6,'0'); }
getTagColor(t) { return this.tagColors[t] || '#888'; }
assignAutoColors() { this.nodes.forEach(n => n.tags.forEach(t => { if(!this.tagColors[t]) this.tagColors[t] = this.getRandomColor(); })); }
updateVisibility() {
this.visibleNodeIds.clear(); this.groupCenters = {}; const sums = {};
this.nodes.forEach(n => { if(!sums[n.group]) sums[n.group] = {x:0, y:0, z:0, count:0}; sums[n.group].x += n.x; sums[n.group].y += n.y; sums[n.group].z += n.z; sums[n.group].count++; });
for(let g in sums) if(sums[g].count>0) this.groupCenters[g] = {x:sums[g].x/sums[g].count, y:sums[g].y/sums[g].count, z:sums[g].z/sums[g].count, count:sums[g].count};
const sv = this.ui.iSearch.value.toLowerCase();
this.nodes.forEach(n => {
let vis = false; if(sv) { if(n.name.toLowerCase().includes(sv) || n.tags.some(t=>t.toLowerCase().includes(sv))) vis = true; } else if (this.selectedNodeId === null) { vis = true; }
else { const cn = this.nodes.find(x=>x.id===this.selectedNodeId); if(cn) { if(n.id===cn.id || cn.links.includes(n.id)||n.links.includes(cn.id)||cn.tags.some(t=>n.tags.includes(t))) vis=true; if(this.visibleRange > 1) vis = true; } else vis = true; }
if(vis && !this.collapsedGroups.has(n.group)) this.visibleNodeIds.add(n.id);
});
}
refresh() { this.updateVisibility(); this.ui.updateLists(); if(this.mode !== 'list') this.visualizer.updateScene(); }
explode() { this.physicsParams.repulsion = 3000; setTimeout(()=>this.physicsParams.repulsion=800, 500); }
captureGraph() { const w = prompt("W", "1920"); const h = prompt("H", "1080"); if(w && h) this.visualizer.capture(parseInt(w), parseInt(h)); }
}
class Visualizer {
constructor(app) {
this.app = app; this.container = document.getElementById('canvas-container'); this.scene = null;
this.meshMap = new Map(); this.groupMeshMap = new Map(); this.linkObjects = []; this.raycaster = new THREE.Raycaster(); this.mouse = new THREE.Vector2(); this.texLoader = new THREE.TextureLoader(); this.textureCache = {};
this.mouseDownPos = new THREE.Vector2();
}
init() {
this.scene = new THREE.Scene();
const w = this.container.clientWidth; const h = this.container.clientHeight;
this.camera = new THREE.PerspectiveCamera(60, w/h, 0.1, 5000); this.camera.position.set(0, 30, 60);
this.renderer = new THREE.WebGLRenderer({antialias: true, alpha: true, preserveDrawingBuffer: true}); this.renderer.setSize(w, h); this.container.appendChild(this.renderer.domElement);
this.controls = new THREE.OrbitControls(this.camera, this.renderer.domElement);
this.scene.add(new THREE.AmbientLight(0xffffff, 1.2));
const dl = new THREE.DirectionalLight(0xffffff, 0.8); dl.position.set(20,50,20); this.scene.add(dl);
const grid = new THREE.GridHelper(500, 100, 0x333333, 0x111111); grid.position.y = -50; grid.material.opacity = 0.3; grid.material.transparent=true; this.scene.add(grid);
this.selectionRing = new THREE.Mesh(new THREE.RingGeometry(2.4, 2.6, 32), new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.DoubleSide, transparent:true })); this.scene.add(this.selectionRing);
window.addEventListener('resize', () => { this.camera.aspect = this.container.clientWidth/this.container.clientHeight; this.camera.updateProjectionMatrix(); this.renderer.setSize(this.container.clientWidth, this.container.clientHeight); });
this.renderer.domElement.addEventListener('pointerdown', (e) => { this.mouseDownPos.set(e.clientX, e.clientY); });
this.renderer.domElement.addEventListener('pointerup', (e) => {
const dx = e.clientX - this.mouseDownPos.x; const dy = e.clientY - this.mouseDownPos.y;
if(Math.abs(dx) < 3 && Math.abs(dy) < 3) { this.onClick(e); }
});
this.renderer.domElement.addEventListener('dblclick', (e) => { this.onDblClick(e); });
document.addEventListener('keydown', (e) => { if(e.code === 'PageUp') this.zoom(1); if(e.code === 'PageDown') this.zoom(-1); if(e.code === 'Home') this.resetCamera(); });
this.animate();
}
setCamMode(m) {
const el = document.getElementById('cam-mode-indicator'); this.camMode = m;
if(m==='rotate') { this.controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.PAN }; el.innerText = "回転"; }
else if(m==='pan') { this.controls.mouseButtons = { LEFT: THREE.MOUSE.PAN, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.ROTATE }; el.innerText = "移動"; }
else if(m==='zoom') { this.controls.mouseButtons = { LEFT: THREE.MOUSE.DOLLY, MIDDLE: THREE.MOUSE.ROTATE, RIGHT: THREE.MOUSE.PAN }; el.innerText = "ズーム"; }
else if(m==='focus-zoom') { this.controls.mouseButtons = { LEFT: THREE.MOUSE.DOLLY, MIDDLE: THREE.MOUSE.ROTATE, RIGHT: THREE.MOUSE.PAN }; el.innerText = "注視ズーム"; }
}
focusSelection() { if(this.app.selectedNodeId) { const n = this.app.nodes.find(x=>x.id===this.app.selectedNodeId); if(n && Number.isFinite(n.x)) { this.controls.target.set(n.x, n.y, n.z); this.controls.update(); } } }
zoom(dir) { const t = this.controls.target; const cp = this.camera.position; const v = new THREE.Vector3().subVectors(cp, t); const l = v.length(); let nl = dir > 0 ? Math.max(5, l * 0.8) : Math.min(1000, l * 1.2); v.setLength(nl); this.camera.position.copy(t).add(v); this.controls.update(); }
capture(tw, th) { const os = new THREE.Vector2(); this.renderer.getSize(os); const oa = this.camera.aspect; this.renderer.setSize(tw, th); this.camera.aspect = tw/th; this.camera.updateProjectionMatrix(); if(this.app.selectedNodeId) { const n = this.app.nodes.find(x=>x.id===this.app.selectedNodeId); if(n) this.controls.target.set(n.x, n.y, n.z); } this.controls.update(); this.renderer.render(this.scene, this.camera); const l = document.createElement('a'); l.download = `cap_${Date.now()}.png`; l.href = this.renderer.domElement.toDataURL('image/png'); l.click(); this.renderer.setSize(os.x, os.y); this.camera.aspect = oa; this.camera.updateProjectionMatrix(); this.controls.update(); }
updateNodeColor(id, color) {
if(this.meshMap.has(id)) {
const obj = this.meshMap.get(id);
const n = this.app.nodes.find(x=>x.id===id);
if(n.icon && this.textureCache[n.icon]) {
obj.mesh.material.map = this.textureCache[n.icon];
obj.mesh.material.color.setHex(0xffffff);
obj.mesh.material.emissive.setHex(0x333333);
} else if (n.icon) {
} else {
obj.mesh.material.map = null;
obj.mesh.material.color.set(color);
obj.mesh.material.emissive.set(color);
}
obj.mesh.material.needsUpdate = true;
}
}
updateScene() {
const vs = this.app.visibleNodeIds;
this.meshMap.forEach((o, id) => { if(!vs.has(id)) { this.scene.remove(o.mesh); this.scene.remove(o.label); this.meshMap.delete(id); } });
const geo = new THREE.SphereGeometry(1, 32, 32);
vs.forEach(id => {
const n = this.app.nodes.find(x=>x.id===id); const sz = this.app.config.nodeSize;
const setupMat = (obj) => {
if(n.icon) {
if(!this.textureCache[n.icon]) {
this.textureCache[n.icon] = this.texLoader.load(n.icon, (tex)=>{
if(this.meshMap.has(id)) {
const o = this.meshMap.get(id);
o.mesh.material.map = tex; o.mesh.material.color.setHex(0xffffff); o.mesh.material.emissive.setHex(0x333333); o.mesh.material.needsUpdate = true;
}
}, undefined, ()=>{
if(this.meshMap.has(id)) {
const o = this.meshMap.get(id);
o.mesh.material.map = null; o.mesh.material.color.set(n.color); o.mesh.material.emissive.set(n.color); o.mesh.material.needsUpdate = true;
}
});
}
if(this.textureCache[n.icon] && this.textureCache[n.icon].image) {
obj.mesh.material.map = this.textureCache[n.icon];
obj.mesh.material.color.setHex(0xffffff);
obj.mesh.material.emissive.setHex(0x333333);
} else {
obj.mesh.material.map = null;
obj.mesh.material.color.set(n.color);
obj.mesh.material.emissive.set(n.color);
}
} else {
obj.mesh.material.map = null;
obj.mesh.material.color.set(n.color);
obj.mesh.material.emissive.set(n.color);
}
obj.mesh.material.needsUpdate = true;
};
if(!this.meshMap.has(id)) {
const mat = new THREE.MeshPhongMaterial({ color: n.color, emissive: n.color, emissiveIntensity: 0.5, shininess: 30 });
const mesh = new THREE.Mesh(geo, mat); mesh.userData = { id: id, type: 'node' }; mesh.scale.setScalar(sz);
mesh.frustumCulled = false; this.scene.add(mesh);
const label = this.createLabel(n.name, this.app.config.textSize, "#ffffff"); this.scene.add(label);
const obj = {mesh, label};
this.meshMap.set(id, obj);
setupMat(obj);
} else {
const obj = this.meshMap.get(id);
setupMat(obj);
obj.mesh.scale.setScalar(sz);
}
});
this.groupMeshMap.forEach((m, g) => { if(!this.app.collapsedGroups.has(g) || !this.app.groupCenters[g]) { this.scene.remove(m); this.groupMeshMap.delete(g); } });
this.app.collapsedGroups.forEach(g => { if(!this.groupMeshMap.has(g)) { const m = new THREE.Mesh(new THREE.BoxGeometry(4,4,4), new THREE.MeshPhongMaterial({color:0xffaa00, wireframe:true, emissive:0xffaa00})); m.userData = { group: g, type: 'group' }; m.frustumCulled = false; const l = this.createLabel(`[G] ${g}`, this.app.config.textSize*1.2, '#ffcc00'); l.position.y=3; m.add(l); this.scene.add(m); this.groupMeshMap.set(g, m); } });
this.linkObjects.forEach(l => { this.scene.remove(l.obj); if(l.sprite) this.scene.remove(l.sprite); }); this.linkObjects = [];
const visArr = Array.from(vs); const gp = (nid) => { const n = this.app.nodes.find(x=>x.id===nid); return this.app.collapsedGroups.has(n.group) ? this.app.groupCenters[n.group] : n; }; const sid = this.app.selectedNodeId;
visArr.forEach(nid => {
const n1 = this.app.nodes.find(x=>x.id===nid); const p1 = gp(nid);
n1.links.forEach(tid => { const n2 = this.app.nodes.find(x=>x.id===tid); if(!n2 || (!vs.has(tid) && !this.app.collapsedGroups.has(n2.group))) return; const p2 = gp(tid); if(p1 !== p2) { const isSel = (n1.id === sid || n2.id === sid); this.createLink(p1, p2, 0xffffff, isSel ? 1.0 : 0.4, isSel, null); } });
});
for(let i=0; i<visArr.length; i++) { for(let j=i+1; j<visArr.length; j++) { const n1 = this.app.nodes.find(x=>x.id===visArr[i]); const n2 = this.app.nodes.find(x=>x.id===visArr[j]); const s = n1.tags.filter(t => n2.tags.includes(t)); if(s.length > 0) { const p1 = gp(n1.id); const p2 = gp(n2.id); if(p1!==p2) { const isSel = (n1.id === sid || n2.id === sid); this.createLink(p1, p2, this.app.getTagColor(s[0]), isSel?0.9:0.5, isSel, s[0]); } } } }
}
createLink(p1, p2, c, op, laz, txt) {
let o; if (laz) { const d = new THREE.Vector3(p1.x,p1.y,p1.z).distanceTo(new THREE.Vector3(p2.x,p2.y,p2.z)); const g = new THREE.CylinderGeometry(0.3, 0.3, d, 8, 1, true); g.rotateX(Math.PI / 2); const m = new THREE.MeshBasicMaterial({ color: c, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending }); o = new THREE.Mesh(g, m); }
else { const pts = [new THREE.Vector3(p1.x,p1.y,p1.z), new THREE.Vector3(p2.x,p2.y,p2.z)]; const g = new THREE.BufferGeometry().setFromPoints(pts); o = new THREE.Line(g, new THREE.LineBasicMaterial({ color: c, transparent: true, opacity: op })); }
o.frustumCulled = false; this.scene.add(o); let s = null; if(txt) { s = this.createLabel(txt, this.app.config.textSize*0.8, c); s.visible = false; this.scene.add(s); }
this.linkObjects.push({ obj:o, sprite:s, p1, p2, isLaser:laz, labelText:txt });
}
createLabel(t, sm, c) { const cvs = document.createElement('canvas'); const ctx = cvs.getContext('2d'); cvs.width=512; cvs.height=128; ctx.font="Bold 70px Arial"; ctx.shadowColor="black"; ctx.shadowBlur=6; ctx.shadowOffsetX=3; ctx.shadowOffsetY=3; ctx.fillStyle=c; ctx.textAlign="center"; ctx.fillText(t, 256, 90); const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map:new THREE.CanvasTexture(cvs), transparent:true, depthTest:false })); sp.scale.set(8 * sm, 2 * sm, 1); sp.renderOrder = 999; return sp; }
sync() {
if(!this.scene) return; const sz = this.app.config.nodeSize;
this.meshMap.forEach((o, id) => { const n = this.app.nodes.find(x => x.id === id); if(n) { o.mesh.position.set(n.x, n.y, n.z); o.label.position.set(n.x, n.y + sz + 1.5, n.z); } });
this.groupMeshMap.forEach((m, g) => { const c = this.app.groupCenters[g]; if(c) m.position.set(c.x, c.y, c.z); });
if(this.app.selectedNodeId) { const n = this.app.nodes.find(x=>x.id===this.app.selectedNodeId); if(n && this.meshMap.has(n.id)) { this.selectionRing.visible = true; this.selectionRing.position.set(n.x, n.y, n.z); this.selectionRing.lookAt(this.camera.position); this.selectionRing.scale.setScalar(sz); } else this.selectionRing.visible = false; } else this.selectionRing.visible = false;
this.linkObjects.forEach(l => {
const v1 = new THREE.Vector3(l.p1.x, l.p1.y, l.p1.z); const v2 = new THREE.Vector3(l.p2.x, l.p2.y, l.p2.z); const mid = v1.clone().add(v2).multiplyScalar(0.5);
if(l.isLaser) { l.obj.position.copy(mid); l.obj.lookAt(v2); const dist = v1.distanceTo(v2); l.obj.scale.set(1, 1, dist); } else { const pos = l.obj.geometry.attributes.position.array; pos[0]=v1.x; pos[1]=v1.y; pos[2]=v1.z; pos[3]=v2.x; pos[4]=v2.y; pos[5]=v2.z; l.obj.geometry.attributes.position.needsUpdate = true; }
if(l.sprite) l.sprite.position.copy(mid);
});
this.updateHighlights();
// Camera Auto-Recovery (Fix Disappearing Bug)
if(!Number.isFinite(this.camera.position.x) || !Number.isFinite(this.camera.position.y) || !Number.isFinite(this.camera.position.z)) {
this.resetCamera();
}
}
updateHighlights() { const sid = this.app.selectedNodeId; const sn = sid ? this.app.nodes.find(x=>x.id===sid) : null; const bt = this.app.config.textSize; this.meshMap.forEach((o, id) => { if(id === sid) o.label.scale.set(10*bt, 2.5*bt, 1); else o.label.scale.set(7*bt, 1.75*bt, 1); }); this.linkObjects.forEach(l => { if(l.sprite) { let c = false; if(sn) { if(l.p1 === sn || l.p2 === sn) c = true; } if(c) { l.sprite.visible = true; l.sprite.scale.set(5*bt, 1.25*bt, 1); } else l.sprite.visible = false; } }); }
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 t = []; this.meshMap.forEach(v=>t.push(v.mesh)); this.groupMeshMap.forEach(v=>t.push(v)); const i = this.raycaster.intersectObjects(t);
if(i.length > 0) {
const d = i[0].object.userData;
if(d.type === 'node') this.app.selectNode(d.id, false); // Single Click: Select Only
else if (d.type === 'group') this.app.toggleGroup(d.group);
} else this.app.selectNode(null);
}
onDblClick(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 t = []; this.meshMap.forEach(v=>t.push(v.mesh)); const i = this.raycaster.intersectObjects(t);
if(i.length > 0) {
const d = i[0].object.userData;
if(d.type === 'node') {
this.app.selectNode(d.id, false);
this.focusSelection(); // DblClick: Focus!
}
}
}
resetCamera() {
this.camera.position.set(0,30,60);
this.controls.target.set(0,0,0);
this.controls.reset();
}
animate() { requestAnimationFrame(()=>this.animate()); this.controls.update(); this.renderer.render(this.scene, this.camera); }
}
class UIManager {
constructor(app) { this.app = app; this.inputs = { name: document.getElementById('node-name'), group: document.getElementById('node-group'), color: document.getElementById('node-color'), icon: document.getElementById('node-icon'), tags: document.getElementById('node-tags'), body: document.getElementById('node-body'), linkSelect: document.getElementById('link-select') }; this.iSearch = document.getElementById('search-input'); this.mainListView = document.getElementById('main-list-view'); this.bindEvents(); }
setStatus(msg) { document.getElementById('status-bar').innerText = msg; }
bindEvents() {
this.inputs.color.addEventListener('input', () => this.app.updateNodeProp(false));
this.inputs.color.addEventListener('change', () => this.app.updateNodeProp(true));
['name','group','icon','tags','body'].forEach(k => { this.inputs[k].addEventListener('change', () => this.app.updateNodeProp(true)); });
document.getElementById('icon-file-input').addEventListener('change', (e) => { const f = e.target.files[0]; if(!f) return; const r = new FileReader(); r.onload = (ev) => { this.inputs.icon.value = ev.target.result; this.app.updateNodeProp(true); }; r.readAsDataURL(f); });
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();
document.addEventListener('click', (e) => { if(!e.target.closest('.menu-item')) document.querySelectorAll('.dropdown').forEach(d => d.style.display='none'); else { const dd = e.target.closest('.menu-item').querySelector('.dropdown'); document.querySelectorAll('.dropdown').forEach(d => { if(d!==dd) d.style.display='none'; }); if(dd) dd.style.display = (dd.style.display==='block' ? 'none' : 'block'); } });
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-delete').onclick = () => this.app.deleteNode(this.ctxId);
}
switchLeftTab(mode) { document.querySelectorAll('#left-panel .tab').forEach(e=>e.classList.remove('active')); document.querySelectorAll('#left-panel .tab')[mode==='nodes'?0:1].classList.add('active'); document.getElementById('tab-nodes').classList.toggle('hidden', mode!=='nodes'); document.getElementById('tab-groups').classList.toggle('hidden', mode!=='groups'); }
switchRightTab(mode) { document.querySelectorAll('#right-panel .tab').forEach(e=>e.classList.remove('active')); const idx = {'prop':0, 'tags':1, 'global':2}[mode]; document.querySelectorAll('#right-panel .tab')[idx].classList.add('active'); ['prop','tags','global'].forEach(m => document.getElementById('tab-'+m).classList.add('hidden')); document.getElementById('tab-'+mode).classList.remove('hidden'); }
updateConfigUI() { document.getElementById('conf-link').value = this.app.config.linkLength; document.getElementById('conf-link-val').innerText = this.app.config.linkLength; document.getElementById('conf-size').value = this.app.config.nodeSize; document.getElementById('conf-size-val').innerText = this.app.config.nodeSize; document.getElementById('conf-text').value = this.app.config.textSize; document.getElementById('conf-text-val').innerText = this.app.config.textSize; }
updateListItemColor(id, color) { const el = document.getElementById('list-item-' + id); if(el) { el.style.borderLeftColor = color; const ball = el.querySelector('.node-ball'); if(ball) ball.style.color = color; } }
updateSelection() {
const nid = this.app.selectedNodeId; if(nid) { document.getElementById('prop-node').classList.remove('hidden'); document.getElementById('prop-empty').classList.add('hidden'); const n = this.app.nodes.find(x=>x.id===nid); this.inputs.name.value = n.name; this.inputs.group.value = n.group; this.inputs.color.value = n.color; this.inputs.icon.value = n.icon || ""; this.inputs.tags.value = n.tags.join(', '); this.inputs.body.value = n.body; this.inputs.linkSelect.innerHTML = '<option value="">+ 接続先...</option>'; this.app.nodes.forEach(o => { if(o.id !== nid && !n.links.includes(o.id)) this.inputs.linkSelect.appendChild(new Option(o.name, o.id)); }); const list = document.getElementById('manual-links-list'); list.innerHTML = ''; n.links.forEach(lid => { const t = this.app.nodes.find(x=>x.id===lid); if(t) list.innerHTML += `<div style="font-size:0.8em; padding:2px; border-bottom:1px solid #333;">🔗 ${t.name} <span style="color:#f55; cursor:pointer; float:right;" onclick="app.removeManualLink(${lid})">×</span></div>`; }); } else { document.getElementById('prop-node').classList.add('hidden'); document.getElementById('prop-empty').classList.remove('hidden'); } this.updateLists();
}
updateLists() {
const genItem = (n) => { let iconHtml = n.icon ? `<div class="node-icon-visual" style="background-image:url('${n.icon}')"></div>` : `<div class="node-icon-visual"><div class="node-ball" style="color:${n.color}"></div></div>`; return `${iconHtml}<div class="node-info"><div class="node-name">${n.name}</div><div class="node-meta">${n.group}</div></div>`; };
const nl = document.getElementById('node-list'); nl.innerHTML = ''; const term = this.iSearch.value.toLowerCase(); const groups = new Set();
this.app.nodes.forEach(n => {
groups.add(n.group); if(term && !n.name.toLowerCase().includes(term)) return;
const el = document.createElement('div'); el.className = 'list-box-item';
el.id = 'list-item-' + n.id;
if(n.id === this.app.selectedNodeId) el.classList.add('active'); el.style.borderLeftColor = n.color; el.innerHTML = genItem(n);
el.onclick = () => this.app.selectNode(n.id, false); // Single Click: Select Only
el.ondblclick = () => this.app.selectNode(n.id, true); // DblClick: Focus!
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'; }; nl.appendChild(el);
});
if(this.app.mode === 'list') { this.mainListView.innerHTML = ''; this.app.nodes.forEach(n => { if(term && !n.name.toLowerCase().includes(term)) return; const el = document.createElement('div'); el.className = 'list-box-item'; if(n.id === this.app.selectedNodeId) el.classList.add('active'); el.style.borderLeftColor = n.color; el.innerHTML = genItem(n);
el.onclick = () => this.app.selectNode(n.id, false); el.ondblclick = () => this.app.selectNode(n.id, true); this.mainListView.appendChild(el); }); }
const gl = document.getElementById('group-list'); gl.innerHTML = ''; Array.from(groups).sort().forEach(g => { const isCollapsed = this.app.collapsedGroups.has(g); const div = document.createElement('div'); div.className = 'group-item'; div.innerHTML = `<div class="group-header" onclick="app.toggleGroup('${g}')"><span><span class="group-toggle">[${isCollapsed?'+':'-'}]</span> ${g}</span></div>`; gl.appendChild(div); });
const dl = document.getElementById('group-suggestions'); dl.innerHTML = ''; groups.forEach(g => dl.appendChild(new Option(g))); const tl = document.getElementById('tag-list'); tl.innerHTML = ''; for(let tag in this.app.tagColors) { const row = document.createElement('div'); row.className = 'tag-row'; const col = this.app.tagColors[tag]; row.innerHTML = `<input type="color" value="${col}" onchange="app.tagColors['${tag}']=this.value; app.refresh();" class="color-preview" style="padding:0; border:none;"><span style="flex:1; color:${col}">${tag}</span>`; tl.appendChild(row); }
}
openNoteEditor() { const n = this.app.nodes.find(x => x.id === this.app.selectedNodeId); if(n) { document.getElementById('note-editor-area').value = n.body; document.getElementById('note-modal').style.display = 'flex'; } }
closeNoteEditor() { document.getElementById('note-modal').style.display = 'none'; }
saveNoteEditor() { const val = document.getElementById('note-editor-area').value; document.getElementById('node-body').value = val; this.app.updateNodeProp(true); this.closeNoteEditor(); }
}
class AIManager { constructor(app) { this.app = app; this.modal = document.getElementById('ai-modal'); } open() { this.modal.style.display = 'flex'; } close() { this.modal.style.display = 'none'; } generatePrompt() { const req = document.getElementById('ai-input').value; const p = `あなたはデータ構造化のプロです。ユーザーの要望に基づき、ネットワークグラフのJSON配列を作成してください。\n【要望】\n${req}\n【必須フォーマット】\n[\n {\n "name": "ノード名",\n "group": "グループ名",\n "tags": ["タグ1"],\n "color": "#HEX",\n "icon": "URL",\n "body": "Markdown",\n "connects_to": ["接続先名"]\n }\n]\nMarkdownコードブロックは不要。純粋なJSONのみ。\n【重要】アイコンURLは実在する画像のURLのみ。不明な場合は空欄 "" にしてください。`; document.getElementById('ai-prompt-output').value = p; } copyPrompt() { document.getElementById('ai-prompt-output').select(); document.execCommand('copy'); } apply() { try { let raw = document.getElementById('ai-json-input').value.trim(); raw = raw.replace(/```json/g, '').replace(/```/g, '').trim(); const data = JSON.parse(raw); if(!Array.isArray(data)) throw new Error("Not Array"); const nameMap = {}; data.forEach(d => { const id = this.app.nextId++; const node = { id: id, name: d.name, group: d.group || "Imported", tags: d.tags || [], color: d.color || "#00e5ff", icon: d.icon || "", body: d.body || "", links: [], x: (Math.random()-0.5)*20, y: (Math.random()-0.5)*20, z: (Math.random()-0.5)*20, vx:0, vy:0, vz:0 }; this.app.nodes.push(node); nameMap[node.name] = id; node.tags.forEach(t => { if(!this.app.tagColors[t]) this.app.tagColors[t] = this.app.getRandomColor(); }); }); data.forEach(d => { if(d.connects_to) { const src = this.app.nodes.find(n=>n.id===nameMap[d.name]); d.connects_to.forEach(tn => { const tid = nameMap[tn]; if(tid) src.links.push(tid); }); } }); this.app.refresh(); this.app.explode(); this.close(); } catch(e) { alert("JSON Error: "+e.message); } } }
var app, aiManager, ui; window.onload = () => { app = new App(); ui = app.ui; aiManager = new AIManager(app); window.app = app; window.aiManager = aiManager; window.ui = ui; };
</script>
</body>
</html>


コメント