@basic-genomics/hivtrace-viz 1.4.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 +121 -28
  2. package/package.json +1 -1
@@ -1119,6 +1119,8 @@
1119
1119
  this.container = document.getElementById(containerId);
1120
1120
  this.cy = null;
1121
1121
  this.showLabels = false;
1122
+ this.currentClusterId = null; // 当前选中的簇 ID(用于动画后恢复状态)
1123
+ this.animationTimeouts = []; // 存储所有活动的动画定时器 ID
1122
1124
  this.onStatsChange = opts.onStatsChange || function () { };
1123
1125
  this.onNodeSelect = opts.onNodeSelect || function () { };
1124
1126
  }
@@ -1147,20 +1149,37 @@
1147
1149
  };
1148
1150
  TreeViz.prototype.highlightCluster = function (clusterId) {
1149
1151
  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()); });
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');
1159
1181
  }
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
- }
1182
+ });
1164
1183
  };
1165
1184
  TreeViz.prototype.fitView = function () { if (this.cy) { this.cy.resize(); this.cy.fit(undefined, 50); } };
1166
1185
  TreeViz.prototype.getAvailableClusters = function () {
@@ -1198,43 +1217,108 @@
1198
1217
  }
1199
1218
  return null;
1200
1219
  };
1201
- // 从根到叶子的渐显动画(先全部置灰,然后逐层恢复原色)
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 开始)
1202
1258
  TreeViz.prototype.animateFromRoot = function (options) {
1203
1259
  var self = this;
1204
1260
  if (!this.cy) return;
1261
+
1262
+ // 启动新动画前,先清理旧动画
1263
+ this.stopAnimation();
1264
+
1205
1265
  var opts = options || {};
1206
1266
  var delay = opts.delay || 120; // 每层延迟(毫秒)
1207
1267
 
1208
- var root = this.findRoot();
1209
- if (!root) return;
1268
+ // 如果当前选中了某个簇,从该簇的 MRCA 开始动画
1269
+ var animRoot = this.currentClusterId != null
1270
+ ? this.findClusterRoot(this.currentClusterId)
1271
+ : this.findRoot();
1272
+ if (!animRoot) return;
1210
1273
 
1211
- // 第一步:全部置灰
1212
- this.cy.elements().addClass('grayed-out');
1274
+ // 第一步:全部置灰(使用 batch 确保立即生效)
1275
+ this.cy.batch(function () {
1276
+ self.cy.elements().addClass('grayed-out');
1277
+ });
1213
1278
 
1214
1279
  // 使用 Cytoscape.js 官方 BFS API 按层分组
1215
1280
  var layers = [];
1216
1281
  this.cy.elements().bfs({
1217
- roots: root,
1282
+ roots: animRoot,
1218
1283
  visit: function (v, e, u, i, depth) {
1219
1284
  if (!layers[depth]) layers[depth] = { nodes: [], edges: [] };
1220
1285
  layers[depth].nodes.push(v);
1221
- // 将连接边归入当前层
1222
- if (e) {
1223
- layers[depth].edges.push(e);
1224
- }
1286
+ if (e) layers[depth].edges.push(e);
1225
1287
  },
1226
1288
  directed: true
1227
1289
  });
1228
1290
 
1229
- // 第二步:分层逐渐恢复原色(移除 grayed-out 类)
1291
+ // 第二步:分层逐渐恢复原色
1230
1292
  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'); });
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
+ });
1234
1298
  }, i * delay);
1299
+ self.animationTimeouts.push(tid);
1235
1300
  });
1236
1301
 
1237
- return { layerCount: layers.length, totalNodes: this.cy.nodes().length };
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() };
1238
1322
  };
1239
1323
  global.TreeViz = TreeViz;
1240
1324
  })(window);
@@ -1277,6 +1361,15 @@
1277
1361
  var containerRect = container.getBoundingClientRect();
1278
1362
  container.style.height = (window.innerHeight - containerRect.top) + 'px';
1279
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
+ }
1280
1373
  }
1281
1374
 
1282
1375
  // 防抖函数:避免频繁调用
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basic-genomics/hivtrace-viz",
3
- "version": "1.4.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"