archaeopteryx 3.6.0 → 3.7.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/README.md CHANGED
@@ -322,6 +322,38 @@ a Newick or Nexus download never puts one where a support value goes.
322
322
  gene families. *PLOS ONE*, 15(5), e0232950.
323
323
  <https://journals.plos.org/plosone/article?id=10.1371/journal.pone.0232950>
324
324
 
325
+ ## Representative tips
326
+
327
+ A large tree often holds many near-identical tips. The tool row's
328
+ **representative tips** button thins them out the way the desktop
329
+ Archaeopteryx's Tools → Select Representative Tips does: the tips are grouped
330
+ into the largest clades whose tips are all close to each other, and one tip of
331
+ each group is kept. Distance is patristic, the sum of the branch lengths on
332
+ the path between two tips; in a tree without branch lengths it counts edges.
333
+
334
+ The dialog takes either a **distance cutoff**, and no two tips of a group are
335
+ then farther apart than it, or a **target number** of representatives, for
336
+ which the cutoff giving the nearest count the tree allows is used (a tie keeps
337
+ more tips; the count reached is reported). A distance cutoff needs branch
338
+ lengths. The tip kept is the group's **most central** one (the medoid, with
339
+ the smallest summed distance to its group-mates) or its **most divergent** one
340
+ (the longest terminal branch). Tips that are selected or found by a search can
341
+ be kept whatever their group: they stand in for their group's representative,
342
+ so more tips than the target may stay.
343
+
344
+ The tips kept show as search A's hits, with its colour, hit count and
345
+ step-through, and a summary offers **Create tree**: a tree of only those tips,
346
+ added to the tree picker and opened drawn as the tree was (phylogram, aligned
347
+ or cladogram). It is named after the tree and the count, as in
348
+ `mammals_233reps`, and its description says how it was made. An internal node
349
+ left with one child is replaced by that child, whose branch gains the node's
350
+ length; the new root keeps the original root's own branch length. The original
351
+ tree is not changed.
352
+
353
+ The grouping and the tips kept are the desktop's: they match its own results
354
+ on 129 generated trees in 5,666 settings (`test/fixtures/rep-contract.tsv`,
355
+ made by running the desktop's code).
356
+
325
357
  ## Metadata tables
326
358
 
327
359
  A tree file rarely carries everything known about its tips. A **metadata
package/archaeopteryx.js CHANGED
@@ -20,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.6.0
23
+ // v 3.7.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.6.0';
106
+ const VERSION = '3.7.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';
@@ -380,6 +380,8 @@ function (root, d3, forester, phyloXml) {
380
380
  const INTERNAL_LABEL_CB = 'intl_cb';
381
381
  const LABEL_COLOR_SELECT_MENU = 'lcs_menu';
382
382
  const MIDPOINT_ROOT_BUTTON = 'midpointr_b';
383
+ const REPRESENTATIVES_BUTTON = 'reps_b';
384
+ const REPRESENTATIVES_DIALOG = 'reps_dialog';
383
385
  const MAD_CITATION = 'Tria F, Landan G, Dagan T. Phylogenetic rooting using minimal ancestor deviation. Nature Ecology and Evolution. 2017;1:0193';
384
386
  const UNCOLLAPSE_ALL_BUTTON = 'uncollapse_all_b';
385
387
  const TREE_PREV_BUTTON = 'tree_prev_b';
@@ -5724,6 +5726,11 @@ function (root, d3, forester, phyloXml) {
5724
5726
  _baseSvg = d3.select(containerEl).append('svg')
5725
5727
  .attr('width', _displayWidth)
5726
5728
  .attr('height', _displayHeight)
5729
+ // an inline svg sits on a text baseline, and the room left
5730
+ // under it for descenders makes a container sized to the
5731
+ // svg a few pixels taller than the svg itself -- enough to
5732
+ // make a full-window page scroll
5733
+ .style('display', 'block')
5727
5734
  .call(_zoomListener)
5728
5735
  // d3.zoom binds its own double-click-to-zoom, which scales the
5729
5736
  // picture -- fonts and all -- and so behaves like the wheel
@@ -9434,6 +9441,297 @@ function (root, d3, forester, phyloXml) {
9434
9441
  }
9435
9442
  }
9436
9443
 
9444
+ // ---- representative tips ------------------------------------------------
9445
+ //
9446
+ // The desktop's Tools > Select Representative Tips, from the tool row: a
9447
+ // dialog takes a distance cutoff or a target count, the representative to
9448
+ // keep and whether the selected and found tips stay; the tips kept show
9449
+ // as search A's hits, and can be cut out into a new tree in the tree
9450
+ // picker (the desktop's new tab). The choosing is
9451
+ // forester.selectRepresentativeTips, held to the desktop's own results by
9452
+ // test/fixtures/rep-contract.tsv.
9453
+
9454
+ // the dialog's inputs, remembered while the page is open (the desktop
9455
+ // remembers them for its session)
9456
+ let _repsInputs = {byCutoff: true, cutoff: '0.05', target: '100', pickIndex: 0};
9457
+
9458
+ // what the viewer writes onto a tree's nodes, left off a tree cut out of it
9459
+ const VIEW_NODE_FIELDS = ['viewId', 'collapsed', 'x', 'y', 'x0', 'y0', 'id', 'hide', 'hasVis', 'distToRoot',
9460
+ 'ux', 'uy', 'uangle', '_style', '_suppDot', '_extLabelText', '_eventText', '_confText', '_blText'];
9461
+
9462
+ // the whole tree's tip count, counted again only when the tree changes
9463
+ // (every change makes new _basicTreeProperties)
9464
+ let _repsTipCount = {properties: null, count: 0};
9465
+
9466
+ function wholeTreeTipCount() {
9467
+ if (_repsTipCount.properties !== _basicTreeProperties) {
9468
+ _repsTipCount = {
9469
+ properties: _basicTreeProperties,
9470
+ count: _root_const ? forester.getAllExternalNodes(_root_const).length : 0
9471
+ };
9472
+ }
9473
+ return _repsTipCount.count;
9474
+ }
9475
+
9476
+ // why representative tips cannot be selected now, or null
9477
+ function representativesBlockedReason() {
9478
+ if (_in_subtree) {
9479
+ return 'return to the whole tree to select representative tips';
9480
+ }
9481
+ if (!_root_const || wholeTreeTipCount() < 3) {
9482
+ return 'the tree needs at least three tips to select representatives';
9483
+ }
9484
+ return null;
9485
+ }
9486
+
9487
+ function repsElement(parent, tag, className, text) {
9488
+ let e = document.createElement(tag);
9489
+ if (className) {
9490
+ e.className = className;
9491
+ }
9492
+ if (text !== undefined) {
9493
+ e.textContent = text;
9494
+ }
9495
+ parent.appendChild(e);
9496
+ return e;
9497
+ }
9498
+
9499
+ function repsButton(parent, label, primary, action) {
9500
+ let b = repsElement(parent, 'button', 'aptx-reps-button' + (primary ? ' aptx-reps-primary' : ''), label);
9501
+ b.type = 'button';
9502
+ b.addEventListener('click', action);
9503
+ return b;
9504
+ }
9505
+
9506
+ // Java's Double.parseDouble and Integer.parseInt, which read the desktop's
9507
+ // fields: null for what they refuse
9508
+ function javaParseDouble(s) {
9509
+ let m = /^([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)[fFdD]?$/.exec(s);
9510
+ if (m) {
9511
+ return Number(m[1]);
9512
+ }
9513
+ return /^[+-]?(?:Infinity|NaN)$/.test(s) ? Number(s) : null;
9514
+ }
9515
+
9516
+ function javaParseInt(s) {
9517
+ if (!/^[+-]?\d+$/.test(s)) {
9518
+ return null;
9519
+ }
9520
+ let v = Number(s);
9521
+ return (v < -2147483648 || v > 2147483647) ? null : v;
9522
+ }
9523
+
9524
+ function representativesButtonPressed() {
9525
+ if (representativesBlockedReason()) {
9526
+ return;
9527
+ }
9528
+ // the selected and found tips can be kept; taken now, before the
9529
+ // result replaces search A's hits
9530
+ let protectable = [];
9531
+ let seen = new Set();
9532
+ [_selectedNodes, _foundNodes0, _foundNodes1].forEach(function (nodes) {
9533
+ (nodes || []).forEach(function (n) {
9534
+ if ((!n.children || n.children.length === 0) && !seen.has(n)) {
9535
+ seen.add(n);
9536
+ protectable.push(n);
9537
+ }
9538
+ });
9539
+ });
9540
+ showRepresentativesDialog(_treeData, forester.hasUsableBranchLengths(_treeData), protectable);
9541
+ }
9542
+
9543
+ // The desktop's input dialog, its texts and checks included; a distance
9544
+ // cutoff needs branch lengths.
9545
+ function showRepresentativesDialog(phy, hasLengths, protectable) {
9546
+ let shell = makeDialogShell(REPRESENTATIVES_DIALOG, 'Select representative tips', 330);
9547
+ let body = shell.body;
9548
+ body.classList.add('aptx-reps');
9549
+ let startByCutoff = hasLengths && _repsInputs.byCutoff;
9550
+
9551
+ repsElement(body, 'div', 'aptx-reps-lead', 'Select one representative per group of similar tips:');
9552
+ let choice = function (type, label, checked, disabled) {
9553
+ let row = repsElement(body, 'label', 'aptx-reps-choice' + (disabled ? ' aptx-reps-disabled' : ''));
9554
+ let input = repsElement(row, 'input');
9555
+ input.type = type;
9556
+ input.name = REPRESENTATIVES_DIALOG + '_' + type;
9557
+ input.checked = checked;
9558
+ input.disabled = disabled;
9559
+ repsElement(row, 'span', null, label);
9560
+ return input;
9561
+ };
9562
+ let byCutoff = choice('radio', 'By distance cutoff', startByCutoff, !hasLengths);
9563
+ let byTarget = choice('radio', 'By target number of representatives', !startByCutoff, false);
9564
+ if (!hasLengths) {
9565
+ repsElement(body, 'div', 'aptx-reps-note',
9566
+ 'This tree has no branch lengths, so only "target number" is available.');
9567
+ }
9568
+
9569
+ repsElement(body, 'div', 'aptx-dialog-head', 'Value');
9570
+ let value = repsElement(body, 'input', 'aptx-reps-input');
9571
+ value.type = 'text';
9572
+ value.spellcheck = false;
9573
+ value.autocomplete = 'off';
9574
+ value.value = startByCutoff ? _repsInputs.cutoff : _repsInputs.target;
9575
+ let hint = repsElement(body, 'div', 'aptx-reps-note');
9576
+
9577
+ repsElement(body, 'div', 'aptx-dialog-head', 'Representative');
9578
+ let pick = repsElement(body, 'select', 'aptx-reps-input');
9579
+ ['Most central (medoid)', 'Most divergent (longest branch)'].forEach(function (label) {
9580
+ repsElement(pick, 'option', null, label);
9581
+ });
9582
+ pick.selectedIndex = _repsInputs.pickIndex;
9583
+
9584
+ let n = protectable.length;
9585
+ let protect = choice('checkbox', n > 0
9586
+ ? 'Keep the ' + n + ' selected/found ' + (n === 1 ? 'tip' : 'tips') + ' (never drop them)'
9587
+ : 'Keep selected/found tips (none are selected)', n > 0, n === 0);
9588
+ protect.parentNode.classList.add('aptx-reps-protect');
9589
+
9590
+ let error = repsElement(body, 'div', 'aptx-reps-error');
9591
+ let sync = function () {
9592
+ hint.textContent = byCutoff.checked
9593
+ ? 'Maximum distance between any two tips kept in the same group.'
9594
+ : 'Approximate number of representative tips to keep.';
9595
+ error.textContent = '';
9596
+ };
9597
+ byCutoff.addEventListener('change', function () {
9598
+ value.value = _repsInputs.cutoff;
9599
+ sync();
9600
+ });
9601
+ byTarget.addEventListener('change', function () {
9602
+ value.value = _repsInputs.target;
9603
+ sync();
9604
+ });
9605
+ sync();
9606
+
9607
+ let submit = function () {
9608
+ let text = value.value.trim();
9609
+ let cutoff = 0;
9610
+ let target = 0;
9611
+ if (byCutoff.checked) {
9612
+ cutoff = javaParseDouble(text);
9613
+ if (cutoff === null) {
9614
+ error.textContent = 'Please enter a numeric distance cutoff (for example 0.05).';
9615
+ return;
9616
+ }
9617
+ if (!isFinite(cutoff) || cutoff <= 0) {
9618
+ error.textContent = 'The distance cutoff must be a positive number.';
9619
+ return;
9620
+ }
9621
+ } else {
9622
+ target = javaParseInt(text);
9623
+ if (target === null) {
9624
+ error.textContent = 'Please enter a whole number of representatives (for example 100).';
9625
+ return;
9626
+ }
9627
+ if (target < 1) {
9628
+ error.textContent = 'The target number of representatives must be at least 1.';
9629
+ return;
9630
+ }
9631
+ }
9632
+ // a tree without branch lengths forced the target: that is not a preference
9633
+ if (hasLengths) {
9634
+ _repsInputs.byCutoff = byCutoff.checked;
9635
+ }
9636
+ if (byCutoff.checked) {
9637
+ _repsInputs.cutoff = text;
9638
+ } else {
9639
+ _repsInputs.target = text;
9640
+ }
9641
+ _repsInputs.pickIndex = pick.selectedIndex;
9642
+ shell.dialog.close();
9643
+
9644
+ let options = {
9645
+ pick: pick.selectedIndex === 1 ? forester.REPRESENTATIVE_LONGEST_BRANCH : forester.REPRESENTATIVE_MEDOID,
9646
+ protectedTips: protect.checked ? protectable : []
9647
+ };
9648
+ if (byCutoff.checked) {
9649
+ options.cutoff = cutoff;
9650
+ } else {
9651
+ options.target = target;
9652
+ }
9653
+ let result = forester.selectRepresentativeTips(phy, options);
9654
+ showRepresentativesAsHits(result);
9655
+ showRepresentativesResult(phy, result, byCutoff.checked, cutoff, target, options.pick);
9656
+ };
9657
+ value.addEventListener('keydown', function (event) {
9658
+ if (event.key === 'Enter') {
9659
+ event.preventDefault();
9660
+ submit();
9661
+ }
9662
+ });
9663
+
9664
+ let actions = repsElement(body, 'div', 'aptx-reps-actions');
9665
+ repsButton(actions, 'Cancel', false, function () {
9666
+ shell.dialog.close();
9667
+ });
9668
+ repsButton(actions, 'Select', true, submit);
9669
+ shell.dialog.showModal();
9670
+ value.focus();
9671
+ value.select();
9672
+ }
9673
+
9674
+ // The tips kept, as search A's hits -- the desktop shows them as its
9675
+ // found nodes: A's colour, the hit count, the step-through. The next
9676
+ // search replaces them, and resetting search A clears them.
9677
+ function showRepresentativesAsHits(result) {
9678
+ _foundNodes0 = new Set(result.keptTips);
9679
+ _searchBox0Empty = false;
9680
+ _searchHitIndex = -1;
9681
+ scheduleUpdate(null, 0, true);
9682
+ updateSearchHitNavigation();
9683
+ }
9684
+
9685
+ // The desktop's summary, and its offer of a new tree of the tips kept.
9686
+ function showRepresentativesResult(phy, result, byCutoff, cutoff, target, pick) {
9687
+ let shell = makeDialogShell(REPRESENTATIVES_DIALOG, 'Representative tips selected', 360);
9688
+ shell.body.classList.add('aptx-reps');
9689
+ result.summary.split('\n').forEach(function (line) {
9690
+ repsElement(shell.body, 'div', 'aptx-reps-line', line);
9691
+ });
9692
+ let n = result.keptCount;
9693
+ repsElement(shell.body, 'div', 'aptx-reps-question',
9694
+ 'Create a new tree containing only these ' + n + (n === 1 ? ' tip?' : ' tips?'));
9695
+ let actions = repsElement(shell.body, 'div', 'aptx-reps-actions');
9696
+ repsButton(actions, 'Close', false, function () {
9697
+ shell.dialog.close();
9698
+ });
9699
+ let create = repsButton(actions, 'Create tree', true, function () {
9700
+ shell.dialog.close();
9701
+ openRepresentativeTree(phy, result, byCutoff, cutoff, target, pick);
9702
+ });
9703
+ shell.dialog.showModal();
9704
+ create.focus();
9705
+ }
9706
+
9707
+ // The tips kept, cut out into a new tree at the end of the tree picker,
9708
+ // which opens at once: named <parent>_Nreps, its description saying how
9709
+ // it was made, drawn as this tree is (phylogram, aligned or cladogram),
9710
+ // as the desktop's derived tab. This tree is left as it is.
9711
+ function openRepresentativeTree(phy, result, byCutoff, cutoff, target, pick) {
9712
+ let copy = forester.copyTreeKeepingTips(phy, result.keptTips);
9713
+ forester.preOrderTraversalAll(copy, function (n) {
9714
+ VIEW_NODE_FIELDS.forEach(function (f) {
9715
+ delete n[f];
9716
+ });
9717
+ });
9718
+ forester.addParents(copy);
9719
+ let parentName = (typeof phy.name === 'string' && phy.name.length > 0) ? phy.name : null;
9720
+ copy.name = forester.representativeTreeName(parentName, result.keptCount);
9721
+ let provenance = forester.representativeTreeDescription(byCutoff, cutoff, target, pick, result.keptCount,
9722
+ forester.stripShortExtension(parentName), result.tipCount);
9723
+ copy.description = copy.description ? copy.description + ' ' + provenance : provenance;
9724
+
9725
+ let display = _state.phylogram ? (_state.alignPhylogram ? 'aligned' : 'phylogram') : 'cladogram';
9726
+ clearCollapsedFlags(_trees[_treeIndex]);
9727
+ _trees.push(copy);
9728
+ let launchConfig = _launchConfig;
9729
+ let cfg = Object.assign({}, launchConfig || {});
9730
+ cfg.view = {display: display};
9731
+ launchInto(_container, _trees, _trees.length - 1, cfg);
9732
+ _launchConfig = launchConfig; // later switches open each tree its own way
9733
+ }
9734
+
9437
9735
  function escPressed() {
9438
9736
  if (_in_subtree) {
9439
9737
  _root = _root_const;
@@ -10765,6 +11063,16 @@ function (root, d3, forester, phyloXml) {
10765
11063
  + glyphDot(50, 50, 11);
10766
11064
  }
10767
11065
 
11066
+ // Representative tips: two clades of two tips, one tip of each kept --
11067
+ // the dot. The desktop has it only as a menu item, so it is drawn to
11068
+ // match the midpoint glyph's weight.
11069
+ function glyphRepresentatives() {
11070
+ return '<path d="M10,30 V70 M10,30 H38 M10,70 H38 M38,16 V44 M38,56 V84'
11071
+ + ' M38,16 H66 M38,44 H66 M38,56 H66 M38,84 H66"/>'
11072
+ + glyphDot(84, 16, 10)
11073
+ + glyphDot(84, 84, 10);
11074
+ }
11075
+
10768
11076
  // Build one glyph as an inline <svg>. Stroke weight, caps and joins follow
10769
11077
  // the desktop class each glyph came from.
10770
11078
  function makeGlyph(kind) {
@@ -10789,6 +11097,7 @@ function (root, d3, forester, phyloXml) {
10789
11097
  case 'ladderize_asc': sw = 8; cap = 'round'; body = glyphLadderize(true); break;
10790
11098
  case 'ladderize_desc': sw = 8; cap = 'round'; body = glyphLadderize(false); break;
10791
11099
  case 'midpoint': sw = 8; cap = 'round'; body = glyphMidpoint(); break;
11100
+ case 'representatives': sw = 8; cap = 'round'; body = glyphRepresentatives(); break;
10792
11101
  case 'uncollapse_all': sw = 8.5; cap = 'round'; body = glyphUncollapseAll(); break;
10793
11102
  case 'tree_prev': sw = 9; cap = 'round'; body = glyphChevron(false); break;
10794
11103
  case 'tree_next': sw = 9; cap = 'round'; body = glyphChevron(true); break;
@@ -10980,6 +11289,29 @@ function (root, d3, forester, phyloXml) {
10980
11289
  + '.aptx-keys { display:inline-flex; gap:3px; flex-wrap:wrap; }'
10981
11290
  + '.aptx-kbd { font-family:inherit; font-size:10.5px; line-height:1; padding:3px 5px; min-width:18px; text-align:center; color:var(--p-ink); background:var(--p-surface2); border:1px solid var(--p-line-strong); border-bottom-width:2px; border-radius:4px; }'
10982
11291
  + '.aptx-shortcuts-note { margin:8px 0 0; font-size:10.5px; line-height:1.4; color:var(--p-muted); }'
11292
+ // representative tips: the input dialog and its result
11293
+ + '.aptx-reps { line-height:1.35; }'
11294
+ + '.aptx-reps-lead { margin:0 0 4px; }'
11295
+ + '.aptx-reps-choice { display:flex; align-items:center; gap:6px; padding:2px 0; cursor:pointer; }'
11296
+ + '.aptx-reps-choice input { accent-color:var(--p-accent); width:13px; height:13px; margin:0; flex:none; }'
11297
+ + '.aptx-reps-disabled { color:var(--p-faint); cursor:default; }'
11298
+ + '.aptx-reps-input { display:block; box-sizing:border-box; width:100%; font:inherit; color:var(--p-ink);'
11299
+ + ' background:var(--p-surface2); border:1px solid var(--p-line-strong); border-radius:6px; padding:4px 7px; }'
11300
+ + '.aptx-reps-input:focus { outline:none; border-color:var(--p-accent); box-shadow:0 0 0 3px var(--p-accent-weak); }'
11301
+ + '.aptx-reps-note { margin-top:3px; font-size:10.5px; color:var(--p-muted); }'
11302
+ + '.aptx-reps-protect { margin-top:10px; }'
11303
+ + '.aptx-reps-error { margin-top:6px; font-size:10.5px; color:#e5484d; }'
11304
+ + '.aptx-reps-error:empty { display:none; }'
11305
+ + '.aptx-reps-line { padding:1px 0; }'
11306
+ + '.aptx-reps-question { margin-top:9px; font-weight:600; }'
11307
+ + '.aptx-reps-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:12px; }'
11308
+ + '.aptx-reps-button { font:inherit; font-size:11px; height:26px; padding:0 12px; color:var(--p-ink);'
11309
+ + ' background:var(--p-surface2); border:1px solid var(--p-line-strong); border-radius:6px; cursor:pointer;'
11310
+ + ' transition:background .12s,border-color .12s,color .12s; }'
11311
+ + '.aptx-reps-button:hover,.aptx-reps-primary { background:var(--p-accent-weak); border-color:var(--p-accent);'
11312
+ + ' color:var(--p-accent-ink); }'
11313
+ + '.aptx-reps-primary { font-weight:600; }'
11314
+ + '.aptx-reps-button:focus-visible { outline:none; box-shadow:0 0 0 3px var(--p-accent-weak); }'
10983
11315
  + '.aptx-dialog-mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Courier New",monospace;'
10984
11316
  + ' font-size:11px; white-space:pre-wrap; overflow-wrap:anywhere; }'
10985
11317
  // The hover tooltip, on the same palette.
@@ -11118,7 +11450,9 @@ function (root, d3, forester, phyloXml) {
11118
11450
  + '.aptx-panel .aptx-slider-row input[type=range] { flex:1 1 auto; min-width:0; margin:0; }'
11119
11451
  + '.aptx-panel .aptx-linkbtn { background:none; border:0; padding:1px 0 2px; margin:0; font:inherit; font-size:10px; color:var(--p-accent); cursor:pointer; display:block; }'
11120
11452
  + '.aptx-panel .aptx-linkbtn:hover { text-decoration:underline; }'
11121
- + '.aptx-panel .aptx-toolrow { margin-top:5px; }'
11453
+ // one row, its buttons sharing it evenly, its edges on the zoom rows'
11454
+ + '.aptx-panel .aptx-toolrow { margin-top:5px; display:flex; gap:3px; }'
11455
+ + '.aptx-panel .aptx-toolrow .aptx-gbtn { flex:1 1 0; min-width:0; padding:0; margin:2px 0; }'
11122
11456
  + '.aptx-panel .aptx-searchnav { align-items:center; gap:4px; margin:2px 0 4px; }'
11123
11457
  + '.aptx-panel .aptx-searchnav span { flex:1 1 auto; text-align:center; font-weight:600; font-size:11px; color:var(--p-ink); }'
11124
11458
  + '.aptx-panel .aptx-searchnav .aptx-gbtn:last-child { margin-right:0; }'
@@ -12203,6 +12537,8 @@ function (root, d3, forester, phyloXml) {
12203
12537
 
12204
12538
  on(MIDPOINT_ROOT_BUTTON, 'click', midpointRootButtonPressed);
12205
12539
 
12540
+ on(REPRESENTATIVES_BUTTON, 'click', representativesButtonPressed);
12541
+
12206
12542
  // Search Controls
12207
12543
  // ---------------
12208
12544
 
@@ -12651,6 +12987,7 @@ function (root, d3, forester, phyloXml) {
12651
12987
  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)'));
12652
12988
  h = h.concat(makeGlyphButton('uncollapse_all', UNCOLLAPSE_ALL_BUTTON, 'uncollapse all'));
12653
12989
  h = h.concat(makeGlyphButton('midpoint', MIDPOINT_ROOT_BUTTON, 're-root the tree: MAD or midpoint'));
12990
+ h = h.concat(makeGlyphButton('representatives', REPRESENTATIVES_BUTTON, 'select representative tips: one per group of close tips'));
12654
12991
  h = h.concat('</div>');
12655
12992
  h = h.concat('</fieldset>');
12656
12993
  return h;
@@ -13015,6 +13352,16 @@ function (root, d3, forester, phyloXml) {
13015
13352
  rerootButton.title = blocked
13016
13353
  || (_in_subtree ? 'return to the whole tree to re-root it' : 're-root the tree: MAD or midpoint');
13017
13354
  }
13355
+ let repsButton = byId(REPRESENTATIVES_BUTTON);
13356
+ if (repsButton) {
13357
+ let repsBlocked = representativesBlockedReason();
13358
+ if (repsBlocked) {
13359
+ disableButton(repsButton);
13360
+ } else {
13361
+ enableButton(repsButton);
13362
+ }
13363
+ repsButton.title = repsBlocked || 'select representative tips: one per group of close tips';
13364
+ }
13018
13365
  let b;
13019
13366
  if (_foundNodes0 && !_searchBox0Empty) {
13020
13367
  b = byId(RESET_SEARCH_A_BTN);
@@ -13290,6 +13637,22 @@ function (root, d3, forester, phyloXml) {
13290
13637
  holder.style.top = '0';
13291
13638
  holder.appendChild(el);
13292
13639
  document.body.appendChild(holder);
13640
+ // jsPDF's built-in faces come in two weights only: svg2pdf passes any
13641
+ // other (the legend titles' 600) on as a style Helvetica lacks, and
13642
+ // jsPDF then falls back to Times -- snap every weight to one of the two.
13643
+ // Those faces also encode only WinAnsi, where '≤' and '≥' come out as
13644
+ // spaced-out garbage, so the ones the viewer draws itself are spelled out.
13645
+ el.querySelectorAll('text, tspan').forEach(function (t) {
13646
+ let weight = parseInt(window.getComputedStyle(t).fontWeight, 10);
13647
+ if (weight !== 400 && weight !== 700) {
13648
+ t.style.fontWeight = weight >= 600 ? 'bold' : 'normal';
13649
+ }
13650
+ t.childNodes.forEach(function (c) {
13651
+ if (c.nodeType === Node.TEXT_NODE && /[≤≥]/.test(c.nodeValue)) {
13652
+ c.nodeValue = c.nodeValue.replace(/≤/g, '<=').replace(/≥/g, '>=');
13653
+ }
13654
+ });
13655
+ });
13293
13656
  let svgEl = treeSvgElement();
13294
13657
  let w = (svgEl && svgEl.width.baseVal.value) || _displayWidth;
13295
13658
  let h = (svgEl && svgEl.height.baseVal.value) || _displayHeight;
package/forester.js CHANGED
@@ -20,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.6.0
23
+ // v 3.7.0
24
24
  // 2026-09-10
25
25
  //
26
26
  // forester.js is a general suite for dealing with phylogenetic trees.
@@ -781,6 +781,557 @@
781
781
  });
782
782
  };
783
783
 
784
+ // ---- representative tips ------------------------------------------------
785
+ //
786
+ // Tree-based dereplication, the desktop's "Select Representative Tips"
787
+ // (RepresentativeTipSelector) ported step for step, the order of its sums
788
+ // and its 1e-9 tolerance included: test/fixtures/rep-contract.tsv holds
789
+ // the desktop's own results and extractions (RepContract.java), and the
790
+ // tests hold ours to them.
791
+ //
792
+ // Tips are grouped into the maximal clades whose diameter -- the largest
793
+ // patristic distance between two of their tips -- is at most a cutoff, and
794
+ // each group keeps one representative. A clade's diameter only grows
795
+ // rootward, so the groups are one cut through the tree. Without branch
796
+ // lengths the distance is topological, one unit per edge.
797
+
798
+ forester.REPRESENTATIVE_MEDOID = 'medoid';
799
+ forester.REPRESENTATIVE_LONGEST_BRANCH = 'longest_branch';
800
+
801
+ const REPRESENTATIVE_EPS = 1e-9;
802
+
803
+ function repIsTip(n) {
804
+ return !n.children || n.children.length === 0;
805
+ }
806
+
807
+ // below `top`, parents before children, children in their order
808
+ function repPreorder(top) {
809
+ let out = [];
810
+ let stack = [top];
811
+ while (stack.length > 0) {
812
+ let n = stack.pop();
813
+ out.push(n);
814
+ if (n.children) {
815
+ for (let i = n.children.length - 1; i >= 0; --i) {
816
+ stack.push(n.children[i]);
817
+ }
818
+ }
819
+ }
820
+ return out;
821
+ }
822
+
823
+ // the branch above a node, a missing or negative length counting 0
824
+ function repEdge(n, topological) {
825
+ if (topological) {
826
+ return 1;
827
+ }
828
+ let d = n.branch_length;
829
+ return (typeof d === 'number' && d > 0) ? d : 0;
830
+ }
831
+
832
+ /**
833
+ * Whether any branch of the tree carries a length (0 included). Without
834
+ * one, representative tips are chosen by topological distance, and a
835
+ * distance cutoff means nothing.
836
+ *
837
+ * @param phy the tree
838
+ * @returns {boolean}
839
+ */
840
+ forester.hasUsableBranchLengths = function (phy) {
841
+ let root = forester.getTreeRoot(phy);
842
+ return repPreorder(root).some(function (n) {
843
+ return n !== root && typeof n.branch_length === 'number';
844
+ });
845
+ };
846
+
847
+ // every node's diameter, in one pass from the tips up
848
+ function repDiameters(pre, topological) {
849
+ let height = new Map();
850
+ let diameter = new Map();
851
+ for (let i = pre.length - 1; i >= 0; --i) {
852
+ let n = pre[i];
853
+ if (repIsTip(n)) {
854
+ height.set(n, 0);
855
+ diameter.set(n, 0);
856
+ continue;
857
+ }
858
+ let best1 = 0; // the longest reach down through one child
859
+ let best2 = 0; // through another
860
+ let maxChildDiameter = 0;
861
+ for (let c = 0; c < n.children.length; ++c) {
862
+ let child = n.children[c];
863
+ let reach = height.get(child) + repEdge(child, topological);
864
+ if (reach >= best1) {
865
+ best2 = best1;
866
+ best1 = reach;
867
+ } else if (reach > best2) {
868
+ best2 = reach;
869
+ }
870
+ maxChildDiameter = Math.max(maxChildDiameter, diameter.get(child));
871
+ }
872
+ height.set(n, best1);
873
+ diameter.set(n, Math.max(maxChildDiameter, n.children.length >= 2 ? best1 + best2 : 0));
874
+ }
875
+ return diameter;
876
+ }
877
+
878
+ // the groups' clades: the highest nodes whose diameter is within the cutoff
879
+ function repGroupRoots(root, diameter, cutoff) {
880
+ let roots = [];
881
+ let stack = [root];
882
+ while (stack.length > 0) {
883
+ let n = stack.pop();
884
+ if (repIsTip(n) || diameter.get(n) <= cutoff + REPRESENTATIVE_EPS) {
885
+ roots.push(n);
886
+ } else {
887
+ for (let i = 0; i < n.children.length; ++i) {
888
+ stack.push(n.children[i]);
889
+ }
890
+ }
891
+ }
892
+ return roots;
893
+ }
894
+
895
+ // The cutoff whose group count comes closest to the target: the count
896
+ // changes only at clade diameters and never grows with the cutoff, so a
897
+ // binary search finds the smallest diameter giving at most the target,
898
+ // and its neighbour below gives more. A tie keeps more representatives;
899
+ // -1 stands for "below every diameter", every tip its own group.
900
+ function repCutoffForTarget(pre, root, diameter, target, tipCount) {
901
+ let values = [];
902
+ pre.forEach(function (n) {
903
+ if (!repIsTip(n)) {
904
+ values.push(diameter.get(n));
905
+ }
906
+ });
907
+ values.sort(function (a, b) { return a - b; });
908
+ let cand = [];
909
+ values.forEach(function (v) {
910
+ if (cand.length === 0 || v > cand[cand.length - 1] + REPRESENTATIVE_EPS) {
911
+ cand.push(v);
912
+ }
913
+ });
914
+ let count = function (cutoff) {
915
+ return repGroupRoots(root, diameter, cutoff).length;
916
+ };
917
+ let lo = 0;
918
+ let hi = cand.length - 1;
919
+ let boundary = cand.length - 1;
920
+ while (lo <= hi) {
921
+ let mid = (lo + hi) >>> 1;
922
+ if (count(cand[mid]) <= target) {
923
+ boundary = mid;
924
+ hi = mid - 1;
925
+ } else {
926
+ lo = mid + 1;
927
+ }
928
+ }
929
+ let tHigh = cand[boundary];
930
+ let cHigh = count(tHigh);
931
+ let tLow = boundary > 0 ? cand[boundary - 1] : -1;
932
+ let cLow = boundary > 0 ? count(tLow) : tipCount;
933
+ return (Math.abs(cLow - target) <= Math.abs(cHigh - target)) ? tLow : tHigh;
934
+ }
935
+
936
+ function repLongestBranch(members, topological) {
937
+ let best = members[0];
938
+ let bestLength = repEdge(best, topological);
939
+ for (let i = 1; i < members.length; ++i) {
940
+ let length = repEdge(members[i], topological);
941
+ if (length > bestLength + REPRESENTATIVE_EPS) {
942
+ best = members[i];
943
+ bestLength = length;
944
+ }
945
+ }
946
+ return best;
947
+ }
948
+
949
+ // The tip with the smallest summed distance to its group-mates, by the
950
+ // rerooting sum-of-distances recursion (linear, never all pairs): `down`
951
+ // sums the distances to the tips below a node, `up` to all the others.
952
+ function repMedoid(pre, members, topological) {
953
+ let tipsBelow = new Map();
954
+ let down = new Map();
955
+ for (let i = pre.length - 1; i >= 0; --i) {
956
+ let n = pre[i];
957
+ if (repIsTip(n)) {
958
+ tipsBelow.set(n, 1);
959
+ down.set(n, 0);
960
+ continue;
961
+ }
962
+ let s = 0;
963
+ let d = 0;
964
+ for (let c = 0; c < n.children.length; ++c) {
965
+ let child = n.children[c];
966
+ let e = repEdge(child, topological);
967
+ let cs = tipsBelow.get(child);
968
+ s += cs;
969
+ d += down.get(child) + (e * cs);
970
+ }
971
+ tipsBelow.set(n, s);
972
+ down.set(n, d);
973
+ }
974
+ let total = tipsBelow.get(pre[0]);
975
+ let up = new Map();
976
+ up.set(pre[0], 0);
977
+ pre.forEach(function (n) {
978
+ if (repIsTip(n)) {
979
+ return;
980
+ }
981
+ let un = up.get(n);
982
+ let dn = down.get(n);
983
+ for (let c = 0; c < n.children.length; ++c) {
984
+ let child = n.children[c];
985
+ let e = repEdge(child, topological);
986
+ let cs = tipsBelow.get(child);
987
+ up.set(child, un + dn - down.get(child) - (e * cs) + (e * (total - cs)));
988
+ }
989
+ });
990
+ let best = members[0];
991
+ let bestTotal = up.get(best);
992
+ for (let i = 1; i < members.length; ++i) {
993
+ let t = up.get(members[i]);
994
+ if (t < bestTotal - REPRESENTATIVE_EPS) {
995
+ best = members[i];
996
+ bestTotal = t;
997
+ }
998
+ }
999
+ return best;
1000
+ }
1001
+
1002
+ /**
1003
+ * Chooses representative tips: groups the tips into the maximal clades
1004
+ * whose members are all within a distance of each other, and keeps one
1005
+ * tip per group -- the desktop's Select Representative Tips.
1006
+ *
1007
+ * By `cutoff`, no two tips of a group are farther apart than it. By
1008
+ * `target`, the cutoff is the one whose group count comes closest to that
1009
+ * many (a tie keeps more); the count made is reported, since only certain
1010
+ * counts are possible. Without branch lengths the distance counts edges.
1011
+ *
1012
+ * A protected tip is never dropped: it stands in for its group's
1013
+ * representative, and a group with several keeps them all -- which can
1014
+ * keep more tips than the target.
1015
+ *
1016
+ * The tree is not changed.
1017
+ *
1018
+ * @param phy the tree
1019
+ * @param options {cutoff: number} or {target: integer}, with
1020
+ * pick: forester.REPRESENTATIVE_MEDOID (default, the most central
1021
+ * tip) or forester.REPRESENTATIVE_LONGEST_BRANCH (the most
1022
+ * divergent), and protectedTips: tips (nodes) never to drop
1023
+ * @returns {{groups: Array, keptTips: Array, keptCount: number,
1024
+ * protectedKeptCount: number, tipCount: number,
1025
+ * effectiveCutoff: number, topological: boolean, pick: string,
1026
+ * requestedTarget: number, summary: string}}
1027
+ * groups: {clade, members, kept}, in tree order of their first
1028
+ * kept tip; requestedTarget is -1 for a cutoff
1029
+ */
1030
+ forester.selectRepresentativeTips = function (phy, options) {
1031
+ let opts = options || {};
1032
+ let root = phy ? forester.getTreeRoot(phy) : null;
1033
+ if (!root) {
1034
+ throw new Error('the tree is null or empty');
1035
+ }
1036
+ let byCutoff = opts.cutoff !== undefined;
1037
+ if (byCutoff === (opts.target !== undefined)) {
1038
+ throw new Error('give either a cutoff or a target');
1039
+ }
1040
+ if (byCutoff && (typeof opts.cutoff !== 'number' || !isFinite(opts.cutoff) || opts.cutoff < 0)) {
1041
+ throw new Error('cutoff must be a finite, non-negative number');
1042
+ }
1043
+ if (!byCutoff && !(Number.isInteger(opts.target) && opts.target >= 1)) {
1044
+ throw new Error('target number of representatives must be at least 1');
1045
+ }
1046
+ let pick = opts.pick === undefined ? forester.REPRESENTATIVE_MEDOID : opts.pick;
1047
+ if (pick !== forester.REPRESENTATIVE_MEDOID && pick !== forester.REPRESENTATIVE_LONGEST_BRANCH) {
1048
+ throw new Error('unknown representative pick: ' + pick);
1049
+ }
1050
+ let protectedTips = new Set(opts.protectedTips || []);
1051
+
1052
+ let pre = repPreorder(root);
1053
+ let order = new Map();
1054
+ pre.forEach(function (n, i) {
1055
+ order.set(n, i);
1056
+ });
1057
+ let tips = pre.filter(repIsTip);
1058
+ let topological = !forester.hasUsableBranchLengths(phy);
1059
+ let diameter = repDiameters(pre, topological);
1060
+ let groupRoots;
1061
+ let effectiveCutoff;
1062
+ if (byCutoff) {
1063
+ groupRoots = repGroupRoots(root, diameter, opts.cutoff);
1064
+ effectiveCutoff = opts.cutoff;
1065
+ } else if (opts.target >= tips.length) {
1066
+ groupRoots = tips;
1067
+ effectiveCutoff = 0;
1068
+ } else if (opts.target <= 1) {
1069
+ groupRoots = [root];
1070
+ effectiveCutoff = diameter.get(root);
1071
+ } else {
1072
+ let cutoff = repCutoffForTarget(pre, root, diameter, opts.target, tips.length);
1073
+ groupRoots = repGroupRoots(root, diameter, cutoff);
1074
+ effectiveCutoff = Math.max(0, cutoff);
1075
+ }
1076
+
1077
+ let protectedKeptCount = 0;
1078
+ let groups = groupRoots.map(function (clade) {
1079
+ let sub = repPreorder(clade);
1080
+ let members = sub.filter(repIsTip);
1081
+ let kept = members.filter(function (m) {
1082
+ return protectedTips.has(m);
1083
+ });
1084
+ if (kept.length > 0) {
1085
+ protectedKeptCount += kept.length;
1086
+ } else if (members.length === 1) {
1087
+ kept = [members[0]];
1088
+ } else if (pick === forester.REPRESENTATIVE_LONGEST_BRANCH) {
1089
+ kept = [repLongestBranch(members, topological)];
1090
+ } else {
1091
+ kept = [repMedoid(sub, members, topological)];
1092
+ }
1093
+ return {clade: clade, members: members, kept: kept};
1094
+ });
1095
+ groups.sort(function (a, b) {
1096
+ return order.get(a.kept[0]) - order.get(b.kept[0]);
1097
+ });
1098
+ let keptTips = [];
1099
+ groups.forEach(function (g) {
1100
+ g.kept.forEach(function (k) {
1101
+ keptTips.push(k);
1102
+ });
1103
+ });
1104
+ let result = {
1105
+ groups: groups,
1106
+ keptTips: keptTips,
1107
+ keptCount: keptTips.length,
1108
+ protectedKeptCount: protectedKeptCount,
1109
+ tipCount: tips.length,
1110
+ effectiveCutoff: effectiveCutoff,
1111
+ topological: topological,
1112
+ pick: pick,
1113
+ requestedTarget: byCutoff ? -1 : opts.target
1114
+ };
1115
+ result.summary = representativeSummary(result);
1116
+ return result;
1117
+ };
1118
+
1119
+ // Java's Double.toString, which the desktop's texts print numbers with:
1120
+ // the shortest digits that read back as the number (as JS's), but always
1121
+ // with a fraction ("1.0"), and in E notation outside [0.001, 10^7).
1122
+ function javaDoubleText(d) {
1123
+ if (d === 0) {
1124
+ return (1 / d < 0) ? '-0.0' : '0.0';
1125
+ }
1126
+ if (!isFinite(d)) {
1127
+ return String(d);
1128
+ }
1129
+ let a = Math.abs(d);
1130
+ if (a >= 1e-3 && a < 1e7) {
1131
+ let s = String(d);
1132
+ return s.indexOf('.') < 0 ? s + '.0' : s;
1133
+ }
1134
+ let e = d.toExponential();
1135
+ let i = e.indexOf('e');
1136
+ let mantissa = e.slice(0, i);
1137
+ return (mantissa.indexOf('.') < 0 ? mantissa + '.0' : mantissa) + 'E' + e.slice(i + 1).replace('+', '');
1138
+ }
1139
+
1140
+ // a distance to five decimals, a whole number without its fraction
1141
+ function representativeDistanceText(d) {
1142
+ let r = Math.round(d * 1e5) / 1e5;
1143
+ return (r === Math.round(r)) ? String(r) : javaDoubleText(r);
1144
+ }
1145
+
1146
+ function representativeSummary(result) {
1147
+ let groups = result.groups.length;
1148
+ let s = 'Grouped ' + result.tipCount + (result.tipCount === 1 ? ' tip' : ' tips')
1149
+ + ' into ' + groups + (groups === 1 ? ' group.' : ' groups.');
1150
+ if (result.requestedTarget > 0 && groups !== result.requestedTarget) {
1151
+ s += ' (requested ' + result.requestedTarget + ')';
1152
+ }
1153
+ s += '\nEach group\'s tips are within a distance of ' + representativeDistanceText(result.effectiveCutoff)
1154
+ + ' of each other';
1155
+ if (result.topological) {
1156
+ s += ' (topological distance — the tree has no branch lengths)';
1157
+ }
1158
+ s += '.';
1159
+ if (result.protectedKeptCount > 0) {
1160
+ s += '\nKeeping ' + result.keptCount + (result.keptCount === 1 ? ' tip, including ' : ' tips, including ')
1161
+ + result.protectedKeptCount
1162
+ + (result.protectedKeptCount === 1 ? ' selected tip protected from removal.'
1163
+ : ' selected tips protected from removal.');
1164
+ }
1165
+ s += '\nRepresentative per group: '
1166
+ + (result.pick === forester.REPRESENTATIVE_LONGEST_BRANCH ? 'most divergent (longest branch)'
1167
+ : 'most central (medoid)') + '.';
1168
+ return s;
1169
+ }
1170
+
1171
+ // a copy of a node's own data (never its children or parent link)
1172
+ function repCopyData(v) {
1173
+ if (Array.isArray(v)) {
1174
+ return v.map(repCopyData);
1175
+ }
1176
+ if (v !== null && typeof v === 'object') {
1177
+ let o = {};
1178
+ Object.keys(v).forEach(function (k) {
1179
+ if (k !== 'parent' && k !== 'children') {
1180
+ o[k] = repCopyData(v[k]);
1181
+ }
1182
+ });
1183
+ return o;
1184
+ }
1185
+ return v;
1186
+ }
1187
+
1188
+ /**
1189
+ * A copy of the tree holding only the given tips, pruned as the desktop
1190
+ * prunes (Phylogeny.deleteSubtree): a node left with one child is
1191
+ * replaced by that child, whose branch gains the node's length (a missing
1192
+ * or negative length adds nothing; two of them leave the length missing);
1193
+ * a root left with one child is replaced by it. The new root keeps the
1194
+ * original root's own branch length (normally none), where the desktop's
1195
+ * depends on the order it deletes in. The tree is not changed.
1196
+ *
1197
+ * @param phy the tree
1198
+ * @param keep the tips to keep (nodes of phy), at least one
1199
+ * @returns the copy, with every node's data copied
1200
+ */
1201
+ forester.copyTreeKeepingTips = function (phy, keep) {
1202
+ let keepSet = new Set(keep);
1203
+ let top = phy.children && phy.children.length === 1 && !phy.parent ? phy : {children: [phy]};
1204
+ let copies = new Map();
1205
+ let copyTop = repCopyData(top === phy ? phy : {});
1206
+ copies.set(top, copyTop);
1207
+ let stack = [top];
1208
+ while (stack.length > 0) {
1209
+ let n = stack.pop();
1210
+ if (n.children) {
1211
+ copies.get(n).children = n.children.map(function (child) {
1212
+ let cc = repCopyData(child);
1213
+ copies.set(child, cc);
1214
+ stack.push(child);
1215
+ return cc;
1216
+ });
1217
+ }
1218
+ }
1219
+ let tips = repPreorder(top).filter(function (n) {
1220
+ return n !== top && repIsTip(n);
1221
+ });
1222
+ let dropped = tips.filter(function (n) {
1223
+ return !keepSet.has(n);
1224
+ });
1225
+ if (dropped.length === tips.length) {
1226
+ throw new Error('at least one tip must be kept');
1227
+ }
1228
+ let parentOf = new Map();
1229
+ stack = [copyTop];
1230
+ while (stack.length > 0) {
1231
+ let n = stack.pop();
1232
+ (n.children || []).forEach(function (child) {
1233
+ parentOf.set(child, n);
1234
+ stack.push(child);
1235
+ });
1236
+ }
1237
+ let add = function (a, b) {
1238
+ let okA = typeof a === 'number' && a >= 0;
1239
+ let okB = typeof b === 'number' && b >= 0;
1240
+ return (okA && okB) ? a + b : (okA ? a : (okB ? b : undefined));
1241
+ };
1242
+ dropped.forEach(function (tip) {
1243
+ let t = copies.get(tip);
1244
+ let p = parentOf.get(t);
1245
+ let i = p.children.indexOf(t);
1246
+ if (parentOf.get(p) === copyTop) {
1247
+ if (p.children.length === 2) {
1248
+ let other = p.children[1 - i];
1249
+ copyTop.children[0] = other;
1250
+ parentOf.set(other, copyTop);
1251
+ } else {
1252
+ p.children.splice(i, 1);
1253
+ }
1254
+ } else {
1255
+ let pp = parentOf.get(p);
1256
+ if (p.children.length === 2) {
1257
+ let other = p.children[1 - i];
1258
+ let length = add(p.branch_length, other.branch_length);
1259
+ if (length === undefined) {
1260
+ delete other.branch_length;
1261
+ } else {
1262
+ other.branch_length = length;
1263
+ }
1264
+ pp.children[pp.children.indexOf(p)] = other;
1265
+ parentOf.set(other, pp);
1266
+ } else {
1267
+ p.children.splice(i, 1);
1268
+ }
1269
+ }
1270
+ if (p.children.length === 0) {
1271
+ delete p.children;
1272
+ }
1273
+ });
1274
+ // The root keeps the original root's own branch length, normally
1275
+ // none. What the desktop's pruning leaves there depends on the order
1276
+ // it deletes tips in -- that is, on node ids -- so the same selection
1277
+ // gave 0.05 in one session and 0.25 in another (Christian, 2026-09-15).
1278
+ let rootLength = top.children[0].branch_length;
1279
+ if (typeof rootLength === 'number') {
1280
+ copyTop.children[0].branch_length = rootLength;
1281
+ } else {
1282
+ delete copyTop.children[0].branch_length;
1283
+ }
1284
+ return copyTop;
1285
+ };
1286
+
1287
+ /**
1288
+ * Strips a file-type suffix -- a dot and 1 to 5 other characters, such as
1289
+ * .xml or .nexus -- from a tree name, as the desktop does.
1290
+ *
1291
+ * @param name
1292
+ * @returns {string|null}
1293
+ */
1294
+ forester.stripShortExtension = function (name) {
1295
+ return (name === null || name === undefined) ? null : String(name).replace(/\.[^.]{1,5}$/, '');
1296
+ };
1297
+
1298
+ /**
1299
+ * The desktop's name for a tree of representative tips: the parent's name
1300
+ * without its file suffix, then the count -- mammals_233reps, _1rep --
1301
+ * or "tree" for an unnamed parent.
1302
+ *
1303
+ * @param parentName
1304
+ * @param count
1305
+ * @returns {string}
1306
+ */
1307
+ forester.representativeTreeName = function (parentName, count) {
1308
+ let stripped = forester.stripShortExtension(parentName);
1309
+ return (stripped ? stripped : 'tree') + '_' + count + (count === 1 ? 'rep' : 'reps');
1310
+ };
1311
+
1312
+ /**
1313
+ * The desktop's provenance sentence for a tree of representative tips,
1314
+ * which it adds to the tree's description.
1315
+ *
1316
+ * @param byCutoff true for a cutoff, false for a target
1317
+ * @param cutoff
1318
+ * @param target
1319
+ * @param pick forester.REPRESENTATIVE_MEDOID or _LONGEST_BRANCH
1320
+ * @param count tips kept
1321
+ * @param parentName
1322
+ * @param parentTipCount
1323
+ * @returns {string}
1324
+ */
1325
+ forester.representativeTreeDescription = function (byCutoff, cutoff, target, pick, count, parentName, parentTipCount) {
1326
+ let pickText = pick === forester.REPRESENTATIVE_LONGEST_BRANCH ? 'longest-branch' : 'medoid';
1327
+ let algorithm = byCutoff
1328
+ ? 'distance-cutoff (maximum distance ' + javaDoubleText(cutoff) + ', ' + pickText + ' representative)'
1329
+ : 'target-count (target ' + target + ', ' + pickText + ' representative)';
1330
+ return 'Used the ' + algorithm + ' algorithm to select ' + count + ' representative '
1331
+ + (count === 1 ? 'tip' : 'tips') + ' from tree named "' + (parentName ? parentName : 'tree') + '" with '
1332
+ + parentTipCount + (parentTipCount === 1 ? ' tip.' : ' tips.');
1333
+ };
1334
+
784
1335
  /**
785
1336
  * Whether a node carries data about the node itself, the kind a
786
1337
  * re-rooting can take the meaning away from: a name, taxonomy, sequence
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archaeopteryx",
3
- "version": "3.6.0",
3
+ "version": "3.7.0",
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",