archaeopteryx 3.4.0 → 3.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.
package/README.md CHANGED
@@ -217,15 +217,24 @@ any node does — an event, a search hit, a selection, a visualization.
217
217
  internal node's whole clade into a wedge and opens it again; **Uncollapse
218
218
  Subtree** opens everything below a node; the tool row's uncollapse-all button
219
219
  (the desktop's glyph, lit only while something is collapsed) opens the whole
220
- tree, and so does **Esc**. The wedge is the desktop's triangle: its apex at
221
- the node, its vertical base at the clade's average tip distance (one depth
222
- step in a cladogram), so its depth stays readable; it is filled in the colour most of its tips wear under the current
223
- Color-by, grows gently taller with its tip count, and is named — the node's
220
+ tree, and so does **Esc**. The wedge has its apex at the node, one edge
221
+ reaching the clade's nearest tip and the other its farthest, so the shape
222
+ shows how uneven the clade's branch lengths are, as iTOL draws it (one depth
223
+ step in a cladogram). Its label stands where a tip's would: on the label
224
+ column in the aligned phylogram and on the outer ring in circular, with the
225
+ same guide line; it is filled in the colour most of its tips wear under the current
226
+ Color-by (the colour
227
+ they wore, even when no tip on screen shares their value), grows gently taller with its tip count, and is named — the node's
224
228
  own name if it has one; else the one Color-by value nearly all its tips share,
225
229
  so a clade reads "Bovine · 12 tips" while you look at hosts; else the tips'
226
230
  common name prefix; always with the tip count, and with `[found/total]` while
227
231
  a search hits inside it. Legends, alignment rows and domain tracks describe
228
- the tips on screen, so a collapsed clade's tips leave them. Collapsing is
232
+ the tips on screen, so a collapsed clade's tips leave them,
233
+ and the counts follow every clade you fold or open. A clade holding search
234
+ hits is one dot in the overview and one stop for the hit navigator; one
235
+ holding selected tips is outlined in the selection colour, and filled when
236
+ all its tips are selected. Re-rooting opens any collapsed clade whose tips it
237
+ would change, such as one the new midpoint falls inside. Collapsing is
229
238
  display state only: nothing is removed, exports and downloads carry every
230
239
  tip, and the unrooted layout, which has no rows to fold, shows every clade
231
240
  open. The controls are the desktop's; the drawing and naming are this
@@ -270,6 +279,13 @@ nothing; a column the tree already carries under the same ref is replaced by
270
279
  the table's values. Quoted cells, `#` comment lines and Windows line ends are
271
280
  fine.
272
281
 
282
+ The node menu's **Download Ext. Node Data** writes the other direction: the
283
+ tips under a node as a tab-separated table, header first, with the desktop
284
+ Archaeopteryx's column names (`name`, `taxonomy_scientific_name`, …,
285
+ `branch_length`, then one column per property ref). A column no tip fills
286
+ is left out, and a `node_id` column comes first when tip names are blank or
287
+ repeated. Such a file opens again as a metadata table.
288
+
273
289
  Embedders do the same in two lines, before `launch()`:
274
290
 
275
291
  ```js
@@ -291,10 +307,12 @@ and **Inverse** apply to both.
291
307
  Hits are hard to miss: their labels take the search colour **in bold**, a
292
308
  translucent **pulsing halo** breathes behind each hit, and everything that is
293
309
  *not* a hit fades — the desktop's "dim non-matches", engaged only while at
294
- least one hit is actually visible, so a fruitless search never washes the
295
- tree out. The **overview** miniature marks every hit as a dot in the same
310
+ least one hit is on screen, so a fruitless search never washes the tree out.
311
+ A collapsed clade holding a hit counts as on screen: it stays bright, its
312
+ wedge outlined in the search colour and its label counting the hits, and
313
+ the rest fades even when every hit is inside collapsed clades. The **overview** miniature marks every hit as a dot in the same
296
314
  colour, and a **◀ k / N ▶** navigator appears under the search boxes: each
297
- press centres the previous / next hit in the viewport, wrapping around.
315
+ press centres the previous / next hit in the viewport, wrapping around. A collapsed clade holding hits is one dot and one stop.
298
316
 
299
317
  ## Keyboard
300
318
 
@@ -403,6 +421,8 @@ displayed), and a 1-based **column ruler**. **Hover any residue** for its
403
421
  alignment column, its position within that sequence's own ungapped residues,
404
422
  its full name, class, and Kyte-Doolittle hydropathy. The **Alignment**
405
423
  checkbox under Display Data toggles the whole track.
424
+ To find a motif, pick **Molecular Sequence** in a search box: it matches the
425
+ residues as written, gap characters included, as the desktop does.
406
426
 
407
427
  Alignments arrive with the tree: as phyloXML `<mol_seq is_aligned="true">`
408
428
  elements, or in a **Nexus** file whose characters matrix accompanies its tree.
@@ -615,7 +635,7 @@ a name ending in `xml` as phyloXML, anything else as New Hampshire (Newick).
615
635
  A file holding **several trees** — a Nexus TREES block, a Newick file with one
616
636
  tree per `;`, a phyloXML with several phylogenies — opens on the first, and a
617
637
  picker with previous / next buttons at the top of the control panel moves
618
- between them; each tree opens fresh under the same config, the way a new tab
638
+ between them; each tree opens fresh under the same config, nothing collapsed, the way a new tab
619
639
  does on the desktop. A protein/DNA/RNA characters matrix in a Nexus file
620
640
  (sequential or interleaved) lands on the tips as an aligned `mol_seq`, so the
621
641
  alignment track appears just as it does for phyloXML.
package/archaeopteryx.js CHANGED
@@ -20,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.4.0
23
+ // v 3.4.1
24
24
  // 2026-09-10
25
25
  //
26
26
  // Archaeopteryx.js is a software tool for the visualization and
@@ -103,7 +103,7 @@ function (root, d3, forester, phyloXml) {
103
103
  // IIFE's own function name -- a plain object says what it is.)
104
104
  let archaeopteryx = {};
105
105
 
106
- const VERSION = '3.4.0';
106
+ const VERSION = '3.4.1';
107
107
  const WEBSITE = 'https://cmzmasek.github.io/archaeopteryx-js/';
108
108
  const DESKTOP_WEBSITE = 'https://cmzmasek.github.io/archaeopteryx/';
109
109
  const SOURCE_WEBSITE = 'https://github.com/cmzmasek/archaeopteryx-js';
@@ -1147,12 +1147,12 @@ function (root, d3, forester, phyloXml) {
1147
1147
  // on the desktop: dots in the search colours, so hits outside the
1148
1148
  // current viewport can be spotted and steered to. Search results
1149
1149
  // re-run update(), which ends here, so the dots track every search.
1150
- // Visible nodes only (children, not _children): a hit inside a
1151
- // collapsed clade has no drawn position.
1150
+ // Displayed nodes only: a hit inside a collapsed clade has no drawn
1151
+ // position, so the clade gets the dot, in its wedge's mark colour.
1152
1152
  let hits = [];
1153
1153
  if (_root) {
1154
1154
  forEachDisplayed(function (n) {
1155
- let c = getFoundColor(n);
1155
+ let c = getFoundColor(n) || (isCollapsed(n) ? collapsedMarkColor(n) : null);
1156
1156
  if (c) {
1157
1157
  hits.push({node: n, color: c});
1158
1158
  }
@@ -1997,6 +1997,7 @@ function (root, d3, forester, phyloXml) {
1997
1997
  }
1998
1998
  });
1999
1999
  _vis.colorNext[vis.id] = next;
2000
+ vis.categoryOnScreen = new Set(vis.values); // what the scale may be asked about (visualizationColorFor)
2000
2001
  vis.categoryScale = d3.scaleOrdinal()
2001
2002
  .domain(vis.values)
2002
2003
  .range(vis.values.map(function (v) {
@@ -2758,13 +2759,16 @@ function (root, d3, forester, phyloXml) {
2758
2759
  }
2759
2760
  }
2760
2761
 
2761
- // The dim gate: only while a hit is actually DRAWN, so the tree never
2762
+ // The dim gate: only while a hit is actually SHOWN, so the tree never
2762
2763
  // washes out with nothing emphasised (a 0-hit search, or every hit
2763
- // hidden inside a collapsed clade / outside the displayed subtree).
2764
+ // outside the displayed subtree). A collapsed clade holding a hit
2765
+ // counts as shown: it stays bright, outlined and counting its hits,
2766
+ // so the rest fading points at it (Christian, 2026-09-14: "less
2767
+ // confusing to users" than no dimming at all).
2764
2768
  _dimNonMatches = false;
2765
2769
  if ((_foundNodes0 && _foundNodes0.size > 0) || (_foundNodes1 && _foundNodes1.size > 0)) {
2766
2770
  for (let i = 0, len = nodes.length; i !== len; ++i) {
2767
- if (isNodeFound(nodes[i])) {
2771
+ if (isNodeFound(nodes[i]) || (isCollapsed(nodes[i]) && collapsedFoundCounts(nodes[i]).found > 0)) {
2768
2772
  _dimNonMatches = true;
2769
2773
  break;
2770
2774
  }
@@ -2782,10 +2786,15 @@ function (root, d3, forester, phyloXml) {
2782
2786
  placeRootStub();
2783
2787
 
2784
2788
  if (_state.circularDisplay) {
2789
+ // the outermost drawn point sets the scale: a node, or the far
2790
+ // edge of a collapsed clade's wedge -- which reaches the clade's
2791
+ // farthest tip, hidden from nodes but not from the drawing, and
2792
+ // would otherwise poke through the ring the labels stand on
2785
2793
  let maxY = 0;
2786
2794
  for (let i = 0; i < nodes.length; ++i) {
2787
- if (nodes[i].y > maxY) {
2788
- maxY = nodes[i].y;
2795
+ let y = isCollapsed(nodes[i]) ? Math.max(nodes[i].y, collapsedReach(nodes[i])[1]) : nodes[i].y;
2796
+ if (y > maxY) {
2797
+ maxY = y;
2789
2798
  }
2790
2799
  }
2791
2800
  _radial = {
@@ -3153,9 +3162,14 @@ function (root, d3, forester, phyloXml) {
3153
3162
  // Dim Non-Matches: one opacity on the node GROUP dims its labels, dot,
3154
3163
  // shape and branch-data numbers together; the branch lines are separate
3155
3164
  // path.link elements and keep their full colour, as on the desktop.
3156
- // Hits and selected nodes (getFoundColor) are never dimmed.
3165
+ // Hits and selected nodes (getFoundColor) are never dimmed -- and
3166
+ // neither is a collapsed clade holding one: its wedge and label live in
3167
+ // the clade node's group, and the clade node itself is never a hit.
3157
3168
  node.style('opacity', function (d) {
3158
- return (_dimNonMatches && !getFoundColor(d)) ? DIM_NON_MATCH_OPACITY : null;
3169
+ if (!_dimNonMatches || getFoundColor(d) || (isCollapsed(d) && collapsedHoldsHighlight(d))) {
3170
+ return null;
3171
+ }
3172
+ return DIM_NON_MATCH_OPACITY;
3159
3173
  });
3160
3174
 
3161
3175
  let nodeUpdate = animateOrSet(node, transitionDuration)
@@ -3303,43 +3317,75 @@ function (root, d3, forester, phyloXml) {
3303
3317
  // _svgGroup, so that name put them in the way of selectAll('path.link')
3304
3318
  // -- the main link data-join, and the overview's miniature.
3305
3319
  _svgGroup.selectAll('g.aptx-align-ext').remove();
3306
- if (!radialDisplay() && _state.phylogram && _state.alignPhylogram && _state.showExternalLabels
3307
- && (_state.showNodeName || _state.showTaxonomy || _state.showSequence)) {
3320
+ let alignedRect = !radialDisplay() && _state.phylogram && _state.alignPhylogram;
3321
+ let tipGuides = alignedRect && _state.showExternalLabels
3322
+ && (_state.showNodeName || _state.showTaxonomy || _state.showSequence);
3323
+ // a collapsed clade's label always shows, so its guide does too: from
3324
+ // past the wedge's farthest tip to the label column
3325
+ let collapsedGuides = alignedRect ? nodes.filter(function (d) {
3326
+ return isCollapsed(d) && !(_state.dynahide && d.hide);
3327
+ }) : [];
3328
+ if (tipGuides || collapsedGuides.length > 0) {
3308
3329
  let ext = _svgGroup.insert('g', 'g').attr('class', 'aptx-align-ext');
3309
- ext.selectAll('path')
3310
- .data(links.filter(function (d) {
3311
- return (!d.target.children && !(_state.dynahide && d.target.hide));
3312
- }))
3313
- .enter().append('path')
3314
- .attr('fill', 'none')
3315
- .attr('stroke-width', 1)
3316
- .attr('stroke', _state.branchColorDefault)
3317
- .style('stroke-opacity', 0.25)
3330
+ let guideStyle = function (sel) {
3331
+ return sel.attr('fill', 'none')
3332
+ .attr('stroke-width', 1)
3333
+ .attr('stroke', _state.branchColorDefault)
3334
+ .style('stroke-opacity', 0.25);
3335
+ };
3336
+ if (tipGuides) {
3337
+ guideStyle(ext.selectAll('path.aptx-tip-guide')
3338
+ .data(links.filter(function (d) {
3339
+ return (!d.target.children && !(_state.dynahide && d.target.hide));
3340
+ }))
3341
+ .enter().append('path').attr('class', 'aptx-tip-guide'))
3342
+ .attr('d', function (d) {
3343
+ return connection(d.target);
3344
+ });
3345
+ }
3346
+ guideStyle(ext.selectAll('path.aptx-collapsed-guide')
3347
+ .data(collapsedGuides)
3348
+ .enter().append('path').attr('class', 'aptx-collapsed-guide'))
3318
3349
  .attr('d', function (d) {
3319
- return connection(d.target);
3350
+ let x1 = collapsedReach(d)[1] + 5;
3351
+ return (_w - x1) > 5 ? 'M' + x1 + ',' + d.x + 'L' + _w + ',' + d.x : null;
3320
3352
  });
3321
3353
  }
3322
3354
 
3323
3355
  // circular: a thin dashed connector from each external node out to the
3324
3356
  // common label ring (so labels line up like iTOL's aligned display).
3325
3357
  _svgGroup.selectAll('g.aptx-radial-conn').remove();
3326
- if (_state.circularDisplay && _state.showExternalLabels) {
3327
- let conn = _svgGroup.insert('g', 'g').attr('class', 'aptx-radial-conn');
3328
- conn.selectAll('line')
3329
- .data(nodes.filter(function (d) {
3330
- return !d.children && !(_state.dynahide && d.hide);
3331
- }))
3332
- .enter().append('line')
3333
- .attr('stroke', _state.branchColorDefault)
3334
- // width/opacity match the rectangular aligned extensions; the
3335
- // dash alone marks these as connectors (0.5px at 0.3 vanished)
3336
- .attr('stroke-width', 1)
3337
- .style('stroke-opacity', 0.4)
3338
- .style('stroke-dasharray', '2,3')
3339
- .attr('x1', function (d) { return radialXY(d.x, d.y)[0]; })
3340
- .attr('y1', function (d) { return radialXY(d.x, d.y)[1]; })
3341
- .attr('x2', function (d) { return polarXY(radialAngle(d.x), _radial.maxRad)[0]; })
3342
- .attr('y2', function (d) { return polarXY(radialAngle(d.x), _radial.maxRad)[1]; });
3358
+ if (_state.circularDisplay) {
3359
+ // tips while their labels show; a collapsed clade always (its label
3360
+ // always shows), from its wedge's farthest reach out to the ring
3361
+ let connected = nodes.filter(function (d) {
3362
+ if (_state.dynahide && d.hide) {
3363
+ return false;
3364
+ }
3365
+ if (isCollapsed(d)) {
3366
+ return _radial.maxRad - radialRadius(collapsedReach(d)[1]) > 2;
3367
+ }
3368
+ return !d.children && _state.showExternalLabels;
3369
+ });
3370
+ let innerY = function (d) {
3371
+ return isCollapsed(d) ? collapsedReach(d)[1] : d.y;
3372
+ };
3373
+ if (connected.length > 0) {
3374
+ let conn = _svgGroup.insert('g', 'g').attr('class', 'aptx-radial-conn');
3375
+ conn.selectAll('line')
3376
+ .data(connected)
3377
+ .enter().append('line')
3378
+ .attr('stroke', _state.branchColorDefault)
3379
+ // width/opacity match the rectangular aligned extensions; the
3380
+ // dash alone marks these as connectors (0.5px at 0.3 vanished)
3381
+ .attr('stroke-width', 1)
3382
+ .style('stroke-opacity', 0.4)
3383
+ .style('stroke-dasharray', '2,3')
3384
+ .attr('x1', function (d) { return radialXY(d.x, innerY(d))[0]; })
3385
+ .attr('y1', function (d) { return radialXY(d.x, innerY(d))[1]; })
3386
+ .attr('x2', function (d) { return polarXY(radialAngle(d.x), _radial.maxRad)[0]; })
3387
+ .attr('y2', function (d) { return polarXY(radialAngle(d.x), _radial.maxRad)[1]; });
3388
+ }
3343
3389
  }
3344
3390
 
3345
3391
  for (let i = 0, len = nodes.length; i !== len; ++i) {
@@ -3553,6 +3599,17 @@ function (root, d3, forester, phyloXml) {
3553
3599
  if (colorModeOf(vis) === 'range') {
3554
3600
  return vis.rangeScale ? vis.rangeScale(Number(value)) : null;
3555
3601
  }
3602
+ // A value only tips hidden in a collapsed clade carry is not on
3603
+ // screen, so not in the scale's domain -- and a d3 ordinal scale asked
3604
+ // about an unknown value extends its domain and hands out an on-screen
3605
+ // value's colour. Such a value keeps its remembered colour instead
3606
+ // (assigned at launch over the whole tree, or the user's override):
3607
+ // the clade's wedge keeps the colour its tips wore. Christian,
3608
+ // 2026-09-14; reported by the desktop session, which follows.
3609
+ if (vis.categoryOnScreen && !vis.categoryOnScreen.has(value)) {
3610
+ let mem = _vis.colorMemory[vis.id];
3611
+ return (mem && mem[visMemoryKey(vis, value)]) || null;
3612
+ }
3556
3613
  return vis.categoryScale(value);
3557
3614
  }
3558
3615
 
@@ -3593,11 +3650,13 @@ function (root, d3, forester, phyloXml) {
3593
3650
  if (_root && ((_foundNodes0 && _foundNodes0.size > 0) || (_foundNodes1 && _foundNodes1.size > 0))) {
3594
3651
  // natural (top-to-bottom) drawing order -- forester's preorder
3595
3652
  // walks children in reverse, which made the NEXT arrow step upward
3653
+ // a collapsed clade holding hits is ONE stop, on its node: the hits
3654
+ // inside have no position of their own to step to
3596
3655
  let walk = function (n) {
3597
- if (isNodeFound(n)) {
3656
+ if (isNodeFound(n) || (isCollapsed(n) && collapsedFoundCounts(n).found > 0)) {
3598
3657
  hits.push(n);
3599
3658
  }
3600
- if (n.children && !isCollapsed(n)) { // a hit inside a collapsed clade has no position to step to
3659
+ if (n.children && !isCollapsed(n)) {
3601
3660
  for (let i = 0; i < n.children.length; ++i) {
3602
3661
  walk(n.children[i]);
3603
3662
  }
@@ -5412,6 +5471,10 @@ function (root, d3, forester, phyloXml) {
5412
5471
  if (index === _treeIndex) {
5413
5472
  return makeViewerHandle();
5414
5473
  }
5474
+ // collapsing is part of the view, and a switched-to tree opens fresh:
5475
+ // nothing collapsed, now or when you come back (Christian, 2026-09-14)
5476
+ clearCollapsedFlags(_trees[_treeIndex]);
5477
+ clearCollapsedFlags(_trees[index]);
5415
5478
  return launchInto(_container, _trees, index, _launchConfig);
5416
5479
  }
5417
5480
 
@@ -5571,134 +5634,21 @@ function (root, d3, forester, phyloXml) {
5571
5634
  update();
5572
5635
  }
5573
5636
 
5637
+ // The tips under the node as a tab-separated table with a header,
5638
+ // the desktop's columns (forester.externalNodeDataTable), top to
5639
+ // bottom as drawn; it reads back through a metadata-table join.
5574
5640
  function downloadExternalNodeDataAll(node) {
5575
-
5576
- let addSep = function (t) {
5577
- if (t.length > 0) {
5578
- t += '\t';
5579
- }
5580
- return t;
5581
- };
5582
-
5583
- let addSepSame = function (t) {
5584
- if (t.length > 0) {
5585
- t += ', ';
5586
- }
5587
- return t;
5588
- };
5589
- let text_all = '';
5590
-
5591
5641
  const ext_nodes = forester.getAllExternalNodes(node).reverse();
5592
-
5593
5642
  let filename;
5594
5643
  if (ext_nodes.length === 1 && ext_nodes[0].name) {
5595
- filename = 'External_Node_Data_for_Node_' + ext_nodes[0].name.replace(/\W/g, '_') + '.txt';
5644
+ filename = 'External_Node_Data_for_Node_' + ext_nodes[0].name.replace(/\W/g, '_') + '.tsv';
5596
5645
  } else {
5597
- filename = 'External_Node_Data_for_' + ext_nodes.length + '_Nodes.txt';
5646
+ filename = 'External_Node_Data_for_' + ext_nodes.length + '_Nodes.tsv';
5598
5647
  }
5599
-
5600
- for (let j = 0, l = ext_nodes.length; j < l; ++j) {
5601
- let text = '';
5602
- let n = ext_nodes[j];
5603
- if (n.name) {
5604
- text += n.name
5605
- }
5606
-
5607
- if (n.properties && (n.properties.length > 0)) {
5608
- const sorted_properties = n.properties.concat().sort();
5609
- const l = sorted_properties.length;
5610
- let properties_text = '';
5611
- let prev_property_ref = null;
5612
- for (let pl = 0; pl < l; ++pl) {
5613
- if (forester.isNodeScopedProperty(sorted_properties[pl])) {
5614
- if (sorted_properties[pl].ref === prev_property_ref) {
5615
- properties_text = addSepSame(properties_text);
5616
- } else {
5617
- prev_property_ref = sorted_properties[pl].ref;
5618
- properties_text = addSep(properties_text);
5619
- }
5620
- properties_text += sorted_properties[pl].value;
5621
- }
5622
- }
5623
- if (properties_text.length > 0) {
5624
- text = addSep(text);
5625
- text += properties_text;
5626
- }
5627
- }
5628
-
5629
- if (n.taxonomies) {
5630
- let tax_text = '';
5631
- for (let i = 0; i < n.taxonomies.length; ++i) {
5632
- let t = n.taxonomies[i];
5633
- if (t.id) {
5634
- if (t.id.provider) {
5635
- tax_text = addSep(tax_text);
5636
- tax_text += '[' + t.id.provider + ']:' + t.id.value;
5637
- } else {
5638
- tax_text = addSep(tax_text);
5639
- tax_text += t.id.value;
5640
- }
5641
- }
5642
- if (t.code) {
5643
- tax_text = addSep(tax_text);
5644
- tax_text += t.code;
5645
- }
5646
- if (t.scientific_name) {
5647
- tax_text = addSep(tax_text);
5648
- tax_text += t.scientific_name;
5649
- }
5650
- if (t.common_name) {
5651
- tax_text = addSep(tax_text);
5652
- tax_text += t.common_name;
5653
- }
5654
- if (t.rank) {
5655
- tax_text = addSep(tax_text);
5656
- tax_text += t.rank;
5657
- }
5658
- }
5659
- text = addSep(text);
5660
- text += tax_text;
5661
- }
5662
- if (n.sequences) {
5663
- let seq_text = '';
5664
- for (let i = 0; i < n.sequences.length; ++i) {
5665
- let s = n.sequences[i];
5666
- if (s.accession) {
5667
- if (s.accession.source) {
5668
- seq_text = addSep(seq_text);
5669
- seq_text += '[' + s.accession.source + ']:' + s.accession.value;
5670
- } else {
5671
- seq_text = addSep(seq_text);
5672
- seq_text += s.accession.value;
5673
- }
5674
- }
5675
- if (s.symbol) {
5676
- seq_text = addSep(seq_text);
5677
- seq_text += s.symbol;
5678
- }
5679
- if (s.name) {
5680
- seq_text = addSep(seq_text);
5681
- seq_text += s.name;
5682
- }
5683
- if (s.gene_name) {
5684
- seq_text = addSep(seq_text);
5685
- seq_text += s.gene_name;
5686
- }
5687
- if (s.location) {
5688
- seq_text = addSep(seq_text);
5689
- seq_text += s.location;
5690
- }
5691
- }
5692
- text = addSep(text);
5693
- text += seq_text;
5694
- }
5695
- if (text.length > 0) {
5696
- text_all += text + '\n';
5697
- }
5698
- }
5699
-
5700
- saveAs(new Blob([text_all], {type: "application/txt"}), filename);
5701
-
5648
+ const tsv = forester.externalNodeDataTsv(ext_nodes, function (n, i) {
5649
+ return (n.viewId !== undefined) ? n.viewId : i + 1;
5650
+ });
5651
+ saveAs(new Blob([tsv], {type: 'text/tab-separated-values'}), filename);
5702
5652
  update();
5703
5653
  }
5704
5654
 
@@ -5929,7 +5879,12 @@ function (root, d3, forester, phyloXml) {
5929
5879
  }
5930
5880
  if (!_in_subtree && d.parent && d.parent.parent
5931
5881
  && ((_treeData.rerootable === undefined) || (_treeData.rerootable === true))) {
5932
- items.push({label: 'Reroot', action: function () { forester.reRoot(tree, d, -1); zoomToFit(); }});
5882
+ items.push({label: 'Reroot', action: function () {
5883
+ rerootKeepingCollapse(function () {
5884
+ forester.reRoot(tree, d, -1);
5885
+ });
5886
+ zoomToFit();
5887
+ }});
5933
5888
  }
5934
5889
  if (_settings.enableManualNodeSelection) {
5935
5890
  items.push({label: 'Select/Deselect Node', action: function () { selectDeselectNode(d); }});
@@ -6848,6 +6803,7 @@ function (root, d3, forester, phyloXml) {
6848
6803
  }
6849
6804
  d.collapsed = !d.collapsed;
6850
6805
  calcMaxExtLabel();
6806
+ refreshVisualizations(false); // the legends describe the tips on screen
6851
6807
  scheduleUpdate(null, 0);
6852
6808
  }
6853
6809
 
@@ -6856,6 +6812,7 @@ function (root, d3, forester, phyloXml) {
6856
6812
  n.collapsed = false;
6857
6813
  });
6858
6814
  calcMaxExtLabel();
6815
+ refreshVisualizations(false);
6859
6816
  scheduleUpdate(null, 0);
6860
6817
  }
6861
6818
 
@@ -6871,6 +6828,47 @@ function (root, d3, forester, phyloXml) {
6871
6828
  });
6872
6829
  }
6873
6830
 
6831
+ // Runs a re-rooting without turning a collapsed clade inside out. A clade
6832
+ // on the path to the new root has its parent turned into a child, so its
6833
+ // flag would go on hiding a different set of tips -- with a midpoint
6834
+ // inside a collapsed clade, the whole rest of the tree. Such a clade
6835
+ // opens; every other clade keeps its tips and stays collapsed.
6836
+ function rerootKeepingCollapse(reroot) {
6837
+ let flagged = [];
6838
+ forester.preOrderTraversalAll(_root_const || _root, function (n) {
6839
+ if (n.collapsed === true && n.children) {
6840
+ flagged.push({node: n, tips: forester.getAllExternalNodes(n)});
6841
+ }
6842
+ });
6843
+ reroot();
6844
+ let opened = false;
6845
+ flagged.forEach(function (f) {
6846
+ let now = f.node.children ? forester.getAllExternalNodes(f.node) : [];
6847
+ let kept = new Set(f.tips);
6848
+ if (now.length !== f.tips.length || !now.every(function (t) { return kept.has(t); })) {
6849
+ f.node.collapsed = false;
6850
+ opened = true;
6851
+ }
6852
+ });
6853
+ if (opened) {
6854
+ calcMaxExtLabel();
6855
+ refreshVisualizations(false);
6856
+ }
6857
+ }
6858
+
6859
+ // What a collapsed clade is marked with for the tips it hides: the colour
6860
+ // of its first search hit, else the selection colour when a tip is
6861
+ // selected, else null. The wedge, the overview dot and the dimming all
6862
+ // read it, so they always agree.
6863
+ function collapsedMarkColor(d) {
6864
+ let tips = forester.getAllExternalNodes(d);
6865
+ let hit = tips.filter(isNodeFound)[0];
6866
+ if (hit) {
6867
+ return getFoundColor(hit);
6868
+ }
6869
+ return tips.some(function (t) { return _selectedNodes.has(t); }) ? _state.selectedColorDefault : null;
6870
+ }
6871
+
6874
6872
  // How tall a collapsed clade draws, in rows: 1 for a pair, growing with
6875
6873
  // the logarithm of the tip count, capped so a huge clade does not eat
6876
6874
  // the screen. The cluster layout's separation turns it into space.
@@ -6912,6 +6910,24 @@ function (root, d3, forester, phyloXml) {
6912
6910
  return prefix.length >= 2 ? prefix : '';
6913
6911
  }
6914
6912
 
6913
+ // Whether any tip hidden in a collapsed clade is a hit or selected -- the
6914
+ // clade then stays undimmed, as that tip would be if it were drawn.
6915
+ function collapsedHoldsHighlight(d) {
6916
+ return collapsedMarkColor(d) !== null;
6917
+ }
6918
+
6919
+ // Whether every tip a collapsed clade hides is marked: all of them search
6920
+ // hits when the clade holds any hit, else all of them selected. Such a
6921
+ // wedge is filled in the mark colour and its label set in it, bold.
6922
+ function collapsedFullyMarked(d) {
6923
+ let hits = collapsedFoundCounts(d);
6924
+ if (hits.found > 0) {
6925
+ return hits.found === hits.total;
6926
+ }
6927
+ let tips = forester.getAllExternalNodes(d);
6928
+ return tips.length > 0 && tips.every(function (t) { return _selectedNodes.has(t); });
6929
+ }
6930
+
6915
6931
  function collapsedFoundCounts(d) {
6916
6932
  let found = 0;
6917
6933
  let total = 0;
@@ -6959,26 +6975,28 @@ function (root, d3, forester, phyloXml) {
6959
6975
  return best || _state.branchColorDefault;
6960
6976
  }
6961
6977
 
6962
- // The wedge's base: the desktop's triangle (paintCollapsedNode with its
6963
- // default "collapsed with average height") stands on a VERTICAL base at
6964
- // the clade's average tip distance, so the shape is symmetric about the
6965
- // node's row and its depth still reads. A cladogram, where every leaf
6966
- // sits on the tip column, gives it one depth step.
6967
- function collapsedBase(d) {
6978
+ // The wedge's reach: the clade's nearest and farthest tips, in the
6979
+ // layout's x. One edge runs to the nearest tip and the other to the
6980
+ // farthest, so the shape itself shows how uneven the clade's branch
6981
+ // lengths are (as iTOL draws it). Christian chose this over the
6982
+ // desktop's symmetric triangle on an average base (2026-09-14). A
6983
+ // phylogram measures the tips; a cladogram, where every leaf sits on
6984
+ // the tip column, gives the wedge one depth step.
6985
+ function collapsedReach(d) {
6968
6986
  if (_state.phylogram && _yScale) {
6969
- let sum = 0;
6970
- let n = 0;
6987
+ let min = Infinity;
6988
+ let max = -Infinity;
6971
6989
  forester.preOrderTraversal(d, function (t) {
6972
6990
  if (!t.children && typeof t.distToRoot === 'number') {
6973
- sum += t.distToRoot;
6974
- ++n;
6991
+ if (t.distToRoot < min) { min = t.distToRoot; }
6992
+ if (t.distToRoot > max) { max = t.distToRoot; }
6975
6993
  }
6976
6994
  });
6977
- if (n > 0) {
6978
- return Math.max(d.y + 2, _yScale(sum / n));
6995
+ if (isFinite(min)) {
6996
+ return [Math.max(d.y + 2, _yScale(min)), Math.max(d.y + 2, _yScale(max))];
6979
6997
  }
6980
6998
  }
6981
- return d.y + _cladogramUnit;
6999
+ return [d.y + _cladogramUnit, d.y + _cladogramUnit];
6982
7000
  }
6983
7001
 
6984
7002
  let _cladogramUnit = 0; // one depth step of the last cladogram layout, px
@@ -7001,52 +7019,71 @@ function (root, d3, forester, phyloXml) {
7001
7019
  let fs = _state.externalNodeFontSize;
7002
7020
  wedge.each(function (d) {
7003
7021
  let color = collapsedColor(d);
7004
- let hits = collapsedFoundCounts(d);
7005
- let foundColor = hits.found > 0 ? getFoundColor(forester.getAllExternalNodes(d).filter(isNodeFound)[0]) : null;
7022
+ let mark = collapsedMarkColor(d);
7023
+ let full = collapsedFullyMarked(d);
7006
7024
  let h = Math.max(6, collapsedRows(d) * _rowUnit * 0.82);
7007
- let base = collapsedBase(d);
7025
+ let reach = collapsedReach(d);
7008
7026
  let path;
7009
7027
  if (radialDisplay()) {
7010
7028
  // in the rotated frame: +x along the spoke from the node (the
7011
- // group's origin, already at the node's radius), the base
7012
- // across it -- a distance, never an absolute radius, or the
7013
- // wedge lands a whole radius further out than its label
7014
- let dr = radialRadius(base) - radialRadius(d.y);
7015
- path = 'M0,0 L' + dr + ',' + (-h / 2) + ' L' + dr + ',' + (h / 2) + ' Z';
7029
+ // group's origin, already at the node's radius) -- distances,
7030
+ // never absolute radii, or the wedge lands a whole radius
7031
+ // further out than its label
7032
+ let r0 = radialRadius(d.y);
7033
+ let dr1 = radialRadius(reach[0]) - r0;
7034
+ let dr2 = radialRadius(reach[1]) - r0;
7035
+ path = 'M0,0 L' + dr1 + ',' + (-h / 2) + ' L' + dr2 + ',' + (h / 2) + ' Z';
7016
7036
  } else {
7017
- let dx = base - d.y;
7018
- path = 'M0,0 L' + dx + ',' + (-h / 2) + ' L' + dx + ',' + (h / 2) + ' Z';
7037
+ let dx1 = reach[0] - d.y;
7038
+ let dx2 = reach[1] - d.y;
7039
+ path = 'M0,0 L' + dx1 + ',' + (-h / 2) + ' L' + dx2 + ',' + (h / 2) + ' Z';
7019
7040
  }
7020
7041
  d3.select(this)
7021
7042
  .attr('d', path)
7022
7043
  .attr('transform', radialDisplay() ? 'rotate(' + labelAngleDeg(d) + ')' : null)
7023
- .style('fill', (hits.found > 0 && hits.found === hits.total) ? foundColor : color)
7024
- .style('fill-opacity', (hits.found > 0 && hits.found === hits.total) ? 0.45 : 0.22)
7025
- .style('stroke', foundColor || color)
7026
- .style('stroke-width', foundColor ? 1.5 : 1)
7044
+ .style('fill', full ? mark : color)
7045
+ .style('fill-opacity', full ? 0.45 : 0.22)
7046
+ .style('stroke', mark || color)
7047
+ .style('stroke-width', mark ? 1.5 : 1)
7027
7048
  .style('stroke-opacity', 0.9)
7028
7049
  .style('stroke-linejoin', 'round');
7029
7050
  });
7030
7051
  label.each(function (d) {
7031
- let base = collapsedBase(d);
7032
- let hits = collapsedFoundCounts(d);
7033
- let allFound = hits.found > 0 && hits.found === hits.total;
7034
- let ink = allFound ? getFoundColor(forester.getAllExternalNodes(d).filter(isNodeFound)[0]) : _state.labelColorDefault;
7052
+ let far = collapsedReach(d)[1]; // the label clears the farthest tip
7053
+ let allFound = collapsedFullyMarked(d);
7054
+ let ink = allFound ? collapsedMarkColor(d) : _state.labelColorDefault;
7035
7055
  let t = d3.select(this)
7036
7056
  .text(collapsedLabel(d))
7037
7057
  .style('font', (allFound ? '600 ' : '') + fs + 'px ' + FONT_DEFAULTS)
7038
7058
  .style('fill', ink)
7039
7059
  .style('pointer-events', 'none');
7040
- if (radialDisplay()) {
7041
- let r = radialRadius(base) - radialRadius(d.y) + _state.nodeLabelGap;
7042
- let flip = labelFlip(d);
7060
+ // placed exactly as a tip's label is (update's text.extlabel): on
7061
+ // the common ring in circular, rotated along the spoke or upright;
7062
+ // on the label column in the aligned phylogram; else just past the
7063
+ // wedge, where a tip's label sits past its tip
7064
+ let gap = _state.nodeLabelGap;
7065
+ let flip = radialDisplay() && labelFlip(d);
7066
+ if (_state.circularDisplay && _radial) {
7067
+ t.attr('text-anchor', flip ? 'end' : 'start')
7068
+ .attr('x', flip ? -gap : gap).attr('dy', '0.32em');
7069
+ if (_radialLabelsHorizontal) {
7070
+ let p = radialXY(d.x, d.y);
7071
+ let q = polarXY(radialAngle(d.x), _radial.maxRad);
7072
+ t.attr('transform', 'translate(' + (q[0] - p[0]) + ',' + (q[1] - p[1]) + ')');
7073
+ } else {
7074
+ let off = _radial.maxRad - radialRadius(d.y);
7075
+ t.attr('transform', 'rotate(' + labelAngleDeg(d) + ') translate(' + off + ',0)' + (flip ? ' rotate(180)' : ''));
7076
+ }
7077
+ } else if (radialDisplay()) {
7078
+ let r = radialRadius(far) - radialRadius(d.y) + gap;
7043
7079
  t.attr('transform', 'rotate(' + labelAngleDeg(d) + ') translate(' + r + ',0)' + (flip ? ' rotate(180)' : ''))
7044
7080
  .attr('text-anchor', flip ? 'end' : 'start')
7045
7081
  .attr('x', 0).attr('dy', '0.32em');
7046
7082
  } else {
7083
+ let column = (_state.phylogram && _state.alignPhylogram) ? _w : far;
7047
7084
  t.attr('transform', null)
7048
7085
  .attr('text-anchor', 'start')
7049
- .attr('x', base - d.y + _state.nodeLabelGap)
7086
+ .attr('x', column - d.y + gap)
7050
7087
  .attr('dy', (0.3 * fs) + 'px');
7051
7088
  }
7052
7089
  });
@@ -7236,7 +7273,9 @@ function (root, d3, forester, phyloXml) {
7236
7273
  }
7237
7274
  if (s.root === 'midpoint' && _viewOps.root !== 'midpoint'
7238
7275
  && (_treeData.rerootable === undefined || _treeData.rerootable === true)) {
7239
- forester.midpointRoot(_root_const);
7276
+ rerootKeepingCollapse(function () {
7277
+ forester.midpointRoot(_root_const);
7278
+ });
7240
7279
  _viewOps.root = 'midpoint';
7241
7280
  }
7242
7281
  if (s.order === 'asc' || s.order === 'desc') {
@@ -8779,7 +8818,9 @@ function (root, d3, forester, phyloXml) {
8779
8818
  showNodeMenu([
8780
8819
  {
8781
8820
  label: 'Midpoint re-root', action: function () {
8782
- forester.midpointRoot(_root);
8821
+ rerootKeepingCollapse(function () {
8822
+ forester.midpointRoot(_root);
8823
+ });
8783
8824
  _viewOps.root = 'midpoint'; // what a shared view replays
8784
8825
  zoomToFit();
8785
8826
  }
package/forester.js CHANGED
@@ -20,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.4.0
23
+ // v 3.4.1
24
24
  // 2026-09-10
25
25
  //
26
26
  // forester.js is a general suite for dealing with phylogenetic trees.
@@ -3978,6 +3978,98 @@
3978
3978
  // becomes "meta:" plus the header with its whitespace as '_' -- the
3979
3979
  // display name prettifies that back to spaces, so "Collection Date"
3980
3980
  // stays "Collection Date" in every menu.
3981
+ // The tips' data as a table, one row per tip in the order given, header
3982
+ // first: what the node menu's "Download Ext. Node Data" writes. The
3983
+ // columns and their names are the desktop's (NodeDataExporter.toNodeDataTsv),
3984
+ // so the two programs write the same table: name (always), the first
3985
+ // taxonomy's scientific name, common name, code, id and rank, the first
3986
+ // sequence's name, gene name, symbol, accession and type, the branch
3987
+ // length, then one column per property ref, sorted, holding its first
3988
+ // value. A column no tip has a value for is left out. When the tip names
3989
+ // cannot key the rows (one blank or repeated), a node_id column comes
3990
+ // first, from idOf(tip, index) or the row number. Tabs and line breaks
3991
+ // inside a value become spaces. A property keeps its ref as the header,
3992
+ // so the table joins back onto a tree with joinMetadataTable.
3993
+ forester.externalNodeDataTable = function (tips, idOf) {
3994
+ tips = tips || [];
3995
+ if (tips.length === 0) {
3996
+ return {columns: [], rows: []};
3997
+ }
3998
+ let clean = function (v) {
3999
+ return (v === undefined || v === null) ? '' : String(v).replace(/[\t\n\r]/g, ' ');
4000
+ };
4001
+ let tax = function (n) {
4002
+ return (n.taxonomies && n.taxonomies[0]) || {};
4003
+ };
4004
+ let seq = function (n) {
4005
+ return (n.sequences && n.sequences[0]) || {};
4006
+ };
4007
+ let cols = [];
4008
+ let add = function (name, extract, force) {
4009
+ let vals = tips.map(function (n, i) {
4010
+ return clean(extract(n, i));
4011
+ });
4012
+ if (force || vals.some(function (v) { return v.length > 0; })) {
4013
+ cols.push({name: name, vals: vals});
4014
+ }
4015
+ };
4016
+ let seen = new Set();
4017
+ let unique = tips.every(function (n) {
4018
+ if (!n.name || seen.has(n.name)) {
4019
+ return false;
4020
+ }
4021
+ seen.add(n.name);
4022
+ return true;
4023
+ });
4024
+ if (!unique) {
4025
+ add('node_id', function (n, i) { return idOf ? idOf(n, i) : i + 1; }, true);
4026
+ }
4027
+ add('name', function (n) { return n.name; }, true);
4028
+ add('taxonomy_scientific_name', function (n) { return tax(n).scientific_name; });
4029
+ add('taxonomy_common_name', function (n) { return tax(n).common_name; });
4030
+ add('taxonomy_code', function (n) { return tax(n).code; });
4031
+ add('taxonomy_id', function (n) { return tax(n).id && tax(n).id.value; });
4032
+ add('taxonomy_rank', function (n) { return tax(n).rank; });
4033
+ add('sequence_name', function (n) { return seq(n).name; });
4034
+ add('gene_name', function (n) { return seq(n).gene_name; });
4035
+ add('sequence_symbol', function (n) { return seq(n).symbol; });
4036
+ add('sequence_accession', function (n) { return seq(n).accession && seq(n).accession.value; });
4037
+ add('sequence_type', function (n) { return seq(n).type; });
4038
+ add('branch_length', function (n) { return (typeof n.branch_length === 'number') ? n.branch_length : ''; });
4039
+ let refs = new Set();
4040
+ tips.forEach(function (n) {
4041
+ (n.properties || []).forEach(function (p) {
4042
+ if (p.ref) {
4043
+ refs.add(p.ref);
4044
+ }
4045
+ });
4046
+ });
4047
+ Array.from(refs).sort().forEach(function (ref) {
4048
+ add(ref, function (n) {
4049
+ let p = (n.properties || []).filter(function (q) { return q.ref === ref; })[0];
4050
+ return p ? p.value : '';
4051
+ });
4052
+ });
4053
+ return {
4054
+ columns: cols.map(function (c) { return c.name; }),
4055
+ rows: tips.map(function (n, i) {
4056
+ return cols.map(function (c) { return c.vals[i]; });
4057
+ })
4058
+ };
4059
+ };
4060
+
4061
+ // externalNodeDataTable as tab-separated text, header line first; empty
4062
+ // for no tips.
4063
+ forester.externalNodeDataTsv = function (tips, idOf) {
4064
+ let t = forester.externalNodeDataTable(tips, idOf);
4065
+ if (t.columns.length === 0) {
4066
+ return '';
4067
+ }
4068
+ return [t.columns].concat(t.rows).map(function (r) {
4069
+ return r.join('\t');
4070
+ }).join('\n') + '\n';
4071
+ };
4072
+
3981
4073
  forester.metadataColumnRef = function (header, index) {
3982
4074
  let h = String(header || '').trim();
3983
4075
  if (h.length === 0) {
@@ -4135,7 +4227,13 @@
4135
4227
  const SEARCH_DOMAIN = textField('Domain', n => searchSeqs(n).reduce((a, s) => a.concat((s.domain_architecture && s.domain_architecture.domains) ? s.domain_architecture.domains.map(d => d.name) : []), []).filter(Boolean));
4136
4228
  const SEARCH_ANNOTATION = textField('Annotation', n => searchSeqs(n).reduce((a, s) => a.concat((s.annotations || []).reduce((b, an) => b.concat([an.desc, an.ref]), [])), []).filter(Boolean), {anyText: true});
4137
4229
  const SEARCH_CROSS_REFERENCE = textField('Cross-Reference', n => searchSeqs(n).reduce((a, s) => a.concat((s.cross_references || []).reduce((b, x) => b.concat([x.value, x.source, x.comment]), [])), []).filter(Boolean), {anyText: true});
4138
- const SEARCH_MOLECULAR_SEQUENCE = textField('Molecular Sequence', n => searchSeqs(n).map(s => s.mol_seq).filter(Boolean), {suggest: false});
4230
+ // The residues: the phyloXML and Nexus readers give mol_seq as {value,
4231
+ // is_aligned}, and a hand-built tree may carry the plain string. Reading
4232
+ // the object itself compared every query against "[object Object]", so
4233
+ // this field never matched a real file (its test built the string form).
4234
+ const SEARCH_MOLECULAR_SEQUENCE = textField('Molecular Sequence', n => searchSeqs(n).map(function (s) {
4235
+ return (s.mol_seq && typeof s.mol_seq === 'object') ? s.mol_seq.value : s.mol_seq;
4236
+ }).filter(Boolean), {suggest: false});
4139
4237
  // in menu order
4140
4238
  const SEARCH_TEXT_FIELDS = [
4141
4239
  SEARCH_NODE_NAME, SEARCH_TAXONOMY_SCIENTIFIC_NAME, SEARCH_TAXONOMY_COMMON_NAME, SEARCH_TAXONOMY_CODE,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archaeopteryx",
3
- "version": "3.4.0",
3
+ "version": "3.4.1",
4
4
  "description": "Archaeopteryx.js is a software tool for the visualization and analysis of highly annotated phylogenetic trees.",
5
5
  "main": "archaeopteryx.js",
6
6
  "types": "archaeopteryx.d.ts",