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 +32 -0
- package/archaeopteryx.js +366 -3
- package/forester.js +552 -1
- package/package.json +1 -1
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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