@basic-genomics/hivtrace-viz 1.2.6 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,8 @@
11
11
  <link href="./hivtrace.css" rel="stylesheet" />
12
12
  <link rel="shortcut icon" href="#" />
13
13
 
14
+ <!-- Cytoscape.js is bundled in hivtrace.js -->
15
+
14
16
  <style>
15
17
  html,
16
18
  body {
@@ -160,6 +162,30 @@
160
162
  text-decoration: none;
161
163
  }
162
164
 
165
+ /* 自定义状态栏按钮样式 */
166
+ .hivtrace-custom-buttons button {
167
+ display: inline-flex;
168
+ align-items: center;
169
+ justify-content: center;
170
+ gap: 6px;
171
+ padding: 6px 12px;
172
+ border: 1px solid #e2e8f0;
173
+ border-radius: 6px;
174
+ background: #fff;
175
+ color: #374151;
176
+ font-size: 13px;
177
+ cursor: pointer;
178
+ transition: background 0.15s, color 0.15s;
179
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
180
+ }
181
+
182
+ .hivtrace-custom-buttons button:hover,
183
+ .hivtrace-custom-buttons button:focus {
184
+ background: #f4f4f5;
185
+ color: #18181b;
186
+ text-decoration: none;
187
+ }
188
+
163
189
  /* 状态栏 badge 和 label 样式优化 */
164
190
  .hivtrace-status-bar .badge {
165
191
  display: inline-flex;
@@ -638,6 +664,9 @@
638
664
  <li class="disabled" id="attributes-tab">
639
665
  <a href="#trace-attributes" data-toggle="tab">统计</a>
640
666
  </li>
667
+ <li class="disabled" id="tree-tab" style="display: none;">
668
+ <a href="#trace-tree" data-toggle="tab">进化树</a>
669
+ </li>
641
670
  </ul>
642
671
 
643
672
  <div class="tab-content" id="top_level_tab_content">
@@ -843,17 +872,29 @@
843
872
  <!-- 簇选择器 -->
844
873
  <div style="margin-bottom: 12px;">
845
874
  <div class="input-group input-group-sm" style="max-width: 280px;">
846
- <span class="input-group-addon" style="min-width: 60px; text-align: center;">选择簇</span>
847
- <div class="input-group-btn">
875
+ <span class="input-group-btn" style="width: auto;">
876
+ <button type="button" class="btn btn-default" id="attrs_cluster_prev_btn" title="上一个簇" disabled
877
+ style="border-top-right-radius: 0; border-bottom-right-radius: 0;">
878
+ <i class="fa fa-chevron-left"></i>
879
+ </button>
880
+ </span>
881
+ <div class="input-group-btn" style="width: auto;">
848
882
  <button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown"
849
- id="attrs_cluster_selector_btn" style="min-width: 140px; text-align: left;">
850
- <span id="attrs_cluster_selector_label">总览</span> <span class="caret"
883
+ id="attrs_cluster_selector_btn"
884
+ style="min-width: 120px; text-align: left; border-radius: 0; margin-left: -1px;">
885
+ <span id="attrs_cluster_selector_label">所有簇</span> <span class="caret"
851
886
  style="float: right; margin-top: 8px;"></span>
852
887
  </button>
853
888
  <ul class="dropdown-menu" role="menu" id="attrs_cluster_selector_menu">
854
- <li><a href="#" data-value="">总览</a></li>
889
+ <li><a href="#" data-value="">所有簇</a></li>
855
890
  </ul>
856
891
  </div>
892
+ <span class="input-group-btn" style="width: auto;">
893
+ <button type="button" class="btn btn-default" id="attrs_cluster_next_btn" title="下一个簇"
894
+ style="border-top-left-radius: 0; border-bottom-left-radius: 0; margin-left: -1px;">
895
+ <i class="fa fa-chevron-right"></i>
896
+ </button>
897
+ </span>
857
898
  </div>
858
899
  </div>
859
900
  <!-- 属性 Pills -->
@@ -906,6 +947,91 @@
906
947
  </div>
907
948
  </div>
908
949
  </div>
950
+
951
+ <!-- 进化树 Tab -->
952
+ <div id="trace-tree" class="tab-pane">
953
+ <div class="hivtrace-tree-container"
954
+ style="display: flex; flex-direction: column; height: 100%; padding: 12px;">
955
+ <!-- 进化树工具栏 -->
956
+ <div class="hivtrace-tree-toolbar"
957
+ style="display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap;">
958
+ <!-- 适应窗口 -->
959
+ <button type="button" class="btn btn-default btn-sm" id="tree_fit_btn" title="适应窗口">
960
+ <i class="fa fa-expand"></i> 适应窗口
961
+ </button>
962
+ <!-- 显示/隐藏标签 -->
963
+ <button type="button" class="btn btn-default btn-sm" id="tree_labels_btn" title="显示/隐藏标签">
964
+ <i class="fa fa-tag"></i> <span id="tree_labels_text">显示标签</span>
965
+ </button>
966
+ <!-- 簇选择器 -->
967
+ <div class="input-group input-group-sm" style="max-width: 320px;">
968
+ <span class="input-group-btn" style="width: auto;">
969
+ <button type="button" class="btn btn-default" id="tree_cluster_prev_btn" title="上一个簇" disabled
970
+ style="border-top-right-radius: 0; border-bottom-right-radius: 0;">
971
+ <i class="fa fa-chevron-left"></i>
972
+ </button>
973
+ </span>
974
+ <div class="input-group-btn" style="width: auto;">
975
+ <button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown"
976
+ id="tree_cluster_selector_btn"
977
+ style="min-width: 140px; text-align: left; border-radius: 0; margin-left: -1px;">
978
+ <span id="tree_cluster_selector_label">所有簇</span> <span class="caret"
979
+ style="float: right; margin-top: 8px;"></span>
980
+ </button>
981
+ <ul class="dropdown-menu" role="menu" id="tree_cluster_selector_menu">
982
+ <li><a href="#" data-value="">所有簇</a></li>
983
+ </ul>
984
+ </div>
985
+ <span class="input-group-btn" style="width: auto;">
986
+ <button type="button" class="btn btn-default" id="tree_cluster_next_btn" title="下一个簇"
987
+ style="border-top-left-radius: 0; border-bottom-left-radius: 0; margin-left: -1px;">
988
+ <i class="fa fa-chevron-right"></i>
989
+ </button>
990
+ </span>
991
+ </div>
992
+ <!-- 导出按钮 -->
993
+ <div class="input-group-btn">
994
+ <button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown"
995
+ id="tree_export_btn">
996
+ <i class="fa fa-download"></i> 导出 <span class="caret"></span>
997
+ </button>
998
+ <ul class="dropdown-menu" role="menu">
999
+ <li><a href="#" id="tree_export_png">PNG 图片</a></li>
1000
+ <li><a href="#" id="tree_export_json">JSON 数据</a></li>
1001
+ </ul>
1002
+ </div>
1003
+ <!-- 统计信息 -->
1004
+ <div style="flex: 1;"></div>
1005
+ <div class="hivtrace-tree-stats" style="display: flex; gap: 12px; font-size: 13px; color: #6b7280;">
1006
+ <span>节点: <strong id="tree_stat_nodes">0</strong></span>
1007
+ <span>叶节点: <strong id="tree_stat_leaves">0</strong></span>
1008
+ <span>连边: <strong id="tree_stat_edges">0</strong></span>
1009
+ </div>
1010
+ </div>
1011
+ <!-- 进化树可视化容器 -->
1012
+ <div id="tree_container"
1013
+ style="flex: 1; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; position: relative; min-height: 400px;">
1014
+ </div>
1015
+ <!-- 节点信息面板 -->
1016
+ <div id="tree_node_info" class="hivtrace-tree-node-info"
1017
+ style="display: none; position: absolute; bottom: 20px; right: 20px; width: 240px; background: rgba(255,255,255,0.95); border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 100;">
1018
+ <h4 style="margin: 0 0 8px; font-size: 14px; font-weight: 600;">节点信息</h4>
1019
+ <div style="font-size: 12px; color: #374151;">
1020
+ <div style="display: flex; justify-content: space-between; padding: 4px 0;"><span
1021
+ style="color: #6b7280;">ID:</span> <span id="tree_node_id"
1022
+ style="font-family: monospace;">-</span></div>
1023
+ <div style="display: flex; justify-content: space-between; padding: 4px 0;"><span
1024
+ style="color: #6b7280;">标签:</span> <span id="tree_node_label">-</span></div>
1025
+ <div style="display: flex; justify-content: space-between; padding: 4px 0;"><span
1026
+ style="color: #6b7280;">类型:</span> <span id="tree_node_type">-</span></div>
1027
+ <div style="display: flex; justify-content: space-between; padding: 4px 0;"><span
1028
+ style="color: #6b7280;">分支长度:</span> <span id="tree_node_branch">-</span></div>
1029
+ <div style="display: flex; justify-content: space-between; padding: 4px 0;"><span
1030
+ style="color: #6b7280;">连接数:</span> <span id="tree_node_degree">-</span></div>
1031
+ </div>
1032
+ </div>
1033
+ </div>
1034
+ </div>
909
1035
  </div>
910
1036
  </div>
911
1037
  </div>
@@ -917,6 +1043,109 @@
917
1043
 
918
1044
  <script src="./hivtrace.js"></script>
919
1045
  <!-- <script src="dist/hivtrace.es.js"></script> -->
1046
+
1047
+ <!-- Tree Visualization Configuration -->
1048
+ <script>
1049
+ // Cytoscape Stylesheet for Tree
1050
+ window.TREE_STYLESHEET = [
1051
+ { selector: 'node', style: { 'font-size': 11, 'text-valign': 'center', 'text-halign': 'right', 'text-margin-x': 5, 'color': '#333', 'min-zoomed-font-size': 8 } },
1052
+ { selector: 'node[?is_leaf]', style: { 'background-color': '#2563eb', 'width': 10, 'height': 10 } },
1053
+ { selector: 'node[!is_leaf]', style: { 'background-color': '#CBD5E1', 'width': 4, 'height': 4, 'opacity': 0.7, 'label': '', 'border-width': 0 } },
1054
+ { selector: 'node[!is_leaf][support]', style: { 'background-color': '#94A3B8', 'width': 5, 'height': 5, 'opacity': 0.8 } },
1055
+ { selector: 'edge', style: { 'width': 2, 'line-color': '#333', 'target-arrow-color': '#333', 'curve-style': 'bezier', 'opacity': 0.8 } },
1056
+ { selector: 'node:selected', style: { 'background-color': '#FFD700', 'border-width': 3, 'border-color': '#FF6B6B' } },
1057
+ { selector: 'edge:selected', style: { 'line-color': '#FF6B6B', 'width': 3, 'opacity': 1 } },
1058
+ { selector: 'node.grayed-out', style: { 'background-color': '#94A3B8', 'border-color': '#CBD5E1', 'opacity': 0.5 } },
1059
+ { selector: 'edge.grayed-out', style: { 'line-color': '#94A3B8', 'opacity': 0.4 } }
1060
+ ];
1061
+
1062
+ /**
1063
+ * 根据 cluster 标志格式化 cluster_id 的显示值
1064
+ * - cluster = 0: cluster_id 显示时 +1
1065
+ * - cluster = 1: cluster_id 直接显示原值
1066
+ */
1067
+ function formatClusterLabel(clusterId, clusterFlag) {
1068
+ if (clusterFlag === 0) {
1069
+ var numId = typeof clusterId === 'string' ? parseInt(clusterId, 10) : clusterId;
1070
+ return isNaN(numId) ? clusterId : numId + 1;
1071
+ }
1072
+ return clusterId;
1073
+ }
1074
+
1075
+ // 保存全局 clusterFlag 用于显示
1076
+ var treeClusterFlag = null;
1077
+
1078
+ // TreeViz Class
1079
+ (function (global) {
1080
+ function TreeViz(containerId, opts) {
1081
+ this.container = document.getElementById(containerId);
1082
+ this.cy = null;
1083
+ this.showLabels = false;
1084
+ this.onStatsChange = opts.onStatsChange || function () { };
1085
+ this.onNodeSelect = opts.onNodeSelect || function () { };
1086
+ }
1087
+ TreeViz.prototype.init = function (treeData) {
1088
+ if (!treeData || !treeData.nodes) return;
1089
+ if (this.cy) { this.cy.destroy(); this.cy = null; }
1090
+ var elements = treeData.nodes.map(function (n) {
1091
+ return { data: { id: n.data.id, label: n.data.label, is_leaf: n.data.is_leaf, branch_length: n.data.branch_length, support: n.data.support, cluster_id: n.data.cluster_id }, position: n.position };
1092
+ }).concat(treeData.edges.map(function (e) {
1093
+ return { data: { id: e.data.id, source: e.data.source, target: e.data.target, branch_length: e.data.branch_length } };
1094
+ }));
1095
+ this.cy = cytoscape({ container: this.container, elements: elements, style: window.TREE_STYLESHEET, layout: { name: 'preset', fit: true, padding: 50 }, wheelSensitivity: 0.2, minZoom: 0.01, maxZoom: 5, boxSelectionEnabled: false });
1096
+ this.onStatsChange({ nodeCount: this.cy.nodes().length, edgeCount: this.cy.edges().length, leafCount: this.cy.nodes('[?is_leaf]').length });
1097
+ var self = this;
1098
+ this.cy.on('tap', 'node', function (evt) {
1099
+ var d = evt.target.data();
1100
+ self.onNodeSelect({ id: d.id, label: d.label, isLeaf: d.is_leaf, branchLength: d.branch_length, support: d.support, degree: evt.target.degree() });
1101
+ });
1102
+ this.cy.on('tap', function (evt) { if (evt.target === self.cy) self.onNodeSelect(null); });
1103
+ };
1104
+ TreeViz.prototype.toggleLabels = function () {
1105
+ this.showLabels = !this.showLabels;
1106
+ var show = this.showLabels;
1107
+ this.cy && this.cy.nodes().forEach(function (n) { if (n.data('is_leaf')) n.style('label', show ? (n.data('label') || '') : ''); });
1108
+ return this.showLabels;
1109
+ };
1110
+ TreeViz.prototype.highlightCluster = function (clusterId) {
1111
+ if (!this.cy) return;
1112
+ if (clusterId != null) {
1113
+ var matchIds = new Set();
1114
+ this.cy.nodes().forEach(function (n) { if (n.data('cluster_id') != null && String(n.data('cluster_id')) === String(clusterId)) matchIds.add(n.id()); });
1115
+ this.cy.nodes().forEach(function (n) { n[matchIds.has(n.id()) ? 'removeClass' : 'addClass']('grayed-out'); });
1116
+ var edgesToHighlight = new Set();
1117
+ var matchingNodes = this.cy.nodes().filter(function (n) { return matchIds.has(n.id()); });
1118
+ if (matchingNodes.length > 1) {
1119
+ var dijk = this.cy.elements().dijkstra({ root: matchingNodes[0], directed: false });
1120
+ for (var i = 1; i < matchingNodes.length; i++) dijk.pathTo(matchingNodes[i]).edges().forEach(function (e) { edgesToHighlight.add(e.id()); });
1121
+ }
1122
+ this.cy.edges().forEach(function (e) { e[(matchIds.has(e.source().id()) || matchIds.has(e.target().id()) || edgesToHighlight.has(e.id())) ? 'removeClass' : 'addClass']('grayed-out'); });
1123
+ } else {
1124
+ this.cy.nodes().removeClass('grayed-out'); this.cy.edges().removeClass('grayed-out');
1125
+ }
1126
+ };
1127
+ TreeViz.prototype.fitView = function () { if (this.cy) { this.cy.resize(); this.cy.fit(undefined, 50); } };
1128
+ TreeViz.prototype.getAvailableClusters = function () {
1129
+ if (!this.cy) return [];
1130
+ var counts = new Map();
1131
+ this.cy.nodes().forEach(function (n) { var c = n.data('cluster_id'); if (c != null) counts.set(c, (counts.get(c) || 0) + 1); });
1132
+ var arr = []; counts.forEach(function (cnt, id) { arr.push({ id: id, nodeCount: cnt }); });
1133
+ arr.sort(function (a, b) { return typeof a.id === 'number' && typeof b.id === 'number' ? a.id - b.id : String(a.id).localeCompare(String(b.id)); });
1134
+ return arr;
1135
+ };
1136
+ TreeViz.prototype.exportPNG = function (fname) {
1137
+ if (!this.cy) return;
1138
+ var png = this.cy.png({ output: 'blob', bg: '#fff', scale: 2, full: true });
1139
+ var a = document.createElement('a'); a.href = URL.createObjectURL(png); a.download = (fname || 'tree') + '.png'; a.click(); URL.revokeObjectURL(a.href);
1140
+ };
1141
+ TreeViz.prototype.exportJSON = function (data, fname) {
1142
+ if (!data) return;
1143
+ var blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
1144
+ var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = (fname || 'tree') + '.json'; a.click(); URL.revokeObjectURL(a.href);
1145
+ };
1146
+ global.TreeViz = TreeViz;
1147
+ })(window);
1148
+ </script>
920
1149
  <script>
921
1150
  // 动态计算标签页高度
922
1151
  (function () {
@@ -936,6 +1165,12 @@
936
1165
  statsTab.style.height = availableHeight + 'px';
937
1166
  }
938
1167
 
1168
+ // 设置进化树标签页高度
1169
+ var treeTab = document.getElementById('trace-tree');
1170
+ if (treeTab) {
1171
+ treeTab.style.height = availableHeight + 'px';
1172
+ }
1173
+
939
1174
  // 设置网络标签页内 network_tag 高度
940
1175
  var networkTag = document.querySelector('#trace-results #network_tag');
941
1176
  if (networkTag) {
@@ -1018,9 +1253,177 @@
1018
1253
  parent_container = "#top_level_tab_container",
1019
1254
  node_table = "#node_table",
1020
1255
  user_graph,
1256
+ tree_viz = null, // 进化树可视化实例
1257
+ tree_data = null, // 进化树数据
1021
1258
  countryCenersObject = null,
1022
1259
  countryOutlines = null;
1023
1260
 
1261
+ // 进化树初始化函数
1262
+ function initTreeViz(treeData, options) {
1263
+ if (!treeData || !treeData.nodes || treeData.nodes.length === 0) {
1264
+ return;
1265
+ }
1266
+
1267
+ // 显示进化树 Tab
1268
+ var treeTab = document.getElementById('tree-tab');
1269
+ if (treeTab) {
1270
+ treeTab.style.display = '';
1271
+ treeTab.classList.remove('disabled');
1272
+ }
1273
+
1274
+ // 保存树数据
1275
+ window._treeData = treeData;
1276
+
1277
+ // 保存 cluster 标志用于显示格式化
1278
+ treeClusterFlag = treeData.cluster;
1279
+
1280
+ // 初始化 TreeViz 实例
1281
+ tree_viz = new TreeViz('tree_container', {
1282
+ onStatsChange: function (stats) {
1283
+ document.getElementById('tree_stat_nodes').textContent = stats.nodeCount;
1284
+ document.getElementById('tree_stat_leaves').textContent = stats.leafCount;
1285
+ document.getElementById('tree_stat_edges').textContent = stats.edgeCount;
1286
+ },
1287
+ onNodeSelect: function (node) {
1288
+ var infoPanel = document.getElementById('tree_node_info');
1289
+ if (node) {
1290
+ document.getElementById('tree_node_id').textContent = node.id;
1291
+ document.getElementById('tree_node_label').textContent = node.label || '-';
1292
+ document.getElementById('tree_node_type').textContent = node.isLeaf ? '叶节点' : '内部节点';
1293
+ document.getElementById('tree_node_branch').textContent = node.branchLength ? node.branchLength.toFixed(6) : '-';
1294
+ document.getElementById('tree_node_degree').textContent = node.degree;
1295
+ infoPanel.style.display = 'block';
1296
+ } else {
1297
+ infoPanel.style.display = 'none';
1298
+ }
1299
+ }
1300
+ });
1301
+
1302
+ tree_viz.init(treeData);
1303
+
1304
+ // 获取可用簇
1305
+ var clusters = tree_viz.getAvailableClusters();
1306
+ var currentClusterIndex = -1; // -1 表示 "所有簇"
1307
+
1308
+ // 填充簇选择器菜单 - 先清空旧的簇选项(保留"所有簇")
1309
+ var menu = document.getElementById('tree_cluster_selector_menu');
1310
+ var label = document.getElementById('tree_cluster_selector_label');
1311
+ if (menu) {
1312
+ // 移除除了"所有簇"之外的所有选项
1313
+ var items = menu.querySelectorAll('li');
1314
+ for (var i = items.length - 1; i > 0; i--) {
1315
+ items[i].remove();
1316
+ }
1317
+ // 重置选择器标签
1318
+ if (label) label.textContent = '所有簇';
1319
+ // 使用 DocumentFragment 批量添加新簇选项(减少重排)
1320
+ if (clusters.length > 0) {
1321
+ var fragment = document.createDocumentFragment();
1322
+ clusters.forEach(function (cluster, index) {
1323
+ var li = document.createElement('li');
1324
+ var a = document.createElement('a');
1325
+ a.href = '#';
1326
+ a.setAttribute('data-value', cluster.id);
1327
+ a.setAttribute('data-index', index);
1328
+ a.textContent = '簇 ' + formatClusterLabel(cluster.id, treeClusterFlag) + ' (' + cluster.nodeCount + ' 节点)';
1329
+ li.appendChild(a);
1330
+ fragment.appendChild(li);
1331
+ });
1332
+ menu.appendChild(fragment);
1333
+ }
1334
+ }
1335
+
1336
+ // 更新导航按钮状态
1337
+ function updateTreeNavButtons() {
1338
+ var prevBtn = document.getElementById('tree_cluster_prev_btn');
1339
+ var nextBtn = document.getElementById('tree_cluster_next_btn');
1340
+ if (prevBtn) prevBtn.disabled = currentClusterIndex < 0;
1341
+ if (nextBtn) nextBtn.disabled = currentClusterIndex >= clusters.length - 1;
1342
+ }
1343
+
1344
+ // 选择簇
1345
+ function selectTreeCluster(index) {
1346
+ currentClusterIndex = index;
1347
+ var label = document.getElementById('tree_cluster_selector_label');
1348
+
1349
+ if (index < 0) {
1350
+ if (label) label.textContent = '所有簇';
1351
+ tree_viz.highlightCluster(null);
1352
+ } else {
1353
+ var cluster = clusters[index];
1354
+ if (label) label.textContent = '簇 ' + formatClusterLabel(cluster.id, treeClusterFlag) + ' (' + cluster.nodeCount + ' 节点)';
1355
+ tree_viz.highlightCluster(cluster.id);
1356
+ }
1357
+ updateTreeNavButtons();
1358
+ }
1359
+
1360
+ // 默认高亮簇
1361
+ if (options && options.treeDefaultClusterId != null) {
1362
+ var defaultIndex = clusters.findIndex(function (c) {
1363
+ return String(c.id) === String(options.treeDefaultClusterId);
1364
+ });
1365
+ if (defaultIndex >= 0) {
1366
+ selectTreeCluster(defaultIndex);
1367
+ }
1368
+ }
1369
+
1370
+ updateTreeNavButtons();
1371
+
1372
+ // 适应窗口按钮
1373
+ document.getElementById('tree_fit_btn').addEventListener('click', function () {
1374
+ tree_viz.fitView();
1375
+ });
1376
+
1377
+ // 标签按钮
1378
+ document.getElementById('tree_labels_btn').addEventListener('click', function () {
1379
+ var showing = tree_viz.toggleLabels();
1380
+ document.getElementById('tree_labels_text').textContent = showing ? '隐藏标签' : '显示标签';
1381
+ });
1382
+
1383
+ // 上一个簇
1384
+ document.getElementById('tree_cluster_prev_btn').addEventListener('click', function () {
1385
+ if (currentClusterIndex >= 0) {
1386
+ selectTreeCluster(currentClusterIndex - 1);
1387
+ }
1388
+ });
1389
+
1390
+ // 下一个簇
1391
+ document.getElementById('tree_cluster_next_btn').addEventListener('click', function () {
1392
+ if (currentClusterIndex < clusters.length - 1) {
1393
+ selectTreeCluster(currentClusterIndex + 1);
1394
+ }
1395
+ });
1396
+
1397
+ // 簇选择器下拉菜单点击
1398
+ document.getElementById('tree_cluster_selector_menu').addEventListener('click', function (e) {
1399
+ e.preventDefault();
1400
+ var link = e.target.closest('a[data-value]');
1401
+ if (!link) return;
1402
+
1403
+ var value = link.getAttribute('data-value');
1404
+ if (value === '') {
1405
+ selectTreeCluster(-1);
1406
+ } else {
1407
+ var index = parseInt(link.getAttribute('data-index'), 10);
1408
+ if (!isNaN(index)) {
1409
+ selectTreeCluster(index);
1410
+ }
1411
+ }
1412
+ });
1413
+
1414
+ // 导出 PNG
1415
+ document.getElementById('tree_export_png').addEventListener('click', function (e) {
1416
+ e.preventDefault();
1417
+ tree_viz.exportPNG(options && options.exportId ? options.exportId + '_tree' : 'tree');
1418
+ });
1419
+
1420
+ // 导出 JSON
1421
+ document.getElementById('tree_export_json').addEventListener('click', function (e) {
1422
+ e.preventDefault();
1423
+ tree_viz.exportJSON(treeData, options && options.exportId ? options.exportId + '_tree' : 'tree');
1424
+ });
1425
+ }
1426
+
1024
1427
  function HandleAppError(err_string) {
1025
1428
  showLoadingError(err_string);
1026
1429
  }
@@ -1090,6 +1493,49 @@
1090
1493
  };
1091
1494
  }
1092
1495
 
1496
+ // 如果有进化树数据,添加内置的"在进化树中查看"菜单项
1497
+ if (options && options.treeData && options.treeData.nodes && options.treeData.nodes.length > 0) {
1498
+ // 添加到自定义菜单项列表
1499
+ if (!user_graph.customContextMenuItems) {
1500
+ user_graph.customContextMenuItems = [];
1501
+ }
1502
+ user_graph.customContextMenuItems.push({
1503
+ id: '__view_in_tree__',
1504
+ label: '在进化树中查看'
1505
+ });
1506
+
1507
+ // 包装原有回调
1508
+ var originalCallback = user_graph.onCustomMenuItemClick;
1509
+ user_graph.onCustomMenuItemClick = function (itemId, clusterInfo) {
1510
+ if (itemId === '__view_in_tree__') {
1511
+ // 切换到进化树 Tab
1512
+ var treeTabLink = document.querySelector('#tree-tab a[data-toggle="tab"]');
1513
+ if (treeTabLink) {
1514
+ $(treeTabLink).tab('show');
1515
+ // 高亮对应的簇
1516
+ setTimeout(function () {
1517
+ if (tree_viz) {
1518
+ tree_viz.highlightCluster(clusterInfo.cluster_id);
1519
+ // 更新簇选择器显示
1520
+ var clusters = tree_viz.getAvailableClusters();
1521
+ var index = clusters.findIndex(function (c) {
1522
+ return String(c.id) === String(clusterInfo.cluster_id);
1523
+ });
1524
+ if (index >= 0) {
1525
+ var label = document.getElementById('tree_cluster_selector_label');
1526
+ var cluster = clusters[index];
1527
+ if (label) label.textContent = '簇 ' + formatClusterLabel(cluster.id, treeClusterFlag) + ' (' + cluster.nodeCount + ' 节点)';
1528
+ }
1529
+ tree_viz.fitView();
1530
+ }
1531
+ }, 200);
1532
+ }
1533
+ } else if (originalCallback) {
1534
+ originalCallback(itemId, clusterInfo);
1535
+ }
1536
+ };
1537
+ }
1538
+
1093
1539
  if (user_graph.is_empty()) {
1094
1540
  HandleAppError(
1095
1541
  "This network contains no clusters and cannot be displayed"
@@ -1124,48 +1570,96 @@
1124
1570
  // 设置属性页的簇过滤状态
1125
1571
  user_graph.attributes_selected_cluster = null;
1126
1572
 
1573
+ // 当前选中的簇索引(-1 表示 "所有簇")
1574
+ var currentClusterIndex = -1;
1575
+
1576
+ // 获取导航按钮
1577
+ var prevBtn = document.getElementById('attrs_cluster_prev_btn');
1578
+ var nextBtn = document.getElementById('attrs_cluster_next_btn');
1579
+
1580
+ // 更新导航按钮状态
1581
+ function updateNavigationButtons() {
1582
+ if (prevBtn) {
1583
+ prevBtn.disabled = (currentClusterIndex === -1);
1584
+ }
1585
+ if (nextBtn) {
1586
+ nextBtn.disabled = (currentClusterIndex === clusters.length - 1);
1587
+ }
1588
+ }
1589
+
1590
+ // 选择簇的公共函数
1591
+ function selectCluster(index) {
1592
+ currentClusterIndex = index;
1593
+ var statsTitle = document.getElementById('attrs_stats_title');
1594
+
1595
+ if (index === -1) {
1596
+ // 显示所有簇
1597
+ label.textContent = '所有簇';
1598
+ user_graph.attributes_selected_cluster = null;
1599
+ if (statsTitle) statsTitle.textContent = '网络统计';
1600
+ hivtrace.graphSummary(user_graph, graph_summary_tag);
1601
+ hivtrace.histogramDistances(graph, histogram_tag, histogram_label);
1602
+ updatePillBadges(null, clusters);
1603
+ } else {
1604
+ // 显示单个 cluster
1605
+ var cluster = clusters[index];
1606
+ label.textContent = '簇 ' + cluster.cluster_id + ' (' + cluster.children.length + ' 节点)';
1607
+ user_graph.attributes_selected_cluster = String(cluster.cluster_id);
1608
+ if (statsTitle) statsTitle.textContent = '簇 ' + cluster.cluster_id + ' 统计';
1609
+ updateClusterStatistics(cluster);
1610
+ updatePillBadges(String(cluster.cluster_id), clusters);
1611
+ }
1612
+
1613
+ // 重新触发当前选中的属性(如果有)
1614
+ if (user_graph.colorizer && user_graph.colorizer['category_id']) {
1615
+ if (user_graph.colorizer['continuous']) {
1616
+ user_graph.handle_attribute_continuous(user_graph.colorizer['category_id']);
1617
+ } else {
1618
+ user_graph.handle_attribute_categorical(user_graph.colorizer['category_id'], false);
1619
+ }
1620
+ }
1621
+
1622
+ updateNavigationButtons();
1623
+ }
1624
+
1625
+ // 上一个簇按钮点击事件
1626
+ if (prevBtn) {
1627
+ prevBtn.addEventListener('click', function () {
1628
+ if (currentClusterIndex > -1) {
1629
+ selectCluster(currentClusterIndex - 1);
1630
+ }
1631
+ });
1632
+ }
1633
+
1634
+ // 下一个簇按钮点击事件
1635
+ if (nextBtn) {
1636
+ nextBtn.addEventListener('click', function () {
1637
+ if (currentClusterIndex < clusters.length - 1) {
1638
+ selectCluster(currentClusterIndex + 1);
1639
+ }
1640
+ });
1641
+ }
1642
+
1643
+ // 初始化按钮状态
1644
+ updateNavigationButtons();
1645
+
1127
1646
  // 监听 dropdown 菜单项点击
1128
1647
  menu.addEventListener('click', function (e) {
1129
1648
  if (e.target.tagName === 'A') {
1130
1649
  e.preventDefault();
1131
1650
  var selectedClusterId = e.target.getAttribute('data-value');
1132
- var selectedText = e.target.textContent;
1133
- var statsTitle = document.getElementById('attrs_stats_title');
1134
-
1135
- // 更新按钮文本
1136
- label.textContent = selectedText;
1137
-
1138
- // 设置属性页的簇过滤
1139
- user_graph.attributes_selected_cluster = selectedClusterId || null;
1140
1651
 
1652
+ // 更新 currentClusterIndex
1141
1653
  if (!selectedClusterId) {
1142
- // 显示全网统计
1143
- if (statsTitle) statsTitle.textContent = '网络统计';
1144
- hivtrace.graphSummary(user_graph, graph_summary_tag);
1145
- hivtrace.histogramDistances(graph, histogram_tag, histogram_label);
1654
+ currentClusterIndex = -1;
1146
1655
  } else {
1147
- // 显示单个 cluster 的统计
1148
- var cluster = clusters.find(function (c) {
1656
+ currentClusterIndex = clusters.findIndex(function (c) {
1149
1657
  return String(c.cluster_id) === String(selectedClusterId);
1150
1658
  });
1151
-
1152
- if (cluster) {
1153
- if (statsTitle) statsTitle.textContent = '簇 ' + selectedClusterId + ' 统计';
1154
- updateClusterStatistics(cluster);
1155
- }
1156
1659
  }
1157
1660
 
1158
- // 重新触发当前选中的属性(如果有)
1159
- if (user_graph.colorizer && user_graph.colorizer['category_id']) {
1160
- if (user_graph.colorizer['continuous']) {
1161
- user_graph.handle_attribute_continuous(user_graph.colorizer['category_id']);
1162
- } else {
1163
- user_graph.handle_attribute_categorical(user_graph.colorizer['category_id'], false);
1164
- }
1165
- }
1166
-
1167
- // 更新 pill badges(唯一值数量)
1168
- updatePillBadges(selectedClusterId, clusters);
1661
+ // 使用公共函数选择簇
1662
+ selectCluster(currentClusterIndex);
1169
1663
  }
1170
1664
  });
1171
1665
 
@@ -1449,6 +1943,19 @@
1449
1943
  }
1450
1944
  );
1451
1945
 
1946
+ // 进化树 Tab 切换时自动适应窗口
1947
+ $("#tree-tab a[data-toggle='tab']").on(
1948
+ "shown.bs.tab",
1949
+ function (e) {
1950
+ if (tree_viz) {
1951
+ // 延迟一点确保容器已完全渲染
1952
+ setTimeout(function () {
1953
+ tree_viz.fitView();
1954
+ }, 100);
1955
+ }
1956
+ }
1957
+ );
1958
+
1452
1959
  }
1453
1960
  document
1454
1961
  .getElementById("min_cluster_size_input")
@@ -1506,6 +2013,51 @@
1506
2013
  }
1507
2014
  }
1508
2015
 
2016
+ // 如果有进化树数据,确保"在进化树中查看"菜单项存在
2017
+ if (options && options.treeData && options.treeData.nodes && options.treeData.nodes.length > 0) {
2018
+ if (!user_graph.customContextMenuItems) {
2019
+ user_graph.customContextMenuItems = [];
2020
+ }
2021
+ // 检查是否已存在
2022
+ var exists = user_graph.customContextMenuItems.some(function (item) {
2023
+ return item.id === '__view_in_tree__';
2024
+ });
2025
+ if (!exists) {
2026
+ user_graph.customContextMenuItems.push({
2027
+ id: '__view_in_tree__',
2028
+ label: '在进化树中查看'
2029
+ });
2030
+ }
2031
+
2032
+ // 包装原有回调
2033
+ var originalCallback = user_graph.onCustomMenuItemClick;
2034
+ user_graph.onCustomMenuItemClick = function (itemId, clusterInfo) {
2035
+ if (itemId === '__view_in_tree__') {
2036
+ var treeTabLink = document.querySelector('#tree-tab a[data-toggle="tab"]');
2037
+ if (treeTabLink) {
2038
+ $(treeTabLink).tab('show');
2039
+ setTimeout(function () {
2040
+ if (tree_viz) {
2041
+ tree_viz.highlightCluster(clusterInfo.cluster_id);
2042
+ var clusters = tree_viz.getAvailableClusters();
2043
+ var index = clusters.findIndex(function (c) {
2044
+ return String(c.id) === String(clusterInfo.cluster_id);
2045
+ });
2046
+ if (index >= 0) {
2047
+ var label = document.getElementById('tree_cluster_selector_label');
2048
+ var cluster = clusters[index];
2049
+ if (label) label.textContent = '簇 ' + formatClusterLabel(cluster.id, treeClusterFlag) + ' (' + cluster.nodeCount + ' 节点)';
2050
+ }
2051
+ tree_viz.fitView();
2052
+ }
2053
+ }, 200);
2054
+ }
2055
+ } else if (originalCallback) {
2056
+ originalCallback(itemId, clusterInfo);
2057
+ }
2058
+ };
2059
+ }
2060
+
1509
2061
  // 执行必要的更新
1510
2062
  user_graph.update(false, 0.4);
1511
2063
 
@@ -1632,6 +2184,13 @@
1632
2184
  }
1633
2185
 
1634
2186
  this.isFullscreen = isFullscreen;
2187
+
2188
+ // 全屏状态变化后,更新树可视化尺寸
2189
+ if (tree_viz) {
2190
+ setTimeout(function () {
2191
+ tree_viz.fitView();
2192
+ }, 100);
2193
+ }
1635
2194
  },
1636
2195
 
1637
2196
  init: function () {
@@ -1676,8 +2235,16 @@
1676
2235
 
1677
2236
  if (data.type === 'HIVTRACE_DATA') {
1678
2237
  init(data.graphData, data.options);
2238
+ // 如果提供了进化树数据,初始化进化树
2239
+ if (data.options && data.options.treeData) {
2240
+ initTreeViz(data.options.treeData, data.options);
2241
+ }
1679
2242
  } else if (data.type === 'HIVTRACE_UPDATE_DATA') {
1680
2243
  updateNetworkData(data.graphData, data.options);
2244
+ // 更新进化树数据(如果提供)
2245
+ if (data.options && data.options.treeData) {
2246
+ initTreeViz(data.options.treeData, data.options);
2247
+ }
1681
2248
  } else if (data.type === 'HIVTRACE_ERROR') {
1682
2249
  // 处理来自宿主应用的错误(如 API 请求失败)
1683
2250
  showLoadingError(data.message || '数据加载失败');