@basic-genomics/hivtrace-viz 1.3.0 → 1.4.1

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.
Files changed (2) hide show
  1. package/dist/embed/index.html +210 -16
  2. package/package.json +1 -1
@@ -955,6 +955,11 @@
955
955
  <!-- 进化树工具栏 -->
956
956
  <div class="hivtrace-tree-toolbar"
957
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>
958
963
  <!-- 适应窗口 -->
959
964
  <button type="button" class="btn btn-default btn-sm" id="tree_fit_btn" title="适应窗口">
960
965
  <i class="fa fa-expand"></i> 适应窗口
@@ -990,7 +995,7 @@
990
995
  </span>
991
996
  </div>
992
997
  <!-- 导出按钮 -->
993
- <div class="input-group-btn">
998
+ <div class="dropdown" style="display: inline-block; position: relative;">
994
999
  <button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown"
995
1000
  id="tree_export_btn">
996
1001
  <i class="fa fa-download"></i> 导出 <span class="caret"></span>
@@ -1000,6 +1005,11 @@
1000
1005
  <li><a href="#" id="tree_export_json">JSON 数据</a></li>
1001
1006
  </ul>
1002
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>
1003
1013
  <!-- 统计信息 -->
1004
1014
  <div style="flex: 1;"></div>
1005
1015
  <div class="hivtrace-tree-stats" style="display: flex; gap: 12px; font-size: 13px; color: #6b7280;">
@@ -1048,11 +1058,39 @@
1048
1058
  <script>
1049
1059
  // Cytoscape Stylesheet for Tree
1050
1060
  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 } },
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
+ },
1052
1070
  { selector: 'node[?is_leaf]', style: { 'background-color': '#2563eb', 'width': 10, 'height': 10 } },
1053
1071
  { selector: 'node[!is_leaf]', style: { 'background-color': '#CBD5E1', 'width': 4, 'height': 4, 'opacity': 0.7, 'label': '', 'border-width': 0 } },
1054
1072
  { 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 } },
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
+ },
1056
1094
  { selector: 'node:selected', style: { 'background-color': '#FFD700', 'border-width': 3, 'border-color': '#FF6B6B' } },
1057
1095
  { selector: 'edge:selected', style: { 'line-color': '#FF6B6B', 'width': 3, 'opacity': 1 } },
1058
1096
  { selector: 'node.grayed-out', style: { 'background-color': '#94A3B8', 'border-color': '#CBD5E1', 'opacity': 0.5 } },
@@ -1081,6 +1119,8 @@
1081
1119
  this.container = document.getElementById(containerId);
1082
1120
  this.cy = null;
1083
1121
  this.showLabels = false;
1122
+ this.currentClusterId = null; // 当前选中的簇 ID(用于动画后恢复状态)
1123
+ this.animationTimeouts = []; // 存储所有活动的动画定时器 ID
1084
1124
  this.onStatsChange = opts.onStatsChange || function () { };
1085
1125
  this.onNodeSelect = opts.onNodeSelect || function () { };
1086
1126
  }
@@ -1109,20 +1149,37 @@
1109
1149
  };
1110
1150
  TreeViz.prototype.highlightCluster = function (clusterId) {
1111
1151
  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()); });
1152
+ var self = this;
1153
+
1154
+ // 停止任何正在进行的动画,防止状态冲突
1155
+ this.stopAnimation();
1156
+
1157
+ this.currentClusterId = clusterId;
1158
+
1159
+ this.cy.batch(function () {
1160
+ if (clusterId != null) {
1161
+ var strClusterId = String(clusterId);
1162
+ // 一次性过滤匹配节点
1163
+ var matchingNodes = self.cy.nodes().filter(function (n) {
1164
+ return n.data('cluster_id') != null && String(n.data('cluster_id')) === strClusterId;
1165
+ });
1166
+
1167
+ // 使用集合操作批量增删类名
1168
+ self.cy.elements().addClass('grayed-out');
1169
+ matchingNodes.removeClass('grayed-out');
1170
+
1171
+ if (matchingNodes.length > 1) {
1172
+ var dijk = self.cy.elements().dijkstra({ root: matchingNodes[0], directed: false });
1173
+ // 使用集合 union 快速合并路径元素
1174
+ var pathEles = matchingNodes.slice(1).reduce(function (acc, node) {
1175
+ return acc.union(dijk.pathTo(node));
1176
+ }, self.cy.collection());
1177
+ pathEles.removeClass('grayed-out');
1178
+ }
1179
+ } else {
1180
+ self.cy.elements().removeClass('grayed-out');
1121
1181
  }
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
- }
1182
+ });
1126
1183
  };
1127
1184
  TreeViz.prototype.fitView = function () { if (this.cy) { this.cy.resize(); this.cy.fit(undefined, 50); } };
1128
1185
  TreeViz.prototype.getAvailableClusters = function () {
@@ -1143,6 +1200,126 @@
1143
1200
  var blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
1144
1201
  var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = (fname || 'tree') + '.json'; a.click(); URL.revokeObjectURL(a.href);
1145
1202
  };
1203
+ // 查找根节点(入度为 0 的节点)
1204
+ TreeViz.prototype.findRoot = function () {
1205
+ if (!this.cy) return null;
1206
+ var roots = this.cy.nodes().filter(function (n) { return n.indegree(false) === 0; });
1207
+ return roots.length > 0 ? roots[0] : null;
1208
+ };
1209
+ // 标记根节点
1210
+ TreeViz.prototype.markRoot = function () {
1211
+ if (!this.cy) return null;
1212
+ this.cy.nodes().removeClass('tree-root');
1213
+ var root = this.findRoot();
1214
+ if (root) {
1215
+ root.addClass('tree-root');
1216
+ return root.data();
1217
+ }
1218
+ return null;
1219
+ };
1220
+ // 查找簇成员的最近共同祖先(MRCA)
1221
+ // 使用官方 predecessors() + intersection() API
1222
+ TreeViz.prototype.findClusterRoot = function (clusterId) {
1223
+ if (!this.cy || clusterId == null) return this.findRoot();
1224
+
1225
+ // 获取该簇的所有成员节点
1226
+ var clusterNodes = this.cy.nodes().filter(function (n) {
1227
+ return n.data('cluster_id') != null && String(n.data('cluster_id')) === String(clusterId);
1228
+ });
1229
+
1230
+ if (clusterNodes.length === 0) return this.findRoot();
1231
+ if (clusterNodes.length === 1) return clusterNodes[0];
1232
+
1233
+ // 计算所有成员的共同祖先交集
1234
+ // 包括节点自身,以处理一个节点是另一个节点祖先的情况
1235
+ var commonAncestors = clusterNodes[0].predecessors().nodes().union(clusterNodes[0]);
1236
+ for (var i = 1; i < clusterNodes.length; i++) {
1237
+ commonAncestors = commonAncestors.intersection(clusterNodes[i].predecessors().nodes().union(clusterNodes[i]));
1238
+ }
1239
+
1240
+ // 如果没有共同祖先,返回全局根
1241
+ if (commonAncestors.length === 0) return this.findRoot();
1242
+
1243
+ // 在树结构中,MRCA 是共同祖先集合中唯一一个没有后代也在该集合中的节点
1244
+ var mrca = commonAncestors.filter(function (n) {
1245
+ return n.successors().nodes().intersection(commonAncestors).length === 0;
1246
+ });
1247
+
1248
+ return mrca.length > 0 ? mrca[0] : commonAncestors[0];
1249
+ };
1250
+ // 停止并清理当前正在执行的所有动画定时器
1251
+ TreeViz.prototype.stopAnimation = function () {
1252
+ if (this.animationTimeouts && this.animationTimeouts.length > 0) {
1253
+ this.animationTimeouts.forEach(function (tid) { clearTimeout(tid); });
1254
+ this.animationTimeouts = [];
1255
+ }
1256
+ };
1257
+ // 从根到叶子的渐显动画(支持簇感知:从簇的 MRCA 开始)
1258
+ TreeViz.prototype.animateFromRoot = function (options) {
1259
+ var self = this;
1260
+ if (!this.cy) return;
1261
+
1262
+ // 启动新动画前,先清理旧动画
1263
+ this.stopAnimation();
1264
+
1265
+ var opts = options || {};
1266
+ var delay = opts.delay || 120; // 每层延迟(毫秒)
1267
+
1268
+ // 如果当前选中了某个簇,从该簇的 MRCA 开始动画
1269
+ var animRoot = this.currentClusterId != null
1270
+ ? this.findClusterRoot(this.currentClusterId)
1271
+ : this.findRoot();
1272
+ if (!animRoot) return;
1273
+
1274
+ // 第一步:全部置灰(使用 batch 确保立即生效)
1275
+ this.cy.batch(function () {
1276
+ self.cy.elements().addClass('grayed-out');
1277
+ });
1278
+
1279
+ // 使用 Cytoscape.js 官方 BFS API 按层分组
1280
+ var layers = [];
1281
+ this.cy.elements().bfs({
1282
+ roots: animRoot,
1283
+ visit: function (v, e, u, i, depth) {
1284
+ if (!layers[depth]) layers[depth] = { nodes: [], edges: [] };
1285
+ layers[depth].nodes.push(v);
1286
+ if (e) layers[depth].edges.push(e);
1287
+ },
1288
+ directed: true
1289
+ });
1290
+
1291
+ // 第二步:分层逐渐恢复原色
1292
+ layers.forEach(function (layer, i) {
1293
+ var tid = setTimeout(function () {
1294
+ self.cy.batch(function () {
1295
+ layer.nodes.forEach(function (n) { n.removeClass('grayed-out'); });
1296
+ layer.edges.forEach(function (e) { e.removeClass('grayed-out'); });
1297
+ });
1298
+ }, i * delay);
1299
+ self.animationTimeouts.push(tid);
1300
+ });
1301
+
1302
+ // 第三步:动画结束后恢复状态
1303
+ var totalDelay = layers.length * delay;
1304
+ var savedClusterId = this.currentClusterId;
1305
+ var finalTid = setTimeout(function () {
1306
+ if (savedClusterId != null) {
1307
+ // 恢复簇高亮状态(highlightCluster 内部自带 batch)
1308
+ self.highlightCluster(savedClusterId);
1309
+ } else {
1310
+ // 全局视图:确保移除所有 grayed-out 类
1311
+ self.cy.batch(function () {
1312
+ self.cy.elements().removeClass('grayed-out');
1313
+ });
1314
+ }
1315
+ // 动画执行完毕,清空列表
1316
+ self.animationTimeouts = [];
1317
+ }, totalDelay + 150);
1318
+
1319
+ self.animationTimeouts.push(finalTid);
1320
+
1321
+ return { layerCount: layers.length, totalNodes: this.cy.nodes().length, animRoot: animRoot.id() };
1322
+ };
1146
1323
  global.TreeViz = TreeViz;
1147
1324
  })(window);
1148
1325
  </script>
@@ -1184,6 +1361,15 @@
1184
1361
  var containerRect = container.getBoundingClientRect();
1185
1362
  container.style.height = (window.innerHeight - containerRect.top) + 'px';
1186
1363
  });
1364
+
1365
+ // 核心优化:确保进化树在容器大小变化或切换可见性时正确调整画布大小
1366
+ if (typeof tree_viz !== 'undefined' && tree_viz && tree_viz.cy) {
1367
+ // 仅当 tree-tab 是 active 状态时才执行 fitView
1368
+ var treePane = document.getElementById('trace-tree');
1369
+ if (treePane && treePane.classList.contains('active')) {
1370
+ tree_viz.fitView();
1371
+ }
1372
+ }
1187
1373
  }
1188
1374
 
1189
1375
  // 防抖函数:避免频繁调用
@@ -1300,6 +1486,8 @@
1300
1486
  });
1301
1487
 
1302
1488
  tree_viz.init(treeData);
1489
+ // 自动标记根节点(红色菱形)
1490
+ tree_viz.markRoot();
1303
1491
 
1304
1492
  // 获取可用簇
1305
1493
  var clusters = tree_viz.getAvailableClusters();
@@ -1422,6 +1610,12 @@
1422
1610
  e.preventDefault();
1423
1611
  tree_viz.exportJSON(treeData, options && options.exportId ? options.exportId + '_tree' : 'tree');
1424
1612
  });
1613
+
1614
+ // 播放动画按钮
1615
+ document.getElementById('tree_animate_btn').addEventListener('click', function (e) {
1616
+ e.preventDefault();
1617
+ tree_viz.animateFromRoot({ delay: 100 });
1618
+ });
1425
1619
  }
1426
1620
 
1427
1621
  function HandleAppError(err_string) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basic-genomics/hivtrace-viz",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "description": "HIV-TRACE molecular transmission network visualization with React integration",
5
5
  "engines": {
6
6
  "node": ">=18"