archaeopteryx 3.4.0 → 3.5.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.
package/archaeopteryx.js CHANGED
@@ -20,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.4.0
23
+ // v 3.5.0
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.5.0';
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';
@@ -350,6 +350,7 @@ function (root, d3, forester, phyloXml) {
350
350
  const CLADOGRAM_BUTTON = 'cla_b';
351
351
  const CONFIDENCE_VALUES_CB = 'conf_cb';
352
352
  const SUPPORT_DOTS_CB = 'suppdots_cb';
353
+ const MAD_VALUES_CB = 'mad_cb';
353
354
  const DOWNLOAD_BUTTON = 'dl_b';
354
355
  const DYNAHIDE_CB = 'dynahide_cb';
355
356
  const MSA_CB = 'msa_cb';
@@ -371,6 +372,7 @@ function (root, d3, forester, phyloXml) {
371
372
  const INTERNAL_LABEL_CB = 'intl_cb';
372
373
  const LABEL_COLOR_SELECT_MENU = 'lcs_menu';
373
374
  const MIDPOINT_ROOT_BUTTON = 'midpointr_b';
375
+ const MAD_CITATION = 'Tria F, Landan G, Dagan T. Phylogenetic rooting using minimal ancestor deviation. Nature Ecology and Evolution. 2017;1:0193';
374
376
  const UNCOLLAPSE_ALL_BUTTON = 'uncollapse_all_b';
375
377
  const TREE_PREV_BUTTON = 'tree_prev_b';
376
378
  const TREE_NEXT_BUTTON = 'tree_next_b';
@@ -559,6 +561,7 @@ function (root, d3, forester, phyloXml) {
559
561
  const HPD_BAR_COLOR = 'rgba(70,130,220,0.35)'; // translucent blue, FigTree-like
560
562
  const FOSSIL_BAR_COLOR = 'rgba(150,100,55,0.86)'; // opaque-ish sepia
561
563
  let _timeInfo = null; // forester.timeAxisInfo, recomputed per render
564
+ let _timeTree = false; // forester.isTimeTree: never re-rooted
562
565
  let _clusterH = 0; // the cluster layout's vertical extent, set per render
563
566
  let _docListenersBound = false; // page-level key/wheel handlers bind once, not per launch
564
567
  let _docListeners = []; // ...and destroy() can take every one of them down again
@@ -1147,12 +1150,12 @@ function (root, d3, forester, phyloXml) {
1147
1150
  // on the desktop: dots in the search colours, so hits outside the
1148
1151
  // current viewport can be spotted and steered to. Search results
1149
1152
  // 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.
1153
+ // Displayed nodes only: a hit inside a collapsed clade has no drawn
1154
+ // position, so the clade gets the dot, in its wedge's mark colour.
1152
1155
  let hits = [];
1153
1156
  if (_root) {
1154
1157
  forEachDisplayed(function (n) {
1155
- let c = getFoundColor(n);
1158
+ let c = getFoundColor(n) || (isCollapsed(n) ? collapsedMarkColor(n) : null);
1156
1159
  if (c) {
1157
1160
  hits.push({node: n, color: c});
1158
1161
  }
@@ -1462,9 +1465,19 @@ function (root, d3, forester, phyloXml) {
1462
1465
  menu.appendChild(document.createElement('hr'));
1463
1466
  return;
1464
1467
  }
1468
+ if (item.note) { // a sentence to read before choosing, such as a warning
1469
+ let note = document.createElement('div');
1470
+ note.className = 'aptx-node-menu-note';
1471
+ note.textContent = item.note;
1472
+ menu.appendChild(note);
1473
+ return;
1474
+ }
1465
1475
  let b = document.createElement('button');
1466
1476
  b.type = 'button';
1467
1477
  b.textContent = item.label;
1478
+ if (item.title) {
1479
+ b.title = item.title;
1480
+ }
1468
1481
  if (item.danger) {
1469
1482
  b.className = 'aptx-menu-danger';
1470
1483
  }
@@ -1997,6 +2010,7 @@ function (root, d3, forester, phyloXml) {
1997
2010
  }
1998
2011
  });
1999
2012
  _vis.colorNext[vis.id] = next;
2013
+ vis.categoryOnScreen = new Set(vis.values); // what the scale may be asked about (visualizationColorFor)
2000
2014
  vis.categoryScale = d3.scaleOrdinal()
2001
2015
  .domain(vis.values)
2002
2016
  .range(vis.values.map(function (v) {
@@ -2758,13 +2772,16 @@ function (root, d3, forester, phyloXml) {
2758
2772
  }
2759
2773
  }
2760
2774
 
2761
- // The dim gate: only while a hit is actually DRAWN, so the tree never
2775
+ // The dim gate: only while a hit is actually SHOWN, so the tree never
2762
2776
  // washes out with nothing emphasised (a 0-hit search, or every hit
2763
- // hidden inside a collapsed clade / outside the displayed subtree).
2777
+ // outside the displayed subtree). A collapsed clade holding a hit
2778
+ // counts as shown: it stays bright, outlined and counting its hits,
2779
+ // so the rest fading points at it (Christian, 2026-09-14: "less
2780
+ // confusing to users" than no dimming at all).
2764
2781
  _dimNonMatches = false;
2765
2782
  if ((_foundNodes0 && _foundNodes0.size > 0) || (_foundNodes1 && _foundNodes1.size > 0)) {
2766
2783
  for (let i = 0, len = nodes.length; i !== len; ++i) {
2767
- if (isNodeFound(nodes[i])) {
2784
+ if (isNodeFound(nodes[i]) || (isCollapsed(nodes[i]) && collapsedFoundCounts(nodes[i]).found > 0)) {
2768
2785
  _dimNonMatches = true;
2769
2786
  break;
2770
2787
  }
@@ -2782,10 +2799,15 @@ function (root, d3, forester, phyloXml) {
2782
2799
  placeRootStub();
2783
2800
 
2784
2801
  if (_state.circularDisplay) {
2802
+ // the outermost drawn point sets the scale: a node, or the far
2803
+ // edge of a collapsed clade's wedge -- which reaches the clade's
2804
+ // farthest tip, hidden from nodes but not from the drawing, and
2805
+ // would otherwise poke through the ring the labels stand on
2785
2806
  let maxY = 0;
2786
2807
  for (let i = 0; i < nodes.length; ++i) {
2787
- if (nodes[i].y > maxY) {
2788
- maxY = nodes[i].y;
2808
+ let y = isCollapsed(nodes[i]) ? Math.max(nodes[i].y, collapsedReach(nodes[i])[1]) : nodes[i].y;
2809
+ if (y > maxY) {
2810
+ maxY = y;
2789
2811
  }
2790
2812
  }
2791
2813
  _radial = {
@@ -3153,9 +3175,14 @@ function (root, d3, forester, phyloXml) {
3153
3175
  // Dim Non-Matches: one opacity on the node GROUP dims its labels, dot,
3154
3176
  // shape and branch-data numbers together; the branch lines are separate
3155
3177
  // path.link elements and keep their full colour, as on the desktop.
3156
- // Hits and selected nodes (getFoundColor) are never dimmed.
3178
+ // Hits and selected nodes (getFoundColor) are never dimmed -- and
3179
+ // neither is a collapsed clade holding one: its wedge and label live in
3180
+ // the clade node's group, and the clade node itself is never a hit.
3157
3181
  node.style('opacity', function (d) {
3158
- return (_dimNonMatches && !getFoundColor(d)) ? DIM_NON_MATCH_OPACITY : null;
3182
+ if (!_dimNonMatches || getFoundColor(d) || (isCollapsed(d) && collapsedHoldsHighlight(d))) {
3183
+ return null;
3184
+ }
3185
+ return DIM_NON_MATCH_OPACITY;
3159
3186
  });
3160
3187
 
3161
3188
  let nodeUpdate = animateOrSet(node, transitionDuration)
@@ -3186,7 +3213,7 @@ function (root, d3, forester, phyloXml) {
3186
3213
  if (_state.showBranchLengthValues) {
3187
3214
  nodeChild('text.bllabel').text(makeBranchLengthLabel);
3188
3215
  }
3189
- if (_state.showConfidenceValues) {
3216
+ if (_state.showConfidenceValues || _state.showMadValues) {
3190
3217
  nodeChild('text.conflabel').text(makeConfidenceValuesLabel);
3191
3218
  }
3192
3219
  if (_state.showBranchEvents) {
@@ -3303,43 +3330,75 @@ function (root, d3, forester, phyloXml) {
3303
3330
  // _svgGroup, so that name put them in the way of selectAll('path.link')
3304
3331
  // -- the main link data-join, and the overview's miniature.
3305
3332
  _svgGroup.selectAll('g.aptx-align-ext').remove();
3306
- if (!radialDisplay() && _state.phylogram && _state.alignPhylogram && _state.showExternalLabels
3307
- && (_state.showNodeName || _state.showTaxonomy || _state.showSequence)) {
3333
+ let alignedRect = !radialDisplay() && _state.phylogram && _state.alignPhylogram;
3334
+ let tipGuides = alignedRect && _state.showExternalLabels
3335
+ && (_state.showNodeName || _state.showTaxonomy || _state.showSequence);
3336
+ // a collapsed clade's label always shows, so its guide does too: from
3337
+ // past the wedge's farthest tip to the label column
3338
+ let collapsedGuides = alignedRect ? nodes.filter(function (d) {
3339
+ return isCollapsed(d) && !(_state.dynahide && d.hide);
3340
+ }) : [];
3341
+ if (tipGuides || collapsedGuides.length > 0) {
3308
3342
  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)
3343
+ let guideStyle = function (sel) {
3344
+ return sel.attr('fill', 'none')
3345
+ .attr('stroke-width', 1)
3346
+ .attr('stroke', _state.branchColorDefault)
3347
+ .style('stroke-opacity', 0.25);
3348
+ };
3349
+ if (tipGuides) {
3350
+ guideStyle(ext.selectAll('path.aptx-tip-guide')
3351
+ .data(links.filter(function (d) {
3352
+ return (!d.target.children && !(_state.dynahide && d.target.hide));
3353
+ }))
3354
+ .enter().append('path').attr('class', 'aptx-tip-guide'))
3355
+ .attr('d', function (d) {
3356
+ return connection(d.target);
3357
+ });
3358
+ }
3359
+ guideStyle(ext.selectAll('path.aptx-collapsed-guide')
3360
+ .data(collapsedGuides)
3361
+ .enter().append('path').attr('class', 'aptx-collapsed-guide'))
3318
3362
  .attr('d', function (d) {
3319
- return connection(d.target);
3363
+ let x1 = collapsedReach(d)[1] + 5;
3364
+ return (_w - x1) > 5 ? 'M' + x1 + ',' + d.x + 'L' + _w + ',' + d.x : null;
3320
3365
  });
3321
3366
  }
3322
3367
 
3323
3368
  // circular: a thin dashed connector from each external node out to the
3324
3369
  // common label ring (so labels line up like iTOL's aligned display).
3325
3370
  _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]; });
3371
+ if (_state.circularDisplay) {
3372
+ // tips while their labels show; a collapsed clade always (its label
3373
+ // always shows), from its wedge's farthest reach out to the ring
3374
+ let connected = nodes.filter(function (d) {
3375
+ if (_state.dynahide && d.hide) {
3376
+ return false;
3377
+ }
3378
+ if (isCollapsed(d)) {
3379
+ return _radial.maxRad - radialRadius(collapsedReach(d)[1]) > 2;
3380
+ }
3381
+ return !d.children && _state.showExternalLabels;
3382
+ });
3383
+ let innerY = function (d) {
3384
+ return isCollapsed(d) ? collapsedReach(d)[1] : d.y;
3385
+ };
3386
+ if (connected.length > 0) {
3387
+ let conn = _svgGroup.insert('g', 'g').attr('class', 'aptx-radial-conn');
3388
+ conn.selectAll('line')
3389
+ .data(connected)
3390
+ .enter().append('line')
3391
+ .attr('stroke', _state.branchColorDefault)
3392
+ // width/opacity match the rectangular aligned extensions; the
3393
+ // dash alone marks these as connectors (0.5px at 0.3 vanished)
3394
+ .attr('stroke-width', 1)
3395
+ .style('stroke-opacity', 0.4)
3396
+ .style('stroke-dasharray', '2,3')
3397
+ .attr('x1', function (d) { return radialXY(d.x, innerY(d))[0]; })
3398
+ .attr('y1', function (d) { return radialXY(d.x, innerY(d))[1]; })
3399
+ .attr('x2', function (d) { return polarXY(radialAngle(d.x), _radial.maxRad)[0]; })
3400
+ .attr('y2', function (d) { return polarXY(radialAngle(d.x), _radial.maxRad)[1]; });
3401
+ }
3343
3402
  }
3344
3403
 
3345
3404
  for (let i = 0, len = nodes.length; i !== len; ++i) {
@@ -3553,6 +3612,17 @@ function (root, d3, forester, phyloXml) {
3553
3612
  if (colorModeOf(vis) === 'range') {
3554
3613
  return vis.rangeScale ? vis.rangeScale(Number(value)) : null;
3555
3614
  }
3615
+ // A value only tips hidden in a collapsed clade carry is not on
3616
+ // screen, so not in the scale's domain -- and a d3 ordinal scale asked
3617
+ // about an unknown value extends its domain and hands out an on-screen
3618
+ // value's colour. Such a value keeps its remembered colour instead
3619
+ // (assigned at launch over the whole tree, or the user's override):
3620
+ // the clade's wedge keeps the colour its tips wore. Christian,
3621
+ // 2026-09-14; reported by the desktop session, which follows.
3622
+ if (vis.categoryOnScreen && !vis.categoryOnScreen.has(value)) {
3623
+ let mem = _vis.colorMemory[vis.id];
3624
+ return (mem && mem[visMemoryKey(vis, value)]) || null;
3625
+ }
3556
3626
  return vis.categoryScale(value);
3557
3627
  }
3558
3628
 
@@ -3593,11 +3663,13 @@ function (root, d3, forester, phyloXml) {
3593
3663
  if (_root && ((_foundNodes0 && _foundNodes0.size > 0) || (_foundNodes1 && _foundNodes1.size > 0))) {
3594
3664
  // natural (top-to-bottom) drawing order -- forester's preorder
3595
3665
  // walks children in reverse, which made the NEXT arrow step upward
3666
+ // a collapsed clade holding hits is ONE stop, on its node: the hits
3667
+ // inside have no position of their own to step to
3596
3668
  let walk = function (n) {
3597
- if (isNodeFound(n)) {
3669
+ if (isNodeFound(n) || (isCollapsed(n) && collapsedFoundCounts(n).found > 0)) {
3598
3670
  hits.push(n);
3599
3671
  }
3600
- if (n.children && !isCollapsed(n)) { // a hit inside a collapsed clade has no position to step to
3672
+ if (n.children && !isCollapsed(n)) {
3601
3673
  for (let i = 0; i < n.children.length; ++i) {
3602
3674
  walk(n.children[i]);
3603
3675
  }
@@ -3752,7 +3824,7 @@ function (root, d3, forester, phyloXml) {
3752
3824
 
3753
3825
  function syncOptionalNodeChildren(node) {
3754
3826
  let wantBl = _state.showBranchLengthValues === true;
3755
- let wantConf = _state.showConfidenceValues === true;
3827
+ let wantConf = _state.showConfidenceValues === true || _state.showMadValues === true;
3756
3828
  let wantEvent = _state.showBranchEvents === true;
3757
3829
  let wantDot = _state.showSupportDots === true;
3758
3830
 
@@ -4021,39 +4093,32 @@ function (root, d3, forester, phyloXml) {
4021
4093
  }
4022
4094
  };
4023
4095
 
4096
+ // A branch's values as "MAD/support", as the desktop writes them: the MAD
4097
+ // value (from MAD rooting, to two decimals, 0 included -- low is good)
4098
+ // while MAD Values is on, then the support values while Confidence is on,
4099
+ // those only when one reaches the minimum. A MAD value is never support.
4024
4100
  let makeConfidenceValuesLabel = function (phynode) {
4025
- if (phynode.confidences && phynode.confidences.length > 0) {
4026
- let c = phynode.confidences;
4027
- let cl = c.length;
4028
- if (_state.minConfidenceValueToShow) {
4029
- let show = false;
4030
- for (let i = 0; i < cl; ++i) {
4031
- if (c[i].value >= _state.minConfidenceValueToShow) {
4032
- show = true;
4033
- break;
4034
- }
4035
- }
4036
- if (!show) {
4037
- return;
4038
- }
4101
+ if (!phynode.confidences || phynode.confidences.length === 0) {
4102
+ return;
4103
+ }
4104
+ let parts = [];
4105
+ let support = [];
4106
+ phynode.confidences.forEach(function (c) {
4107
+ if (c.type !== forester.MAD_CONFIDENCE_TYPE) {
4108
+ support.push(c);
4109
+ } else if (_state.showMadValues && typeof c.value === 'number' && isFinite(c.value)) {
4110
+ parts.push(+c.value.toFixed(2));
4039
4111
  }
4040
- if (cl === 1) {
4041
- if (c[0].value) {
4042
- return +c[0].value.toFixed(CONFIDENCE_VALUE_DIGITS_DEFAULT);
4043
- }
4044
- } else {
4045
- let s = "";
4046
- for (let ii = 0; ii < cl; ++ii) {
4047
- if (c[ii].value) {
4048
- if (ii > 0) {
4049
- s += "/";
4050
- }
4051
- s += +c[ii].value.toFixed(CONFIDENCE_VALUE_DIGITS_DEFAULT);
4052
- }
4112
+ });
4113
+ if (_state.showConfidenceValues && support.length > 0
4114
+ && (!_state.minConfidenceValueToShow || support.some(function (c) { return c.value >= _state.minConfidenceValueToShow; }))) {
4115
+ support.forEach(function (c) {
4116
+ if (c.value) {
4117
+ parts.push(+c.value.toFixed(CONFIDENCE_VALUE_DIGITS_DEFAULT));
4053
4118
  }
4054
- return s;
4055
- }
4119
+ });
4056
4120
  }
4121
+ return parts.length > 0 ? parts.join('/') : undefined;
4057
4122
  };
4058
4123
 
4059
4124
  let makeBranchEventsLabel = function (phynode) {
@@ -4114,12 +4179,23 @@ function (root, d3, forester, phyloXml) {
4114
4179
  return (branchWidth + SUPPORT_DOT_EXTRA_DIAMETER) / 2;
4115
4180
  }
4116
4181
 
4182
+ // A branch's first confidence that is support: a MAD value (MAD rooting)
4183
+ // rates a root position, not the clade.
4184
+ function firstSupportValue(d) {
4185
+ for (let i = 0; i < d.confidences.length; ++i) {
4186
+ if (d.confidences[i].type !== forester.MAD_CONFIDENCE_TYPE) {
4187
+ return d.confidences[i].value;
4188
+ }
4189
+ }
4190
+ return undefined;
4191
+ }
4192
+
4117
4193
  function showSupportDot(d) {
4118
4194
  if (!_state.showSupportDots || !d.parent
4119
4195
  || !d.confidences || d.confidences.length === 0) {
4120
4196
  return false;
4121
4197
  }
4122
- let v = d.confidences[0].value;
4198
+ let v = firstSupportValue(d);
4123
4199
  if (typeof v !== 'number' || !isFinite(v) || v < supportDotThreshold()) {
4124
4200
  return false;
4125
4201
  }
@@ -4704,6 +4780,7 @@ function (root, d3, forester, phyloXml) {
4704
4780
  // elements by forester.timeAxisInfo -- again unless the caller set
4705
4781
  // showTimeAxis explicitly.
4706
4782
  _timeInfo = _treeData ? forester.timeAxisInfo(forester.getTreeRoot(_treeData)) : null;
4783
+ _timeTree = _treeData ? forester.isTimeTree(_treeData) : false;
4707
4784
  if (_state.showTimeAxis === undefined) {
4708
4785
  _state.showTimeAxis = !!(_timeInfo && _timeInfo.type);
4709
4786
  }
@@ -5412,6 +5489,10 @@ function (root, d3, forester, phyloXml) {
5412
5489
  if (index === _treeIndex) {
5413
5490
  return makeViewerHandle();
5414
5491
  }
5492
+ // collapsing is part of the view, and a switched-to tree opens fresh:
5493
+ // nothing collapsed, now or when you come back (Christian, 2026-09-14)
5494
+ clearCollapsedFlags(_trees[_treeIndex]);
5495
+ clearCollapsedFlags(_trees[index]);
5415
5496
  return launchInto(_container, _trees, index, _launchConfig);
5416
5497
  }
5417
5498
 
@@ -5571,134 +5652,21 @@ function (root, d3, forester, phyloXml) {
5571
5652
  update();
5572
5653
  }
5573
5654
 
5655
+ // The tips under the node as a tab-separated table with a header,
5656
+ // the desktop's columns (forester.externalNodeDataTable), top to
5657
+ // bottom as drawn; it reads back through a metadata-table join.
5574
5658
  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
5659
  const ext_nodes = forester.getAllExternalNodes(node).reverse();
5592
-
5593
5660
  let filename;
5594
5661
  if (ext_nodes.length === 1 && ext_nodes[0].name) {
5595
- filename = 'External_Node_Data_for_Node_' + ext_nodes[0].name.replace(/\W/g, '_') + '.txt';
5662
+ filename = 'External_Node_Data_for_Node_' + ext_nodes[0].name.replace(/\W/g, '_') + '.tsv';
5596
5663
  } else {
5597
- filename = 'External_Node_Data_for_' + ext_nodes.length + '_Nodes.txt';
5598
- }
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
- }
5664
+ filename = 'External_Node_Data_for_' + ext_nodes.length + '_Nodes.tsv';
5698
5665
  }
5699
-
5700
- saveAs(new Blob([text_all], {type: "application/txt"}), filename);
5701
-
5666
+ const tsv = forester.externalNodeDataTsv(ext_nodes, function (n, i) {
5667
+ return (n.viewId !== undefined) ? n.viewId : i + 1;
5668
+ });
5669
+ saveAs(new Blob([tsv], {type: 'text/tab-separated-values'}), filename);
5702
5670
  update();
5703
5671
  }
5704
5672
 
@@ -5927,9 +5895,25 @@ function (root, d3, forester, phyloXml) {
5927
5895
  update(null, 0);
5928
5896
  }});
5929
5897
  }
5930
- if (!_in_subtree && d.parent && d.parent.parent
5931
- && ((_treeData.rerootable === undefined) || (_treeData.rerootable === true))) {
5932
- items.push({label: 'Reroot', action: function () { forester.reRoot(tree, d, -1); zoomToFit(); }});
5898
+ if (!_in_subtree && d.parent && d.parent.parent) {
5899
+ let blocked = rerootBlockedReason();
5900
+ if (blocked) {
5901
+ // offered but greyed, saying why, as the desktop does
5902
+ items.push({label: 'Reroot', disabled: true, title: blocked, action: function () {
5903
+ }});
5904
+ } else {
5905
+ items.push({label: 'Reroot', action: function () {
5906
+ confirmRerooting(tree, 'node', d, event, function () {
5907
+ rerootKeepingCollapse(function () {
5908
+ forester.removeMadConfidences(tree); // they rate the MAD rooting only
5909
+ forester.reRoot(tree, d, -1);
5910
+ });
5911
+ _basicTreeProperties = forester.collectBasicTreeProperties(_root);
5912
+ syncMadValuesCheckbox();
5913
+ zoomToFit();
5914
+ });
5915
+ }});
5916
+ }
5933
5917
  }
5934
5918
  if (_settings.enableManualNodeSelection) {
5935
5919
  items.push({label: 'Select/Deselect Node', action: function () { selectDeselectNode(d); }});
@@ -6848,6 +6832,7 @@ function (root, d3, forester, phyloXml) {
6848
6832
  }
6849
6833
  d.collapsed = !d.collapsed;
6850
6834
  calcMaxExtLabel();
6835
+ refreshVisualizations(false); // the legends describe the tips on screen
6851
6836
  scheduleUpdate(null, 0);
6852
6837
  }
6853
6838
 
@@ -6856,6 +6841,7 @@ function (root, d3, forester, phyloXml) {
6856
6841
  n.collapsed = false;
6857
6842
  });
6858
6843
  calcMaxExtLabel();
6844
+ refreshVisualizations(false);
6859
6845
  scheduleUpdate(null, 0);
6860
6846
  }
6861
6847
 
@@ -6871,6 +6857,47 @@ function (root, d3, forester, phyloXml) {
6871
6857
  });
6872
6858
  }
6873
6859
 
6860
+ // Runs a re-rooting without turning a collapsed clade inside out. A clade
6861
+ // on the path to the new root has its parent turned into a child, so its
6862
+ // flag would go on hiding a different set of tips -- with a midpoint
6863
+ // inside a collapsed clade, the whole rest of the tree. Such a clade
6864
+ // opens; every other clade keeps its tips and stays collapsed.
6865
+ function rerootKeepingCollapse(reroot) {
6866
+ let flagged = [];
6867
+ forester.preOrderTraversalAll(_root_const || _root, function (n) {
6868
+ if (n.collapsed === true && n.children) {
6869
+ flagged.push({node: n, tips: forester.getAllExternalNodes(n)});
6870
+ }
6871
+ });
6872
+ reroot();
6873
+ let opened = false;
6874
+ flagged.forEach(function (f) {
6875
+ let now = f.node.children ? forester.getAllExternalNodes(f.node) : [];
6876
+ let kept = new Set(f.tips);
6877
+ if (now.length !== f.tips.length || !now.every(function (t) { return kept.has(t); })) {
6878
+ f.node.collapsed = false;
6879
+ opened = true;
6880
+ }
6881
+ });
6882
+ if (opened) {
6883
+ calcMaxExtLabel();
6884
+ refreshVisualizations(false);
6885
+ }
6886
+ }
6887
+
6888
+ // What a collapsed clade is marked with for the tips it hides: the colour
6889
+ // of its first search hit, else the selection colour when a tip is
6890
+ // selected, else null. The wedge, the overview dot and the dimming all
6891
+ // read it, so they always agree.
6892
+ function collapsedMarkColor(d) {
6893
+ let tips = forester.getAllExternalNodes(d);
6894
+ let hit = tips.filter(isNodeFound)[0];
6895
+ if (hit) {
6896
+ return getFoundColor(hit);
6897
+ }
6898
+ return tips.some(function (t) { return _selectedNodes.has(t); }) ? _state.selectedColorDefault : null;
6899
+ }
6900
+
6874
6901
  // How tall a collapsed clade draws, in rows: 1 for a pair, growing with
6875
6902
  // the logarithm of the tip count, capped so a huge clade does not eat
6876
6903
  // the screen. The cluster layout's separation turns it into space.
@@ -6912,6 +6939,24 @@ function (root, d3, forester, phyloXml) {
6912
6939
  return prefix.length >= 2 ? prefix : '';
6913
6940
  }
6914
6941
 
6942
+ // Whether any tip hidden in a collapsed clade is a hit or selected -- the
6943
+ // clade then stays undimmed, as that tip would be if it were drawn.
6944
+ function collapsedHoldsHighlight(d) {
6945
+ return collapsedMarkColor(d) !== null;
6946
+ }
6947
+
6948
+ // Whether every tip a collapsed clade hides is marked: all of them search
6949
+ // hits when the clade holds any hit, else all of them selected. Such a
6950
+ // wedge is filled in the mark colour and its label set in it, bold.
6951
+ function collapsedFullyMarked(d) {
6952
+ let hits = collapsedFoundCounts(d);
6953
+ if (hits.found > 0) {
6954
+ return hits.found === hits.total;
6955
+ }
6956
+ let tips = forester.getAllExternalNodes(d);
6957
+ return tips.length > 0 && tips.every(function (t) { return _selectedNodes.has(t); });
6958
+ }
6959
+
6915
6960
  function collapsedFoundCounts(d) {
6916
6961
  let found = 0;
6917
6962
  let total = 0;
@@ -6959,26 +7004,28 @@ function (root, d3, forester, phyloXml) {
6959
7004
  return best || _state.branchColorDefault;
6960
7005
  }
6961
7006
 
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) {
7007
+ // The wedge's reach: the clade's nearest and farthest tips, in the
7008
+ // layout's x. One edge runs to the nearest tip and the other to the
7009
+ // farthest, so the shape itself shows how uneven the clade's branch
7010
+ // lengths are (as iTOL draws it). Christian chose this over the
7011
+ // desktop's symmetric triangle on an average base (2026-09-14). A
7012
+ // phylogram measures the tips; a cladogram, where every leaf sits on
7013
+ // the tip column, gives the wedge one depth step.
7014
+ function collapsedReach(d) {
6968
7015
  if (_state.phylogram && _yScale) {
6969
- let sum = 0;
6970
- let n = 0;
7016
+ let min = Infinity;
7017
+ let max = -Infinity;
6971
7018
  forester.preOrderTraversal(d, function (t) {
6972
7019
  if (!t.children && typeof t.distToRoot === 'number') {
6973
- sum += t.distToRoot;
6974
- ++n;
7020
+ if (t.distToRoot < min) { min = t.distToRoot; }
7021
+ if (t.distToRoot > max) { max = t.distToRoot; }
6975
7022
  }
6976
7023
  });
6977
- if (n > 0) {
6978
- return Math.max(d.y + 2, _yScale(sum / n));
7024
+ if (isFinite(min)) {
7025
+ return [Math.max(d.y + 2, _yScale(min)), Math.max(d.y + 2, _yScale(max))];
6979
7026
  }
6980
7027
  }
6981
- return d.y + _cladogramUnit;
7028
+ return [d.y + _cladogramUnit, d.y + _cladogramUnit];
6982
7029
  }
6983
7030
 
6984
7031
  let _cladogramUnit = 0; // one depth step of the last cladogram layout, px
@@ -7001,52 +7048,71 @@ function (root, d3, forester, phyloXml) {
7001
7048
  let fs = _state.externalNodeFontSize;
7002
7049
  wedge.each(function (d) {
7003
7050
  let color = collapsedColor(d);
7004
- let hits = collapsedFoundCounts(d);
7005
- let foundColor = hits.found > 0 ? getFoundColor(forester.getAllExternalNodes(d).filter(isNodeFound)[0]) : null;
7051
+ let mark = collapsedMarkColor(d);
7052
+ let full = collapsedFullyMarked(d);
7006
7053
  let h = Math.max(6, collapsedRows(d) * _rowUnit * 0.82);
7007
- let base = collapsedBase(d);
7054
+ let reach = collapsedReach(d);
7008
7055
  let path;
7009
7056
  if (radialDisplay()) {
7010
7057
  // 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';
7058
+ // group's origin, already at the node's radius) -- distances,
7059
+ // never absolute radii, or the wedge lands a whole radius
7060
+ // further out than its label
7061
+ let r0 = radialRadius(d.y);
7062
+ let dr1 = radialRadius(reach[0]) - r0;
7063
+ let dr2 = radialRadius(reach[1]) - r0;
7064
+ path = 'M0,0 L' + dr1 + ',' + (-h / 2) + ' L' + dr2 + ',' + (h / 2) + ' Z';
7016
7065
  } else {
7017
- let dx = base - d.y;
7018
- path = 'M0,0 L' + dx + ',' + (-h / 2) + ' L' + dx + ',' + (h / 2) + ' Z';
7066
+ let dx1 = reach[0] - d.y;
7067
+ let dx2 = reach[1] - d.y;
7068
+ path = 'M0,0 L' + dx1 + ',' + (-h / 2) + ' L' + dx2 + ',' + (h / 2) + ' Z';
7019
7069
  }
7020
7070
  d3.select(this)
7021
7071
  .attr('d', path)
7022
7072
  .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)
7073
+ .style('fill', full ? mark : color)
7074
+ .style('fill-opacity', full ? 0.45 : 0.22)
7075
+ .style('stroke', mark || color)
7076
+ .style('stroke-width', mark ? 1.5 : 1)
7027
7077
  .style('stroke-opacity', 0.9)
7028
7078
  .style('stroke-linejoin', 'round');
7029
7079
  });
7030
7080
  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;
7081
+ let far = collapsedReach(d)[1]; // the label clears the farthest tip
7082
+ let allFound = collapsedFullyMarked(d);
7083
+ let ink = allFound ? collapsedMarkColor(d) : _state.labelColorDefault;
7035
7084
  let t = d3.select(this)
7036
7085
  .text(collapsedLabel(d))
7037
7086
  .style('font', (allFound ? '600 ' : '') + fs + 'px ' + FONT_DEFAULTS)
7038
7087
  .style('fill', ink)
7039
7088
  .style('pointer-events', 'none');
7040
- if (radialDisplay()) {
7041
- let r = radialRadius(base) - radialRadius(d.y) + _state.nodeLabelGap;
7042
- let flip = labelFlip(d);
7089
+ // placed exactly as a tip's label is (update's text.extlabel): on
7090
+ // the common ring in circular, rotated along the spoke or upright;
7091
+ // on the label column in the aligned phylogram; else just past the
7092
+ // wedge, where a tip's label sits past its tip
7093
+ let gap = _state.nodeLabelGap;
7094
+ let flip = radialDisplay() && labelFlip(d);
7095
+ if (_state.circularDisplay && _radial) {
7096
+ t.attr('text-anchor', flip ? 'end' : 'start')
7097
+ .attr('x', flip ? -gap : gap).attr('dy', '0.32em');
7098
+ if (_radialLabelsHorizontal) {
7099
+ let p = radialXY(d.x, d.y);
7100
+ let q = polarXY(radialAngle(d.x), _radial.maxRad);
7101
+ t.attr('transform', 'translate(' + (q[0] - p[0]) + ',' + (q[1] - p[1]) + ')');
7102
+ } else {
7103
+ let off = _radial.maxRad - radialRadius(d.y);
7104
+ t.attr('transform', 'rotate(' + labelAngleDeg(d) + ') translate(' + off + ',0)' + (flip ? ' rotate(180)' : ''));
7105
+ }
7106
+ } else if (radialDisplay()) {
7107
+ let r = radialRadius(far) - radialRadius(d.y) + gap;
7043
7108
  t.attr('transform', 'rotate(' + labelAngleDeg(d) + ') translate(' + r + ',0)' + (flip ? ' rotate(180)' : ''))
7044
7109
  .attr('text-anchor', flip ? 'end' : 'start')
7045
7110
  .attr('x', 0).attr('dy', '0.32em');
7046
7111
  } else {
7112
+ let column = (_state.phylogram && _state.alignPhylogram) ? _w : far;
7047
7113
  t.attr('transform', null)
7048
7114
  .attr('text-anchor', 'start')
7049
- .attr('x', base - d.y + _state.nodeLabelGap)
7115
+ .attr('x', column - d.y + gap)
7050
7116
  .attr('dy', (0.3 * fs) + 'px');
7051
7117
  }
7052
7118
  });
@@ -7069,6 +7135,7 @@ function (root, d3, forester, phyloXml) {
7069
7135
  ['taxonomy', TAXONOMY_CB, 'showTaxonomy'],
7070
7136
  ['sequence', SEQUENCE_CB, 'showSequence'],
7071
7137
  ['confidence', CONFIDENCE_VALUES_CB, 'showConfidenceValues'],
7138
+ ['madValues', MAD_VALUES_CB, 'showMadValues'],
7072
7139
  ['branchLength', BRANCH_LENGTH_VALUES_CB, 'showBranchLengthValues'],
7073
7140
  ['external', EXTERNAL_LABEL_CB, 'showExternalLabels'],
7074
7141
  ['internal', INTERNAL_LABEL_CB, 'showInternalLabels'],
@@ -7234,10 +7301,9 @@ function (root, d3, forester, phyloXml) {
7234
7301
  launchInto(_container, _trees, s.tree, cfg);
7235
7302
  return;
7236
7303
  }
7237
- if (s.root === 'midpoint' && _viewOps.root !== 'midpoint'
7238
- && (_treeData.rerootable === undefined || _treeData.rerootable === true)) {
7239
- forester.midpointRoot(_root_const);
7240
- _viewOps.root = 'midpoint';
7304
+ if ((s.root === 'midpoint' || s.root === 'mad') && _viewOps.root !== s.root
7305
+ && rerootingAllowed()) {
7306
+ rootTreeBy(s.root);
7241
7307
  }
7242
7308
  if (s.order === 'asc' || s.order === 'desc') {
7243
7309
  ladderizeSubtree(_root_const, s.order === 'asc', false);
@@ -7270,6 +7336,9 @@ function (root, d3, forester, phyloXml) {
7270
7336
  if (s.layout === 'rectangular' || s.layout === 'circular' || s.layout === 'unrooted') {
7271
7337
  _state.circularDisplay = s.layout === 'circular';
7272
7338
  _state.unrootedDisplay = s.layout === 'unrooted';
7339
+ if (_treeData && _treeData.rooted === false && byId(SEARCH_FIELD_SELECT_0)) {
7340
+ populateSearchMenus(); // before the view's searches pick their fields
7341
+ }
7273
7342
  }
7274
7343
  if (s.display === 'phylogram' || s.display === 'aligned' || s.display === 'cladogram') {
7275
7344
  let measured = _basicTreeProperties.branchLengths === true;
@@ -8762,11 +8831,86 @@ function (root, d3, forester, phyloXml) {
8762
8831
  }
8763
8832
  }
8764
8833
 
8765
- // Midpoint re-rooting rearranges the whole tree and its button is easy to
8766
- // hit by accident, so it asks first -- through the same little popup the
8767
- // node menu uses (click anywhere else or press Esc to cancel).
8834
+ // Why the tree cannot be re-rooted, or null when it can. Every way of
8835
+ // re-rooting asks -- the re-root button and its menu, the node menu's
8836
+ // Reroot, a shared view's root: never a tree its file marks
8837
+ // rerootable="false", never a time tree. The reason is the greyed
8838
+ // controls' tooltip, as on the desktop.
8839
+ function rerootBlockedReason() {
8840
+ if (!_treeData) {
8841
+ return 'no tree';
8842
+ }
8843
+ if (_treeData.rerootable === false) {
8844
+ return 'This tree is marked as not re-rootable (rerootable="false").';
8845
+ }
8846
+ if (_timeTree) {
8847
+ return 'Time trees can\'t be re-rooted: their branch lengths are times measured from this root.';
8848
+ }
8849
+ return null;
8850
+ }
8851
+
8852
+ function rerootingAllowed() {
8853
+ return rerootBlockedReason() === null;
8854
+ }
8855
+
8856
+ // MAD rooting needs branch lengths, three tips, and a tree that may be
8857
+ // re-rooted at all.
8858
+ function madRootingPossible() {
8859
+ return rerootingAllowed()
8860
+ && !!_basicTreeProperties && !!_basicTreeProperties.branchLengths
8861
+ && _basicTreeProperties.externalNodesCount >= 3;
8862
+ }
8863
+
8864
+ // Roots the whole tree by 'mad' or 'midpoint', from the re-root menu or a
8865
+ // shared view. MAD values rate the MAD rooting only, so any other rooting
8866
+ // removes them.
8867
+ function rootTreeBy(method) {
8868
+ let rooted = true;
8869
+ rerootKeepingCollapse(function () {
8870
+ if (method === 'mad') {
8871
+ rooted = forester.madRoot(_root_const);
8872
+ } else {
8873
+ forester.removeMadConfidences(_root_const);
8874
+ forester.midpointRoot(_root_const);
8875
+ }
8876
+ });
8877
+ if (rooted) {
8878
+ _viewOps.root = method; // what a shared view replays
8879
+ }
8880
+ _basicTreeProperties = forester.collectBasicTreeProperties(_root_const);
8881
+ syncMadValuesCheckbox();
8882
+ }
8883
+
8884
+ // Before a re-root from the panel or the node menu (a shared view never
8885
+ // asks: whoever shared it chose): when it would change the clade of
8886
+ // internal nodes carrying data, say so where the choice was made, and
8887
+ // re-root only on "Re-root". Otherwise re-root at once.
8888
+ function confirmRerooting(phy, method, node, anchor, proceed) {
8889
+ let effect = forester.cladesChangedByRerooting(phy, method, node);
8890
+ let k = effect.changed.length;
8891
+ if (k === 0) {
8892
+ proceed();
8893
+ return;
8894
+ }
8895
+ let n = effect.annotated.length;
8896
+ let text = (n === 1)
8897
+ ? 'This tree has data on 1 internal node. Re-rooting changes its clade, so its data may no longer describe it.'
8898
+ : 'This tree has data on ' + n + ' internal nodes. Re-rooting changes the clade of ' + k + ' of them, so '
8899
+ + (k === 1 ? 'its data may no longer describe it.' : 'their data may no longer describe them.');
8900
+ showNodeMenu([
8901
+ {note: text},
8902
+ {label: 'Re-root', action: proceed},
8903
+ {label: 'Cancel', action: function () {
8904
+ }}
8905
+ ], anchor, 'Re-root tree');
8906
+ }
8907
+
8908
+ // Re-rooting rearranges the whole tree and its button is easy to hit by
8909
+ // accident, so it asks first -- through the same little popup the node
8910
+ // menu uses (click anywhere else or press Esc to cancel) -- and the popup
8911
+ // is where the method is picked.
8768
8912
  function midpointRootButtonPressed(event) {
8769
- if (!_in_subtree && _root && ((_treeData.rerootable === undefined) || (_treeData.rerootable === true))) {
8913
+ if (!_in_subtree && _root && rerootingAllowed()) {
8770
8914
  let ev = event;
8771
8915
  if (!ev || ev.pageX === undefined || (ev.pageX === 0 && ev.pageY === 0)) {
8772
8916
  // keyboard/synthetic invocation: anchor the popup at the button
@@ -8776,19 +8920,30 @@ function (root, d3, forester, phyloXml) {
8776
8920
  ev = {pageX: window.scrollX + r.right + 4, pageY: window.scrollY + r.top};
8777
8921
  }
8778
8922
  }
8779
- showNodeMenu([
8780
- {
8781
- label: 'Midpoint re-root', action: function () {
8782
- forester.midpointRoot(_root);
8783
- _viewOps.root = 'midpoint'; // what a shared view replays
8784
- zoomToFit();
8785
- }
8786
- },
8787
- {
8788
- label: 'Cancel', action: function () {
8923
+ let items = [];
8924
+ if (madRootingPossible()) {
8925
+ items.push({
8926
+ label: 'MAD re-root (Tria et al., 2017)', title: MAD_CITATION, action: function () {
8927
+ confirmRerooting(_root_const, 'mad', null, ev, function () {
8928
+ rootTreeBy('mad');
8929
+ zoomToFit();
8930
+ });
8789
8931
  }
8932
+ });
8933
+ }
8934
+ items.push({
8935
+ label: 'Midpoint re-root', action: function () {
8936
+ confirmRerooting(_root_const, 'midpoint', null, ev, function () {
8937
+ rootTreeBy('midpoint');
8938
+ zoomToFit();
8939
+ });
8790
8940
  }
8791
- ], ev, 'midpoint re-root the tree?');
8941
+ });
8942
+ items.push({
8943
+ label: 'Cancel', action: function () {
8944
+ }
8945
+ });
8946
+ showNodeMenu(items, ev, 're-root the tree?');
8792
8947
  }
8793
8948
  }
8794
8949
 
@@ -8993,7 +9148,11 @@ function (root, d3, forester, phyloXml) {
8993
9148
  // then set up each box's mode menu. Called when a tree is (re)loaded.
8994
9149
  function populateSearchMenus() {
8995
9150
  let before = _searchFields;
8996
- _searchFields = forester.availableSearchFields(_root);
9151
+ // an unrooted tree in the unrooted layout has no root to measure from
9152
+ let rootMeasured = [forester.searchFields.depth, forester.searchFields.distanceFromRoot, forester.searchFields.cladeSize];
9153
+ _searchFields = forester.availableSearchFields(_root).filter(function (f) {
9154
+ return !unrootedContext() || rootMeasured.indexOf(f) < 0;
9155
+ });
8997
9156
  [SEARCH_FIELD_SELECT_0, SEARCH_FIELD_SELECT_1].forEach(function (selId) {
8998
9157
  let sel = byId(selId);
8999
9158
  if (!sel) return;
@@ -9328,6 +9487,11 @@ function (root, d3, forester, phyloXml) {
9328
9487
  function layoutButtonClicked() {
9329
9488
  _state.circularDisplay = getCheckboxValue(LAYOUT_CIRC_BUTTON);
9330
9489
  _state.unrootedDisplay = getCheckboxValue(LAYOUT_UNROOTED_BUTTON);
9490
+ if (_treeData && _treeData.rooted === false) {
9491
+ populateSearchMenus(); // the root-measured search fields go and come with the unrooted layout
9492
+ search0();
9493
+ search1();
9494
+ }
9331
9495
  if (radialDisplay() && _state.showDomainArchitectures && _basicTreeProperties.domainArchitectures) {
9332
9496
  // the domain boxes ride the tips' spokes, so the labels must too
9333
9497
  _radialLabelsHorizontal = false;
@@ -9488,6 +9652,31 @@ function (root, d3, forester, phyloXml) {
9488
9652
  scheduleUpdate();
9489
9653
  }
9490
9654
 
9655
+ function madValuesCbClicked() {
9656
+ _state.showMadValues = getCheckboxValue(MAD_VALUES_CB);
9657
+ scheduleUpdate();
9658
+ }
9659
+
9660
+ // The MAD Values checkbox is built for every tree MAD rooting could root
9661
+ // and shows while the tree carries MAD values: after MAD rooting, or from
9662
+ // a file saved after it. Any other rooting removes the values, and the
9663
+ // checkbox goes with them. Inside a subtree the whole tree's state stands.
9664
+ function syncMadValuesCheckbox() {
9665
+ let cb = byId(MAD_VALUES_CB);
9666
+ if (!cb || _in_subtree) {
9667
+ return;
9668
+ }
9669
+ let present = !!(_basicTreeProperties && _basicTreeProperties.madValues);
9670
+ if (!present) {
9671
+ _state.showMadValues = false;
9672
+ }
9673
+ setCheckboxValue(MAD_VALUES_CB, _state.showMadValues === true);
9674
+ let item = cb.closest('label');
9675
+ if (item) {
9676
+ item.style.display = present ? '' : 'none';
9677
+ }
9678
+ }
9679
+
9491
9680
  function branchLengthsCbClicked() {
9492
9681
  _state.showBranchLengthValues = getCheckboxValue(BRANCH_LENGTH_VALUES_CB);
9493
9682
  scheduleUpdate();
@@ -10208,6 +10397,7 @@ function (root, d3, forester, phyloXml) {
10208
10397
  + '.aptx-node-menu button.aptx-menu-danger:hover, .aptx-node-menu button.aptx-menu-danger:focus-visible {'
10209
10398
  + ' background:#e5484d; color:#fff; }'
10210
10399
  + '.aptx-node-menu hr { border:0; border-top:1px solid var(--p-line); margin:3px 4px; }'
10400
+ + '.aptx-node-menu-note { padding:4px 9px 7px; color:var(--p-ink); white-space:normal; }'
10211
10401
  // The search suggestions: the node menu's chrome under the value box.
10212
10402
  + '.aptx-suggest { position:absolute; z-index:1000; max-width:320px; padding:4px; box-sizing:border-box;'
10213
10403
  + ' border:1px solid var(--p-line-strong); border-radius:10px; background:var(--p-bg); color:var(--p-ink);'
@@ -10690,6 +10880,32 @@ function (root, d3, forester, phyloXml) {
10690
10880
  // Content arrives as "Label: value" lines separated by <br>. Setting the
10691
10881
  // label part apart makes a wall of such lines scannable. Lines without a
10692
10882
  // label (a heading like "Taxonomy", or a FASTA sequence) are left alone.
10883
+ // The unrooted layout of a tree its file declares unrooted (phyloXML
10884
+ // rooted="false", Nexus [&U]; a plain Newick tree declares nothing). The
10885
+ // unrooted layout of a rooted tree still has its root.
10886
+ function unrootedContext() {
10887
+ return _state.unrootedDisplay === true && !!_treeData && _treeData.rooted === false;
10888
+ }
10889
+
10890
+ // The tips on each side of an internal node of an unrooted tree, one
10891
+ // count per neighbour, smallest first: what "tips below" becomes when
10892
+ // there is no below.
10893
+ function tipsAround(d) {
10894
+ let sides = d.children.map(function (c) {
10895
+ return forester.calcSumOfAllExternalDescendants(c);
10896
+ });
10897
+ let below = sides.reduce(function (a, b) {
10898
+ return a + b;
10899
+ }, 0);
10900
+ let all = forester.calcSumOfAllExternalDescendants(forester.getTreeRoot(_treeData));
10901
+ if (all > below) {
10902
+ sides.push(all - below);
10903
+ }
10904
+ return sides.sort(function (a, b) {
10905
+ return a - b;
10906
+ });
10907
+ }
10908
+
10693
10909
  // The node's data as the hover tooltip and the "Display Node Data" dialog
10694
10910
  // both show it -- ONE builder, because they used to be two near-copies
10695
10911
  // and carried the same two bugs twice (a bare "Date: " line whenever a
@@ -10703,13 +10919,19 @@ function (root, d3, forester, phyloXml) {
10703
10919
  // the section above it. markUpDataLabels() renders the result: a line
10704
10920
  // shaped "Key: value" is a row, anything else a heading, and a leading
10705
10921
  // "- " marks a row as a section's sub-entry.
10922
+ //
10923
+ // A tree its file declares unrooted, shown in the unrooted layout, has no
10924
+ // root to measure from (unrootedContext): an internal node shows no
10925
+ // distance to parent, depth or tips below but the tips on each of its
10926
+ // sides, and a tip's branch is just its branch length -- as on the desktop.
10706
10927
  function nodeDataText(d) {
10707
10928
  let text = '';
10929
+ let unrooted = unrootedContext();
10708
10930
  if (d.name) {
10709
10931
  text += 'Name: ' + d.name + '<br>';
10710
10932
  }
10711
- if (d.branch_length) {
10712
- text += 'Distance to parent: ' + d.branch_length + '<br>';
10933
+ if (d.branch_length && !(unrooted && d.children)) {
10934
+ text += (unrooted ? 'Branch length: ' : 'Distance to parent: ') + d.branch_length + '<br>';
10713
10935
  }
10714
10936
  let date = dateText(d.date);
10715
10937
  if (date) {
@@ -10723,9 +10945,13 @@ function (root, d3, forester, phyloXml) {
10723
10945
  }
10724
10946
  }
10725
10947
  }
10726
- text += 'Depth: ' + forester.calcDepth(d) + '<br>';
10727
- if (d.children) {
10728
- text += 'Tips below: ' + forester.calcSumOfAllExternalDescendants(d) + '<br>';
10948
+ if (!unrooted) {
10949
+ text += 'Depth: ' + forester.calcDepth(d) + '<br>';
10950
+ if (d.children) {
10951
+ text += 'Tips below: ' + forester.calcSumOfAllExternalDescendants(d) + '<br>';
10952
+ }
10953
+ } else if (d.children) {
10954
+ text += 'Tips around: ' + tipsAround(d).join(' · ') + '<br>';
10729
10955
  }
10730
10956
  if (d.confidences) {
10731
10957
  for (let i = 0; i < d.confidences.length; ++i) {
@@ -11318,6 +11544,7 @@ function (root, d3, forester, phyloXml) {
11318
11544
 
11319
11545
  on(CONFIDENCE_VALUES_CB, 'click', confidenceValuesCbClicked);
11320
11546
  on(SUPPORT_DOTS_CB, 'click', supportDotsCbClicked);
11547
+ on(MAD_VALUES_CB, 'click', madValuesCbClicked);
11321
11548
  on(SEARCH_B_TOGGLE, 'click', revealSearchB);
11322
11549
  on(SEARCH_NAV_PREV, 'click', function () {
11323
11550
  stepToFoundNode(-1);
@@ -11826,6 +12053,9 @@ function (root, d3, forester, phyloXml) {
11826
12053
  if (_basicTreeProperties.confidences) {
11827
12054
  labels.push(makeCheckboxItem('Confidence', CONFIDENCE_VALUES_CB, 'to show/hide confidence values'));
11828
12055
  }
12056
+ if (_basicTreeProperties.madValues || madRootingPossible()) {
12057
+ labels.push(makeCheckboxItem('MAD Values', MAD_VALUES_CB, 'to show/hide MAD values: the ancestor deviation were the root on that branch (lower is better, the root branch has the smallest)'));
12058
+ }
11829
12059
  if (_basicTreeProperties.branchLengths) {
11830
12060
  labels.push(makeCheckboxItem('Branch Length', BRANCH_LENGTH_VALUES_CB, 'to show/hide branch length values'));
11831
12061
  }
@@ -11914,7 +12144,7 @@ function (root, d3, forester, phyloXml) {
11914
12144
  h = h.concat(makeGlyphButton('whole_tree', RETURN_TO_SUPERTREE_BUTTON, 'return all the way to the complete tree (if in a sub-tree)'));
11915
12145
  h = h.concat(makeGlyphButton('up_one_level', RETURN_TO_SUPERTREE_BUTTON_BY_ONE, 'move up by one level towards the complete tree (if in a sub-tree)'));
11916
12146
  h = h.concat(makeGlyphButton('uncollapse_all', UNCOLLAPSE_ALL_BUTTON, 'uncollapse all'));
11917
- h = h.concat(makeGlyphButton('midpoint', MIDPOINT_ROOT_BUTTON, 'midpoint re-root'));
12147
+ h = h.concat(makeGlyphButton('midpoint', MIDPOINT_ROOT_BUTTON, 're-root the tree: MAD or midpoint'));
11918
12148
  h = h.concat('</div>');
11919
12149
  h = h.concat('</fieldset>');
11920
12150
  return h;
@@ -12149,6 +12379,7 @@ function (root, d3, forester, phyloXml) {
12149
12379
  setCheckboxValue(SEQUENCE_CB, _state.showSequence)
12150
12380
  setCheckboxValue(CONFIDENCE_VALUES_CB, _state.showConfidenceValues);
12151
12381
  setCheckboxValue(SUPPORT_DOTS_CB, _state.showSupportDots);
12382
+ syncMadValuesCheckbox();
12152
12383
  setCheckboxValue(BRANCH_LENGTH_VALUES_CB, _state.showBranchLengthValues);
12153
12384
  setCheckboxValue(NODE_EVENTS_CB, _state.showNodeEvents);
12154
12385
  setCheckboxValue(BRANCH_EVENTS_CB, _state.showBranchEvents);
@@ -12267,10 +12498,16 @@ function (root, d3, forester, phyloXml) {
12267
12498
  disableButton(byId(RETURN_TO_SUPERTREE_BUTTON));
12268
12499
  }
12269
12500
 
12270
- if (!_in_subtree && ((_treeData.rerootable === undefined) || (_treeData.rerootable === true))) {
12271
- enableButton(byId(MIDPOINT_ROOT_BUTTON));
12501
+ let rerootButton = byId(MIDPOINT_ROOT_BUTTON);
12502
+ let blocked = rerootBlockedReason();
12503
+ if (!_in_subtree && !blocked) {
12504
+ enableButton(rerootButton);
12272
12505
  } else {
12273
- disableButton(byId(MIDPOINT_ROOT_BUTTON));
12506
+ disableButton(rerootButton);
12507
+ }
12508
+ if (rerootButton) {
12509
+ rerootButton.title = blocked
12510
+ || (_in_subtree ? 'return to the whole tree to re-root it' : 're-root the tree: MAD or midpoint');
12274
12511
  }
12275
12512
  let b;
12276
12513
  if (_foundNodes0 && !_searchBox0Empty) {