@basic-genomics/hivtrace-viz 1.2.6 → 1.4.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,101 @@
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
+ <style>
959
+ .hivtrace-tree-toolbar>* {
960
+ flex-shrink: 0;
961
+ }
962
+ </style>
963
+ <!-- 适应窗口 -->
964
+ <button type="button" class="btn btn-default btn-sm" id="tree_fit_btn" title="适应窗口">
965
+ <i class="fa fa-expand"></i> 适应窗口
966
+ </button>
967
+ <!-- 显示/隐藏标签 -->
968
+ <button type="button" class="btn btn-default btn-sm" id="tree_labels_btn" title="显示/隐藏标签">
969
+ <i class="fa fa-tag"></i> <span id="tree_labels_text">显示标签</span>
970
+ </button>
971
+ <!-- 簇选择器 -->
972
+ <div class="input-group input-group-sm" style="max-width: 320px;">
973
+ <span class="input-group-btn" style="width: auto;">
974
+ <button type="button" class="btn btn-default" id="tree_cluster_prev_btn" title="上一个簇" disabled
975
+ style="border-top-right-radius: 0; border-bottom-right-radius: 0;">
976
+ <i class="fa fa-chevron-left"></i>
977
+ </button>
978
+ </span>
979
+ <div class="input-group-btn" style="width: auto;">
980
+ <button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown"
981
+ id="tree_cluster_selector_btn"
982
+ style="min-width: 140px; text-align: left; border-radius: 0; margin-left: -1px;">
983
+ <span id="tree_cluster_selector_label">所有簇</span> <span class="caret"
984
+ style="float: right; margin-top: 8px;"></span>
985
+ </button>
986
+ <ul class="dropdown-menu" role="menu" id="tree_cluster_selector_menu">
987
+ <li><a href="#" data-value="">所有簇</a></li>
988
+ </ul>
989
+ </div>
990
+ <span class="input-group-btn" style="width: auto;">
991
+ <button type="button" class="btn btn-default" id="tree_cluster_next_btn" title="下一个簇"
992
+ style="border-top-left-radius: 0; border-bottom-left-radius: 0; margin-left: -1px;">
993
+ <i class="fa fa-chevron-right"></i>
994
+ </button>
995
+ </span>
996
+ </div>
997
+ <!-- 导出按钮 -->
998
+ <div class="dropdown" style="display: inline-block; position: relative;">
999
+ <button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown"
1000
+ id="tree_export_btn">
1001
+ <i class="fa fa-download"></i> 导出 <span class="caret"></span>
1002
+ </button>
1003
+ <ul class="dropdown-menu" role="menu">
1004
+ <li><a href="#" id="tree_export_png">PNG 图片</a></li>
1005
+ <li><a href="#" id="tree_export_json">JSON 数据</a></li>
1006
+ </ul>
1007
+ </div>
1008
+ <!-- 动画播放按钮 -->
1009
+ <button type="button" class="btn btn-default btn-sm" id="tree_animate_btn" title="播放从根到叶子的动画"
1010
+ style="margin-left: 8px;">
1011
+ <i class="fa fa-play"></i> 播放动画
1012
+ </button>
1013
+ <!-- 统计信息 -->
1014
+ <div style="flex: 1;"></div>
1015
+ <div class="hivtrace-tree-stats" style="display: flex; gap: 12px; font-size: 13px; color: #6b7280;">
1016
+ <span>节点: <strong id="tree_stat_nodes">0</strong></span>
1017
+ <span>叶节点: <strong id="tree_stat_leaves">0</strong></span>
1018
+ <span>连边: <strong id="tree_stat_edges">0</strong></span>
1019
+ </div>
1020
+ </div>
1021
+ <!-- 进化树可视化容器 -->
1022
+ <div id="tree_container"
1023
+ style="flex: 1; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; position: relative; min-height: 400px;">
1024
+ </div>
1025
+ <!-- 节点信息面板 -->
1026
+ <div id="tree_node_info" class="hivtrace-tree-node-info"
1027
+ 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;">
1028
+ <h4 style="margin: 0 0 8px; font-size: 14px; font-weight: 600;">节点信息</h4>
1029
+ <div style="font-size: 12px; color: #374151;">
1030
+ <div style="display: flex; justify-content: space-between; padding: 4px 0;"><span
1031
+ style="color: #6b7280;">ID:</span> <span id="tree_node_id"
1032
+ style="font-family: monospace;">-</span></div>
1033
+ <div style="display: flex; justify-content: space-between; padding: 4px 0;"><span
1034
+ style="color: #6b7280;">标签:</span> <span id="tree_node_label">-</span></div>
1035
+ <div style="display: flex; justify-content: space-between; padding: 4px 0;"><span
1036
+ style="color: #6b7280;">类型:</span> <span id="tree_node_type">-</span></div>
1037
+ <div style="display: flex; justify-content: space-between; padding: 4px 0;"><span
1038
+ style="color: #6b7280;">分支长度:</span> <span id="tree_node_branch">-</span></div>
1039
+ <div style="display: flex; justify-content: space-between; padding: 4px 0;"><span
1040
+ style="color: #6b7280;">连接数:</span> <span id="tree_node_degree">-</span></div>
1041
+ </div>
1042
+ </div>
1043
+ </div>
1044
+ </div>
909
1045
  </div>
910
1046
  </div>
911
1047
  </div>
@@ -917,6 +1053,192 @@
917
1053
 
918
1054
  <script src="./hivtrace.js"></script>
919
1055
  <!-- <script src="dist/hivtrace.es.js"></script> -->
1056
+
1057
+ <!-- Tree Visualization Configuration -->
1058
+ <script>
1059
+ // Cytoscape Stylesheet for Tree
1060
+ window.TREE_STYLESHEET = [
1061
+ // 基础节点样式 - 添加 transition 支持平滑动画(官方最佳实践)
1062
+ {
1063
+ selector: 'node', style: {
1064
+ 'font-size': 11, 'text-valign': 'center', 'text-halign': 'right', 'text-margin-x': 5, 'color': '#333', 'min-zoomed-font-size': 8,
1065
+ 'transition-property': 'background-color, border-color, border-width, width, height, opacity',
1066
+ 'transition-duration': '0.25s',
1067
+ 'transition-timing-function': 'ease-out'
1068
+ }
1069
+ },
1070
+ { selector: 'node[?is_leaf]', style: { 'background-color': '#2563eb', 'width': 10, 'height': 10 } },
1071
+ { selector: 'node[!is_leaf]', style: { 'background-color': '#CBD5E1', 'width': 4, 'height': 4, 'opacity': 0.7, 'label': '', 'border-width': 0 } },
1072
+ { selector: 'node[!is_leaf][support]', style: { 'background-color': '#94A3B8', 'width': 5, 'height': 5, 'opacity': 0.8 } },
1073
+ // 根节点样式 - 青绿色,与蓝色和谐但有区分
1074
+ {
1075
+ selector: 'node.tree-root', style: {
1076
+ 'background-color': '#0d9488', // 青绿色 teal-600
1077
+ 'shape': 'ellipse',
1078
+ 'width': 12,
1079
+ 'height': 12,
1080
+ 'opacity': 1,
1081
+ 'border-width': 2,
1082
+ 'border-color': '#0f766e' // 更深的青绿边框
1083
+ }
1084
+ },
1085
+ // 基础边样式 - 添加 transition 支持
1086
+ {
1087
+ selector: 'edge', style: {
1088
+ 'width': 2, 'line-color': '#333', 'target-arrow-color': '#333', 'curve-style': 'bezier', 'opacity': 0.8,
1089
+ 'transition-property': 'line-color, width, opacity',
1090
+ 'transition-duration': '0.25s',
1091
+ 'transition-timing-function': 'ease-out'
1092
+ }
1093
+ },
1094
+ { selector: 'node:selected', style: { 'background-color': '#FFD700', 'border-width': 3, 'border-color': '#FF6B6B' } },
1095
+ { selector: 'edge:selected', style: { 'line-color': '#FF6B6B', 'width': 3, 'opacity': 1 } },
1096
+ { selector: 'node.grayed-out', style: { 'background-color': '#94A3B8', 'border-color': '#CBD5E1', 'opacity': 0.5 } },
1097
+ { selector: 'edge.grayed-out', style: { 'line-color': '#94A3B8', 'opacity': 0.4 } }
1098
+ ];
1099
+
1100
+ /**
1101
+ * 根据 cluster 标志格式化 cluster_id 的显示值
1102
+ * - cluster = 0: cluster_id 显示时 +1
1103
+ * - cluster = 1: cluster_id 直接显示原值
1104
+ */
1105
+ function formatClusterLabel(clusterId, clusterFlag) {
1106
+ if (clusterFlag === 0) {
1107
+ var numId = typeof clusterId === 'string' ? parseInt(clusterId, 10) : clusterId;
1108
+ return isNaN(numId) ? clusterId : numId + 1;
1109
+ }
1110
+ return clusterId;
1111
+ }
1112
+
1113
+ // 保存全局 clusterFlag 用于显示
1114
+ var treeClusterFlag = null;
1115
+
1116
+ // TreeViz Class
1117
+ (function (global) {
1118
+ function TreeViz(containerId, opts) {
1119
+ this.container = document.getElementById(containerId);
1120
+ this.cy = null;
1121
+ this.showLabels = false;
1122
+ this.onStatsChange = opts.onStatsChange || function () { };
1123
+ this.onNodeSelect = opts.onNodeSelect || function () { };
1124
+ }
1125
+ TreeViz.prototype.init = function (treeData) {
1126
+ if (!treeData || !treeData.nodes) return;
1127
+ if (this.cy) { this.cy.destroy(); this.cy = null; }
1128
+ var elements = treeData.nodes.map(function (n) {
1129
+ 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 };
1130
+ }).concat(treeData.edges.map(function (e) {
1131
+ return { data: { id: e.data.id, source: e.data.source, target: e.data.target, branch_length: e.data.branch_length } };
1132
+ }));
1133
+ 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 });
1134
+ this.onStatsChange({ nodeCount: this.cy.nodes().length, edgeCount: this.cy.edges().length, leafCount: this.cy.nodes('[?is_leaf]').length });
1135
+ var self = this;
1136
+ this.cy.on('tap', 'node', function (evt) {
1137
+ var d = evt.target.data();
1138
+ self.onNodeSelect({ id: d.id, label: d.label, isLeaf: d.is_leaf, branchLength: d.branch_length, support: d.support, degree: evt.target.degree() });
1139
+ });
1140
+ this.cy.on('tap', function (evt) { if (evt.target === self.cy) self.onNodeSelect(null); });
1141
+ };
1142
+ TreeViz.prototype.toggleLabels = function () {
1143
+ this.showLabels = !this.showLabels;
1144
+ var show = this.showLabels;
1145
+ this.cy && this.cy.nodes().forEach(function (n) { if (n.data('is_leaf')) n.style('label', show ? (n.data('label') || '') : ''); });
1146
+ return this.showLabels;
1147
+ };
1148
+ TreeViz.prototype.highlightCluster = function (clusterId) {
1149
+ if (!this.cy) return;
1150
+ if (clusterId != null) {
1151
+ var matchIds = new Set();
1152
+ this.cy.nodes().forEach(function (n) { if (n.data('cluster_id') != null && String(n.data('cluster_id')) === String(clusterId)) matchIds.add(n.id()); });
1153
+ this.cy.nodes().forEach(function (n) { n[matchIds.has(n.id()) ? 'removeClass' : 'addClass']('grayed-out'); });
1154
+ var edgesToHighlight = new Set();
1155
+ var matchingNodes = this.cy.nodes().filter(function (n) { return matchIds.has(n.id()); });
1156
+ if (matchingNodes.length > 1) {
1157
+ var dijk = this.cy.elements().dijkstra({ root: matchingNodes[0], directed: false });
1158
+ for (var i = 1; i < matchingNodes.length; i++) dijk.pathTo(matchingNodes[i]).edges().forEach(function (e) { edgesToHighlight.add(e.id()); });
1159
+ }
1160
+ 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'); });
1161
+ } else {
1162
+ this.cy.nodes().removeClass('grayed-out'); this.cy.edges().removeClass('grayed-out');
1163
+ }
1164
+ };
1165
+ TreeViz.prototype.fitView = function () { if (this.cy) { this.cy.resize(); this.cy.fit(undefined, 50); } };
1166
+ TreeViz.prototype.getAvailableClusters = function () {
1167
+ if (!this.cy) return [];
1168
+ var counts = new Map();
1169
+ this.cy.nodes().forEach(function (n) { var c = n.data('cluster_id'); if (c != null) counts.set(c, (counts.get(c) || 0) + 1); });
1170
+ var arr = []; counts.forEach(function (cnt, id) { arr.push({ id: id, nodeCount: cnt }); });
1171
+ 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)); });
1172
+ return arr;
1173
+ };
1174
+ TreeViz.prototype.exportPNG = function (fname) {
1175
+ if (!this.cy) return;
1176
+ var png = this.cy.png({ output: 'blob', bg: '#fff', scale: 2, full: true });
1177
+ var a = document.createElement('a'); a.href = URL.createObjectURL(png); a.download = (fname || 'tree') + '.png'; a.click(); URL.revokeObjectURL(a.href);
1178
+ };
1179
+ TreeViz.prototype.exportJSON = function (data, fname) {
1180
+ if (!data) return;
1181
+ var blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
1182
+ var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = (fname || 'tree') + '.json'; a.click(); URL.revokeObjectURL(a.href);
1183
+ };
1184
+ // 查找根节点(入度为 0 的节点)
1185
+ TreeViz.prototype.findRoot = function () {
1186
+ if (!this.cy) return null;
1187
+ var roots = this.cy.nodes().filter(function (n) { return n.indegree(false) === 0; });
1188
+ return roots.length > 0 ? roots[0] : null;
1189
+ };
1190
+ // 标记根节点
1191
+ TreeViz.prototype.markRoot = function () {
1192
+ if (!this.cy) return null;
1193
+ this.cy.nodes().removeClass('tree-root');
1194
+ var root = this.findRoot();
1195
+ if (root) {
1196
+ root.addClass('tree-root');
1197
+ return root.data();
1198
+ }
1199
+ return null;
1200
+ };
1201
+ // 从根到叶子的渐显动画(先全部置灰,然后逐层恢复原色)
1202
+ TreeViz.prototype.animateFromRoot = function (options) {
1203
+ var self = this;
1204
+ if (!this.cy) return;
1205
+ var opts = options || {};
1206
+ var delay = opts.delay || 120; // 每层延迟(毫秒)
1207
+
1208
+ var root = this.findRoot();
1209
+ if (!root) return;
1210
+
1211
+ // 第一步:全部置灰
1212
+ this.cy.elements().addClass('grayed-out');
1213
+
1214
+ // 使用 Cytoscape.js 官方 BFS API 按层分组
1215
+ var layers = [];
1216
+ this.cy.elements().bfs({
1217
+ roots: root,
1218
+ visit: function (v, e, u, i, depth) {
1219
+ if (!layers[depth]) layers[depth] = { nodes: [], edges: [] };
1220
+ layers[depth].nodes.push(v);
1221
+ // 将连接边归入当前层
1222
+ if (e) {
1223
+ layers[depth].edges.push(e);
1224
+ }
1225
+ },
1226
+ directed: true
1227
+ });
1228
+
1229
+ // 第二步:分层逐渐恢复原色(移除 grayed-out 类)
1230
+ layers.forEach(function (layer, i) {
1231
+ setTimeout(function () {
1232
+ layer.nodes.forEach(function (n) { n.removeClass('grayed-out'); });
1233
+ layer.edges.forEach(function (e) { e.removeClass('grayed-out'); });
1234
+ }, i * delay);
1235
+ });
1236
+
1237
+ return { layerCount: layers.length, totalNodes: this.cy.nodes().length };
1238
+ };
1239
+ global.TreeViz = TreeViz;
1240
+ })(window);
1241
+ </script>
920
1242
  <script>
921
1243
  // 动态计算标签页高度
922
1244
  (function () {
@@ -936,6 +1258,12 @@
936
1258
  statsTab.style.height = availableHeight + 'px';
937
1259
  }
938
1260
 
1261
+ // 设置进化树标签页高度
1262
+ var treeTab = document.getElementById('trace-tree');
1263
+ if (treeTab) {
1264
+ treeTab.style.height = availableHeight + 'px';
1265
+ }
1266
+
939
1267
  // 设置网络标签页内 network_tag 高度
940
1268
  var networkTag = document.querySelector('#trace-results #network_tag');
941
1269
  if (networkTag) {
@@ -1018,9 +1346,185 @@
1018
1346
  parent_container = "#top_level_tab_container",
1019
1347
  node_table = "#node_table",
1020
1348
  user_graph,
1349
+ tree_viz = null, // 进化树可视化实例
1350
+ tree_data = null, // 进化树数据
1021
1351
  countryCenersObject = null,
1022
1352
  countryOutlines = null;
1023
1353
 
1354
+ // 进化树初始化函数
1355
+ function initTreeViz(treeData, options) {
1356
+ if (!treeData || !treeData.nodes || treeData.nodes.length === 0) {
1357
+ return;
1358
+ }
1359
+
1360
+ // 显示进化树 Tab
1361
+ var treeTab = document.getElementById('tree-tab');
1362
+ if (treeTab) {
1363
+ treeTab.style.display = '';
1364
+ treeTab.classList.remove('disabled');
1365
+ }
1366
+
1367
+ // 保存树数据
1368
+ window._treeData = treeData;
1369
+
1370
+ // 保存 cluster 标志用于显示格式化
1371
+ treeClusterFlag = treeData.cluster;
1372
+
1373
+ // 初始化 TreeViz 实例
1374
+ tree_viz = new TreeViz('tree_container', {
1375
+ onStatsChange: function (stats) {
1376
+ document.getElementById('tree_stat_nodes').textContent = stats.nodeCount;
1377
+ document.getElementById('tree_stat_leaves').textContent = stats.leafCount;
1378
+ document.getElementById('tree_stat_edges').textContent = stats.edgeCount;
1379
+ },
1380
+ onNodeSelect: function (node) {
1381
+ var infoPanel = document.getElementById('tree_node_info');
1382
+ if (node) {
1383
+ document.getElementById('tree_node_id').textContent = node.id;
1384
+ document.getElementById('tree_node_label').textContent = node.label || '-';
1385
+ document.getElementById('tree_node_type').textContent = node.isLeaf ? '叶节点' : '内部节点';
1386
+ document.getElementById('tree_node_branch').textContent = node.branchLength ? node.branchLength.toFixed(6) : '-';
1387
+ document.getElementById('tree_node_degree').textContent = node.degree;
1388
+ infoPanel.style.display = 'block';
1389
+ } else {
1390
+ infoPanel.style.display = 'none';
1391
+ }
1392
+ }
1393
+ });
1394
+
1395
+ tree_viz.init(treeData);
1396
+ // 自动标记根节点(红色菱形)
1397
+ tree_viz.markRoot();
1398
+
1399
+ // 获取可用簇
1400
+ var clusters = tree_viz.getAvailableClusters();
1401
+ var currentClusterIndex = -1; // -1 表示 "所有簇"
1402
+
1403
+ // 填充簇选择器菜单 - 先清空旧的簇选项(保留"所有簇")
1404
+ var menu = document.getElementById('tree_cluster_selector_menu');
1405
+ var label = document.getElementById('tree_cluster_selector_label');
1406
+ if (menu) {
1407
+ // 移除除了"所有簇"之外的所有选项
1408
+ var items = menu.querySelectorAll('li');
1409
+ for (var i = items.length - 1; i > 0; i--) {
1410
+ items[i].remove();
1411
+ }
1412
+ // 重置选择器标签
1413
+ if (label) label.textContent = '所有簇';
1414
+ // 使用 DocumentFragment 批量添加新簇选项(减少重排)
1415
+ if (clusters.length > 0) {
1416
+ var fragment = document.createDocumentFragment();
1417
+ clusters.forEach(function (cluster, index) {
1418
+ var li = document.createElement('li');
1419
+ var a = document.createElement('a');
1420
+ a.href = '#';
1421
+ a.setAttribute('data-value', cluster.id);
1422
+ a.setAttribute('data-index', index);
1423
+ a.textContent = '簇 ' + formatClusterLabel(cluster.id, treeClusterFlag) + ' (' + cluster.nodeCount + ' 节点)';
1424
+ li.appendChild(a);
1425
+ fragment.appendChild(li);
1426
+ });
1427
+ menu.appendChild(fragment);
1428
+ }
1429
+ }
1430
+
1431
+ // 更新导航按钮状态
1432
+ function updateTreeNavButtons() {
1433
+ var prevBtn = document.getElementById('tree_cluster_prev_btn');
1434
+ var nextBtn = document.getElementById('tree_cluster_next_btn');
1435
+ if (prevBtn) prevBtn.disabled = currentClusterIndex < 0;
1436
+ if (nextBtn) nextBtn.disabled = currentClusterIndex >= clusters.length - 1;
1437
+ }
1438
+
1439
+ // 选择簇
1440
+ function selectTreeCluster(index) {
1441
+ currentClusterIndex = index;
1442
+ var label = document.getElementById('tree_cluster_selector_label');
1443
+
1444
+ if (index < 0) {
1445
+ if (label) label.textContent = '所有簇';
1446
+ tree_viz.highlightCluster(null);
1447
+ } else {
1448
+ var cluster = clusters[index];
1449
+ if (label) label.textContent = '簇 ' + formatClusterLabel(cluster.id, treeClusterFlag) + ' (' + cluster.nodeCount + ' 节点)';
1450
+ tree_viz.highlightCluster(cluster.id);
1451
+ }
1452
+ updateTreeNavButtons();
1453
+ }
1454
+
1455
+ // 默认高亮簇
1456
+ if (options && options.treeDefaultClusterId != null) {
1457
+ var defaultIndex = clusters.findIndex(function (c) {
1458
+ return String(c.id) === String(options.treeDefaultClusterId);
1459
+ });
1460
+ if (defaultIndex >= 0) {
1461
+ selectTreeCluster(defaultIndex);
1462
+ }
1463
+ }
1464
+
1465
+ updateTreeNavButtons();
1466
+
1467
+ // 适应窗口按钮
1468
+ document.getElementById('tree_fit_btn').addEventListener('click', function () {
1469
+ tree_viz.fitView();
1470
+ });
1471
+
1472
+ // 标签按钮
1473
+ document.getElementById('tree_labels_btn').addEventListener('click', function () {
1474
+ var showing = tree_viz.toggleLabels();
1475
+ document.getElementById('tree_labels_text').textContent = showing ? '隐藏标签' : '显示标签';
1476
+ });
1477
+
1478
+ // 上一个簇
1479
+ document.getElementById('tree_cluster_prev_btn').addEventListener('click', function () {
1480
+ if (currentClusterIndex >= 0) {
1481
+ selectTreeCluster(currentClusterIndex - 1);
1482
+ }
1483
+ });
1484
+
1485
+ // 下一个簇
1486
+ document.getElementById('tree_cluster_next_btn').addEventListener('click', function () {
1487
+ if (currentClusterIndex < clusters.length - 1) {
1488
+ selectTreeCluster(currentClusterIndex + 1);
1489
+ }
1490
+ });
1491
+
1492
+ // 簇选择器下拉菜单点击
1493
+ document.getElementById('tree_cluster_selector_menu').addEventListener('click', function (e) {
1494
+ e.preventDefault();
1495
+ var link = e.target.closest('a[data-value]');
1496
+ if (!link) return;
1497
+
1498
+ var value = link.getAttribute('data-value');
1499
+ if (value === '') {
1500
+ selectTreeCluster(-1);
1501
+ } else {
1502
+ var index = parseInt(link.getAttribute('data-index'), 10);
1503
+ if (!isNaN(index)) {
1504
+ selectTreeCluster(index);
1505
+ }
1506
+ }
1507
+ });
1508
+
1509
+ // 导出 PNG
1510
+ document.getElementById('tree_export_png').addEventListener('click', function (e) {
1511
+ e.preventDefault();
1512
+ tree_viz.exportPNG(options && options.exportId ? options.exportId + '_tree' : 'tree');
1513
+ });
1514
+
1515
+ // 导出 JSON
1516
+ document.getElementById('tree_export_json').addEventListener('click', function (e) {
1517
+ e.preventDefault();
1518
+ tree_viz.exportJSON(treeData, options && options.exportId ? options.exportId + '_tree' : 'tree');
1519
+ });
1520
+
1521
+ // 播放动画按钮
1522
+ document.getElementById('tree_animate_btn').addEventListener('click', function (e) {
1523
+ e.preventDefault();
1524
+ tree_viz.animateFromRoot({ delay: 100 });
1525
+ });
1526
+ }
1527
+
1024
1528
  function HandleAppError(err_string) {
1025
1529
  showLoadingError(err_string);
1026
1530
  }
@@ -1090,6 +1594,49 @@
1090
1594
  };
1091
1595
  }
1092
1596
 
1597
+ // 如果有进化树数据,添加内置的"在进化树中查看"菜单项
1598
+ if (options && options.treeData && options.treeData.nodes && options.treeData.nodes.length > 0) {
1599
+ // 添加到自定义菜单项列表
1600
+ if (!user_graph.customContextMenuItems) {
1601
+ user_graph.customContextMenuItems = [];
1602
+ }
1603
+ user_graph.customContextMenuItems.push({
1604
+ id: '__view_in_tree__',
1605
+ label: '在进化树中查看'
1606
+ });
1607
+
1608
+ // 包装原有回调
1609
+ var originalCallback = user_graph.onCustomMenuItemClick;
1610
+ user_graph.onCustomMenuItemClick = function (itemId, clusterInfo) {
1611
+ if (itemId === '__view_in_tree__') {
1612
+ // 切换到进化树 Tab
1613
+ var treeTabLink = document.querySelector('#tree-tab a[data-toggle="tab"]');
1614
+ if (treeTabLink) {
1615
+ $(treeTabLink).tab('show');
1616
+ // 高亮对应的簇
1617
+ setTimeout(function () {
1618
+ if (tree_viz) {
1619
+ tree_viz.highlightCluster(clusterInfo.cluster_id);
1620
+ // 更新簇选择器显示
1621
+ var clusters = tree_viz.getAvailableClusters();
1622
+ var index = clusters.findIndex(function (c) {
1623
+ return String(c.id) === String(clusterInfo.cluster_id);
1624
+ });
1625
+ if (index >= 0) {
1626
+ var label = document.getElementById('tree_cluster_selector_label');
1627
+ var cluster = clusters[index];
1628
+ if (label) label.textContent = '簇 ' + formatClusterLabel(cluster.id, treeClusterFlag) + ' (' + cluster.nodeCount + ' 节点)';
1629
+ }
1630
+ tree_viz.fitView();
1631
+ }
1632
+ }, 200);
1633
+ }
1634
+ } else if (originalCallback) {
1635
+ originalCallback(itemId, clusterInfo);
1636
+ }
1637
+ };
1638
+ }
1639
+
1093
1640
  if (user_graph.is_empty()) {
1094
1641
  HandleAppError(
1095
1642
  "This network contains no clusters and cannot be displayed"
@@ -1124,48 +1671,96 @@
1124
1671
  // 设置属性页的簇过滤状态
1125
1672
  user_graph.attributes_selected_cluster = null;
1126
1673
 
1674
+ // 当前选中的簇索引(-1 表示 "所有簇")
1675
+ var currentClusterIndex = -1;
1676
+
1677
+ // 获取导航按钮
1678
+ var prevBtn = document.getElementById('attrs_cluster_prev_btn');
1679
+ var nextBtn = document.getElementById('attrs_cluster_next_btn');
1680
+
1681
+ // 更新导航按钮状态
1682
+ function updateNavigationButtons() {
1683
+ if (prevBtn) {
1684
+ prevBtn.disabled = (currentClusterIndex === -1);
1685
+ }
1686
+ if (nextBtn) {
1687
+ nextBtn.disabled = (currentClusterIndex === clusters.length - 1);
1688
+ }
1689
+ }
1690
+
1691
+ // 选择簇的公共函数
1692
+ function selectCluster(index) {
1693
+ currentClusterIndex = index;
1694
+ var statsTitle = document.getElementById('attrs_stats_title');
1695
+
1696
+ if (index === -1) {
1697
+ // 显示所有簇
1698
+ label.textContent = '所有簇';
1699
+ user_graph.attributes_selected_cluster = null;
1700
+ if (statsTitle) statsTitle.textContent = '网络统计';
1701
+ hivtrace.graphSummary(user_graph, graph_summary_tag);
1702
+ hivtrace.histogramDistances(graph, histogram_tag, histogram_label);
1703
+ updatePillBadges(null, clusters);
1704
+ } else {
1705
+ // 显示单个 cluster
1706
+ var cluster = clusters[index];
1707
+ label.textContent = '簇 ' + cluster.cluster_id + ' (' + cluster.children.length + ' 节点)';
1708
+ user_graph.attributes_selected_cluster = String(cluster.cluster_id);
1709
+ if (statsTitle) statsTitle.textContent = '簇 ' + cluster.cluster_id + ' 统计';
1710
+ updateClusterStatistics(cluster);
1711
+ updatePillBadges(String(cluster.cluster_id), clusters);
1712
+ }
1713
+
1714
+ // 重新触发当前选中的属性(如果有)
1715
+ if (user_graph.colorizer && user_graph.colorizer['category_id']) {
1716
+ if (user_graph.colorizer['continuous']) {
1717
+ user_graph.handle_attribute_continuous(user_graph.colorizer['category_id']);
1718
+ } else {
1719
+ user_graph.handle_attribute_categorical(user_graph.colorizer['category_id'], false);
1720
+ }
1721
+ }
1722
+
1723
+ updateNavigationButtons();
1724
+ }
1725
+
1726
+ // 上一个簇按钮点击事件
1727
+ if (prevBtn) {
1728
+ prevBtn.addEventListener('click', function () {
1729
+ if (currentClusterIndex > -1) {
1730
+ selectCluster(currentClusterIndex - 1);
1731
+ }
1732
+ });
1733
+ }
1734
+
1735
+ // 下一个簇按钮点击事件
1736
+ if (nextBtn) {
1737
+ nextBtn.addEventListener('click', function () {
1738
+ if (currentClusterIndex < clusters.length - 1) {
1739
+ selectCluster(currentClusterIndex + 1);
1740
+ }
1741
+ });
1742
+ }
1743
+
1744
+ // 初始化按钮状态
1745
+ updateNavigationButtons();
1746
+
1127
1747
  // 监听 dropdown 菜单项点击
1128
1748
  menu.addEventListener('click', function (e) {
1129
1749
  if (e.target.tagName === 'A') {
1130
1750
  e.preventDefault();
1131
1751
  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
1752
 
1753
+ // 更新 currentClusterIndex
1141
1754
  if (!selectedClusterId) {
1142
- // 显示全网统计
1143
- if (statsTitle) statsTitle.textContent = '网络统计';
1144
- hivtrace.graphSummary(user_graph, graph_summary_tag);
1145
- hivtrace.histogramDistances(graph, histogram_tag, histogram_label);
1755
+ currentClusterIndex = -1;
1146
1756
  } else {
1147
- // 显示单个 cluster 的统计
1148
- var cluster = clusters.find(function (c) {
1757
+ currentClusterIndex = clusters.findIndex(function (c) {
1149
1758
  return String(c.cluster_id) === String(selectedClusterId);
1150
1759
  });
1151
-
1152
- if (cluster) {
1153
- if (statsTitle) statsTitle.textContent = '簇 ' + selectedClusterId + ' 统计';
1154
- updateClusterStatistics(cluster);
1155
- }
1156
- }
1157
-
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
1760
  }
1166
1761
 
1167
- // 更新 pill badges(唯一值数量)
1168
- updatePillBadges(selectedClusterId, clusters);
1762
+ // 使用公共函数选择簇
1763
+ selectCluster(currentClusterIndex);
1169
1764
  }
1170
1765
  });
1171
1766
 
@@ -1449,6 +2044,19 @@
1449
2044
  }
1450
2045
  );
1451
2046
 
2047
+ // 进化树 Tab 切换时自动适应窗口
2048
+ $("#tree-tab a[data-toggle='tab']").on(
2049
+ "shown.bs.tab",
2050
+ function (e) {
2051
+ if (tree_viz) {
2052
+ // 延迟一点确保容器已完全渲染
2053
+ setTimeout(function () {
2054
+ tree_viz.fitView();
2055
+ }, 100);
2056
+ }
2057
+ }
2058
+ );
2059
+
1452
2060
  }
1453
2061
  document
1454
2062
  .getElementById("min_cluster_size_input")
@@ -1506,6 +2114,51 @@
1506
2114
  }
1507
2115
  }
1508
2116
 
2117
+ // 如果有进化树数据,确保"在进化树中查看"菜单项存在
2118
+ if (options && options.treeData && options.treeData.nodes && options.treeData.nodes.length > 0) {
2119
+ if (!user_graph.customContextMenuItems) {
2120
+ user_graph.customContextMenuItems = [];
2121
+ }
2122
+ // 检查是否已存在
2123
+ var exists = user_graph.customContextMenuItems.some(function (item) {
2124
+ return item.id === '__view_in_tree__';
2125
+ });
2126
+ if (!exists) {
2127
+ user_graph.customContextMenuItems.push({
2128
+ id: '__view_in_tree__',
2129
+ label: '在进化树中查看'
2130
+ });
2131
+ }
2132
+
2133
+ // 包装原有回调
2134
+ var originalCallback = user_graph.onCustomMenuItemClick;
2135
+ user_graph.onCustomMenuItemClick = function (itemId, clusterInfo) {
2136
+ if (itemId === '__view_in_tree__') {
2137
+ var treeTabLink = document.querySelector('#tree-tab a[data-toggle="tab"]');
2138
+ if (treeTabLink) {
2139
+ $(treeTabLink).tab('show');
2140
+ setTimeout(function () {
2141
+ if (tree_viz) {
2142
+ tree_viz.highlightCluster(clusterInfo.cluster_id);
2143
+ var clusters = tree_viz.getAvailableClusters();
2144
+ var index = clusters.findIndex(function (c) {
2145
+ return String(c.id) === String(clusterInfo.cluster_id);
2146
+ });
2147
+ if (index >= 0) {
2148
+ var label = document.getElementById('tree_cluster_selector_label');
2149
+ var cluster = clusters[index];
2150
+ if (label) label.textContent = '簇 ' + formatClusterLabel(cluster.id, treeClusterFlag) + ' (' + cluster.nodeCount + ' 节点)';
2151
+ }
2152
+ tree_viz.fitView();
2153
+ }
2154
+ }, 200);
2155
+ }
2156
+ } else if (originalCallback) {
2157
+ originalCallback(itemId, clusterInfo);
2158
+ }
2159
+ };
2160
+ }
2161
+
1509
2162
  // 执行必要的更新
1510
2163
  user_graph.update(false, 0.4);
1511
2164
 
@@ -1632,6 +2285,13 @@
1632
2285
  }
1633
2286
 
1634
2287
  this.isFullscreen = isFullscreen;
2288
+
2289
+ // 全屏状态变化后,更新树可视化尺寸
2290
+ if (tree_viz) {
2291
+ setTimeout(function () {
2292
+ tree_viz.fitView();
2293
+ }, 100);
2294
+ }
1635
2295
  },
1636
2296
 
1637
2297
  init: function () {
@@ -1676,8 +2336,16 @@
1676
2336
 
1677
2337
  if (data.type === 'HIVTRACE_DATA') {
1678
2338
  init(data.graphData, data.options);
2339
+ // 如果提供了进化树数据,初始化进化树
2340
+ if (data.options && data.options.treeData) {
2341
+ initTreeViz(data.options.treeData, data.options);
2342
+ }
1679
2343
  } else if (data.type === 'HIVTRACE_UPDATE_DATA') {
1680
2344
  updateNetworkData(data.graphData, data.options);
2345
+ // 更新进化树数据(如果提供)
2346
+ if (data.options && data.options.treeData) {
2347
+ initTreeViz(data.options.treeData, data.options);
2348
+ }
1681
2349
  } else if (data.type === 'HIVTRACE_ERROR') {
1682
2350
  // 处理来自宿主应用的错误(如 API 请求失败)
1683
2351
  showLoadingError(data.message || '数据加载失败');