archaeopteryx 3.12.0 → 3.13.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 +41 -7
- package/archaeopteryx.js +49 -51
- package/forester.js +369 -82
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -316,6 +316,25 @@ describe the MAD rooting only. A shared view remembers a MAD root. A phyloXML
|
|
|
316
316
|
download keeps them as `<confidence type="MAD">`, as the desktop writes them;
|
|
317
317
|
a Newick or Nexus download never puts one where a support value goes.
|
|
318
318
|
|
|
319
|
+
A tip is written under its **name**; where it has none, under its taxonomy
|
|
320
|
+
(code, then scientific, then common name), then its sequence's name, symbol or
|
|
321
|
+
gene name, then its sequence **accession**, and only if it has none of those
|
|
322
|
+
under a `node<N>` placeholder numbering it by position among the tips. Each
|
|
323
|
+
step is tried in turn, so a taxonomy element that is present but empty does
|
|
324
|
+
not stop the search. An unlabeled *internal* node stays unlabeled — a
|
|
325
|
+
placeholder there would invent a name for an ancestor. Newick and Nexus use
|
|
326
|
+
the one chain, so a tree saved in both formats names its tips identically, and
|
|
327
|
+
it is the desktop Archaeopteryx's chain, compared byte for byte.
|
|
328
|
+
|
|
329
|
+
Newick and Nexus have **one** support slot per branch and no place to name
|
|
330
|
+
what kind of support it is, so a branch carrying several confidences is
|
|
331
|
+
written with the first that is not a MAD value, exactly as the desktop writes
|
|
332
|
+
it, and a `bootstrap` read back from such a file is typed `unknown`. phyloXML
|
|
333
|
+
keeps every one of them, typed. Everything else survives a trip through
|
|
334
|
+
either format unchanged — names, branch lengths (including zero-length and
|
|
335
|
+
negative branches) and aligned sequences — in both directions, which the test
|
|
336
|
+
suite pins as two standing round trips.
|
|
337
|
+
|
|
319
338
|
1. Tria, F.D.K., Landan, G., Dagan, T. (2017). Phylogenetic rooting using
|
|
320
339
|
minimal ancestor deviation. *Nature Ecology & Evolution*, 1, 0193.
|
|
321
340
|
<https://www.nature.com/articles/s41559-017-0193>
|
|
@@ -555,7 +574,19 @@ column blank. Read `n` and judge.
|
|
|
555
574
|
Alignments arrive with the tree: as phyloXML `<mol_seq is_aligned="true">`
|
|
556
575
|
elements, or in a **Nexus** file whose characters matrix accompanies its tree.
|
|
557
576
|
The **Nexus** entry in the Download menu writes the current tree *and* its
|
|
558
|
-
alignment back into one Nexus file (Taxa, Characters and Trees blocks)
|
|
577
|
+
alignment back into one Nexus file (Taxa, Characters and Trees blocks), in the
|
|
578
|
+
same bytes the desktop Archaeopteryx writes: a row per taxon rather than per
|
|
579
|
+
sequence, so a tip carrying no sequence gets a row of the missing symbol `?`
|
|
580
|
+
and the matrix still covers every taxon the file declares. Reading it back,
|
|
581
|
+
such a row is absence of data and not a sequence of question marks. Residues
|
|
582
|
+
from a Nexus matrix are normalised as the desktop normalises them — raised to
|
|
583
|
+
upper case, `.` read as a gap, and anything outside the declared alphabet read
|
|
584
|
+
as the unspecified residue, `X` for protein and `N` for nucleotides, so the
|
|
585
|
+
missing symbol `?` arrives as `X` or `N`. A phyloXML `<mol_seq>` is kept
|
|
586
|
+
exactly as written, by both programs. Sequences
|
|
587
|
+
of *unequal* length are not an alignment and cannot form a character matrix,
|
|
588
|
+
so they are not written at all — the file says so in a bracketed comment
|
|
589
|
+
rather than padding them into an alignment that does not exist.
|
|
559
590
|
|
|
560
591
|
## Heat maps
|
|
561
592
|
|
|
@@ -1021,11 +1052,14 @@ plus Search, and folds the ones that are adjustments to make later: Zoom,
|
|
|
1021
1052
|
Sizes and the domain controls. Whatever you open or close is then remembered, for the
|
|
1022
1053
|
page and across reloads, so a panel you have arranged stays arranged, even
|
|
1023
1054
|
when you switch to another tree. In a short window it also keeps itself to one
|
|
1024
|
-
screen: opening a section folds the one you opened longest ago,
|
|
1025
|
-
the
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1055
|
+
screen: opening a section folds the one you opened longest ago, and dragging
|
|
1056
|
+
the window (or the element it sits in) shorter does the same — but only while
|
|
1057
|
+
the panel would not otherwise fit, never past the last section left open, and
|
|
1058
|
+
never at all on a tall screen. Growing the window back leaves the folds where
|
|
1059
|
+
they are: what is open is your choice, and only running out of room overrules
|
|
1060
|
+
it. Anything that puts something into a folded section opens it, so jumping to
|
|
1061
|
+
the search box (⌘F / Ctrl+F) or a tool reporting its result as search hits
|
|
1062
|
+
unfolds Search. A host with little room to give can
|
|
1029
1063
|
also start the whole panel tighter and narrower with
|
|
1030
1064
|
[`panelDensity: 'compact'`](#configuration), or collapsed to its header bar
|
|
1031
1065
|
with `collapseControlPanel`.
|
|
@@ -1733,7 +1767,7 @@ readout (`forester.msaResidueInfo`, `msaUngappedPosition`) names the residue
|
|
|
1733
1767
|
|
|
1734
1768
|
Navigation: a lazily-created bar fixed at the viewport bottom — first / page
|
|
1735
1769
|
back / slider / page forward / last, a jump-to-column box (1-based, matching
|
|
1736
|
-
the hover readout) and a live "column N
|
|
1770
|
+
the hover readout) and a live "column N M of total" — plus wheel-over-track
|
|
1737
1771
|
at a tenth of a screen per notch. Every route lands in one `msaScrollTo()`,
|
|
1738
1772
|
which clamps and redraws; the tree never moves. A faint dashed guide runs
|
|
1739
1773
|
from each tip's label (or its node, when labels are hidden) across to that
|
package/archaeopteryx.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
// v 3.
|
|
23
|
+
// v 3.13.0
|
|
24
24
|
// 2026-09-17
|
|
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.13.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';
|
|
@@ -423,8 +423,6 @@ function (root, d3, forester, phyloXml) {
|
|
|
423
423
|
const RETURN_TO_SUPERTREE_BUTTON_BY_ONE = 'ret1_b';
|
|
424
424
|
const SEARCH_FIELD_0 = 'sf0';
|
|
425
425
|
const SEARCH_FIELD_1 = 'sf1';
|
|
426
|
-
const SEARCH_B_WRAP = 'search_b_wrap';
|
|
427
|
-
const SEARCH_B_TOGGLE = 'search_b_tgl';
|
|
428
426
|
const SEARCH_NAV_ROW = 'searchnavrow';
|
|
429
427
|
const SEARCH_NAV_PREV = 'searchnavprev';
|
|
430
428
|
const SEARCH_NAV_NEXT = 'searchnavnext';
|
|
@@ -4088,8 +4086,15 @@ function (root, d3, forester, phyloXml) {
|
|
|
4088
4086
|
};
|
|
4089
4087
|
|
|
4090
4088
|
|
|
4089
|
+
// A branch length of ZERO is a length and gets a label. Testing the
|
|
4090
|
+
// number for truthiness read 0 as "no branch length", so the one branch
|
|
4091
|
+
// whose length is worth pointing out -- two identical sequences, a
|
|
4092
|
+
// multifurcation written out as a run of zero-length branches -- was the
|
|
4093
|
+
// one drawn bare. Same mistake the phyloXML writer was making until
|
|
4094
|
+
// phyloxml 1.1.1; the desktop cannot make it at all, because it marks an
|
|
4095
|
+
// absent length with a sentinel (-1024) rather than with zero.
|
|
4091
4096
|
let makeBranchLengthLabel = function (phynode) {
|
|
4092
|
-
if (phynode.branch_length) {
|
|
4097
|
+
if (phynode.branch_length !== undefined && phynode.branch_length !== null) {
|
|
4093
4098
|
if (_state.phylogram && _state.minBranchLengthValueToShow && phynode.branch_length < _state.minBranchLengthValueToShow) {
|
|
4094
4099
|
return;
|
|
4095
4100
|
}
|
|
@@ -4117,7 +4122,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
4117
4122
|
if (_state.showConfidenceValues && support.length > 0
|
|
4118
4123
|
&& (!_state.minConfidenceValueToShow || support.some(function (c) { return c.value >= _state.minConfidenceValueToShow; }))) {
|
|
4119
4124
|
support.forEach(function (c) {
|
|
4120
|
-
|
|
4125
|
+
// a support of 0 is a support value, and a telling one: the
|
|
4126
|
+
// MAD branch above already tested it this way
|
|
4127
|
+
if (typeof c.value === 'number' && isFinite(c.value)) {
|
|
4121
4128
|
parts.push(+c.value.toFixed(CONFIDENCE_VALUE_DIGITS_DEFAULT));
|
|
4122
4129
|
}
|
|
4123
4130
|
});
|
|
@@ -7358,8 +7365,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
7358
7365
|
msaScrollTo(Infinity);
|
|
7359
7366
|
});
|
|
7360
7367
|
|
|
7361
|
-
// "column [ 1234 ]
|
|
7362
|
-
// the window and takes a column to jump to; the rest reads back
|
|
7368
|
+
// "column [ 1234 ] 1357 of 30,000": the box is the left edge of
|
|
7369
|
+
// the window and takes a column to jump to; the rest reads back.
|
|
7370
|
+
// No dash between them -- the box's own border already separates
|
|
7371
|
+
// the two numbers, and a dash sitting against it read as a minus
|
|
7372
|
+
// sign on the number after it.
|
|
7363
7373
|
let read = document.createElement('span');
|
|
7364
7374
|
read.className = 'aptx-msa-nav-read';
|
|
7365
7375
|
let lbl = document.createElement('span');
|
|
@@ -7409,7 +7419,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
7409
7419
|
_msaNav._jump.value = String(offset + 1);
|
|
7410
7420
|
}
|
|
7411
7421
|
_msaNav._jump.max = String(total);
|
|
7412
|
-
_msaNav._tail.textContent =
|
|
7422
|
+
_msaNav._tail.textContent = (offset + visible).toLocaleString()
|
|
7413
7423
|
+ ' of ' + total.toLocaleString();
|
|
7414
7424
|
let atStart = offset <= 0;
|
|
7415
7425
|
let atEnd = offset >= total - visible;
|
|
@@ -9291,9 +9301,6 @@ function (root, d3, forester, phyloXml) {
|
|
|
9291
9301
|
if (want.value) {
|
|
9292
9302
|
openPanelSection('Search'); // a view carrying a search shows it
|
|
9293
9303
|
}
|
|
9294
|
-
if (idx === 1 && want.value) {
|
|
9295
|
-
showSearchB();
|
|
9296
|
-
}
|
|
9297
9304
|
}
|
|
9298
9305
|
|
|
9299
9306
|
// The panel's controls from the state, after applyViewState
|
|
@@ -10429,30 +10436,6 @@ function (root, d3, forester, phyloXml) {
|
|
|
10429
10436
|
});
|
|
10430
10437
|
}
|
|
10431
10438
|
|
|
10432
|
-
// Search B starts hidden to keep the panel compact; one click (or a
|
|
10433
|
-
// configured initial value) reveals it, and it stays revealed.
|
|
10434
|
-
// Search B is folded away until wanted: the '+ Search B' link (which
|
|
10435
|
-
// focuses it) or a view that carries a second search.
|
|
10436
|
-
function showSearchB() {
|
|
10437
|
-
openPanelSection('Search');
|
|
10438
|
-
let wrap = byId(SEARCH_B_WRAP);
|
|
10439
|
-
if (wrap) {
|
|
10440
|
-
wrap.style.display = '';
|
|
10441
|
-
}
|
|
10442
|
-
let tgl = byId(SEARCH_B_TOGGLE);
|
|
10443
|
-
if (tgl) {
|
|
10444
|
-
tgl.style.display = 'none';
|
|
10445
|
-
}
|
|
10446
|
-
}
|
|
10447
|
-
|
|
10448
|
-
function revealSearchB() {
|
|
10449
|
-
showSearchB();
|
|
10450
|
-
let f = byId(SEARCH_FIELD_1);
|
|
10451
|
-
if (f) {
|
|
10452
|
-
f.focus();
|
|
10453
|
-
}
|
|
10454
|
-
}
|
|
10455
|
-
|
|
10456
10439
|
// ===================== Time axis =====================
|
|
10457
10440
|
// The desktop's time overlays, drawn beneath a rectangular PHYLOGRAM of
|
|
10458
10441
|
// a dated tree: the two-band ICS geologic axis with a "Ma before
|
|
@@ -11441,8 +11424,6 @@ function (root, d3, forester, phyloXml) {
|
|
|
11441
11424
|
}
|
|
11442
11425
|
if (_state.searchBinitialValue) {
|
|
11443
11426
|
setValue(SEARCH_FIELD_1, _state.searchBinitialValue);
|
|
11444
|
-
revealSearchB();
|
|
11445
|
-
|
|
11446
11427
|
} else {
|
|
11447
11428
|
setValue(SEARCH_FIELD_1, '');
|
|
11448
11429
|
}
|
|
@@ -12539,7 +12520,13 @@ function (root, d3, forester, phyloXml) {
|
|
|
12539
12520
|
let open = panelSections(panel).filter(function (s) {
|
|
12540
12521
|
return !s.fieldset.classList.contains('aptx-collapsed') && s.name !== justOpened;
|
|
12541
12522
|
});
|
|
12542
|
-
|
|
12523
|
+
// Never the one just opened -- that is the one the user asked
|
|
12524
|
+
// for -- and never the last one standing. Without the second
|
|
12525
|
+
// clause a container dragged short enough empties the panel down
|
|
12526
|
+
// to a stack of legends, taking away the section being worked in.
|
|
12527
|
+
// (justOpened is itself open and excluded from this list, so when
|
|
12528
|
+
// it is set the last one standing is already protected above.)
|
|
12529
|
+
if (open.length === 0 || (!justOpened && open.length === 1)) {
|
|
12543
12530
|
return;
|
|
12544
12531
|
}
|
|
12545
12532
|
open.sort(function (a, b) {
|
|
@@ -13246,8 +13233,6 @@ function (root, d3, forester, phyloXml) {
|
|
|
13246
13233
|
+ '.aptx-panel .aptx-slider-row { display:flex; align-items:center; gap:7px; margin:3px 0; }'
|
|
13247
13234
|
+ '.aptx-panel .aptx-slider-row label { flex:0 0 42px; font-size:10px; color:var(--p-muted); }'
|
|
13248
13235
|
+ '.aptx-panel .aptx-slider-row input[type=range] { flex:1 1 auto; min-width:0; margin:0; }'
|
|
13249
|
-
+ '.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; }'
|
|
13250
|
-
+ '.aptx-panel .aptx-linkbtn:hover { text-decoration:underline; }'
|
|
13251
13236
|
// one row, its buttons sharing it evenly, its edges on the zoom rows'
|
|
13252
13237
|
+ '.aptx-panel .aptx-toolrow { margin-top:5px; display:flex; gap:3px; }'
|
|
13253
13238
|
+ '.aptx-panel .aptx-toolrow .aptx-gbtn { flex:1 1 0; min-width:0; padding:0; margin:2px 0; }'
|
|
@@ -13455,6 +13440,20 @@ function (root, d3, forester, phyloXml) {
|
|
|
13455
13440
|
// spare, since the user opened none of them just now.
|
|
13456
13441
|
fitPanelSections(panel, null);
|
|
13457
13442
|
|
|
13443
|
+
// ... and again whenever the room changes. The panel is held to its
|
|
13444
|
+
// container's height, so a window dragged shorter (or a host resizing
|
|
13445
|
+
// the div around us) can push an arrangement that fitted a moment ago
|
|
13446
|
+
// into a scrollbar. Watching the CONTAINER, not the panel: folding a
|
|
13447
|
+
// section changes the panel's height and would re-enter the observer.
|
|
13448
|
+
// One-way on purpose -- growing the window back does NOT reopen what
|
|
13449
|
+
// was folded. What is open is the user's choice; only running out of
|
|
13450
|
+
// room may overrule it, and then only far enough to fit.
|
|
13451
|
+
if (typeof ResizeObserver !== 'undefined' && panel.parentElement) {
|
|
13452
|
+
new ResizeObserver(function () {
|
|
13453
|
+
fitPanelSections(panel, null);
|
|
13454
|
+
}).observe(panel.parentElement);
|
|
13455
|
+
}
|
|
13456
|
+
|
|
13458
13457
|
// Apply the current light/dark choice to this (and every) panel.
|
|
13459
13458
|
applyPanelTheme();
|
|
13460
13459
|
}
|
|
@@ -13599,7 +13598,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
13599
13598
|
if (d.name) {
|
|
13600
13599
|
text += 'Name: ' + d.name + '<br>';
|
|
13601
13600
|
}
|
|
13602
|
-
if (d.branch_length
|
|
13601
|
+
if (d.branch_length !== undefined && d.branch_length !== null
|
|
13602
|
+
&& !(unrooted && d.children)) {
|
|
13603
13603
|
text += (unrooted ? 'Branch length: ' : 'Distance to parent: ') + d.branch_length + '<br>';
|
|
13604
13604
|
}
|
|
13605
13605
|
let date = dateText(d.date);
|
|
@@ -14241,7 +14241,6 @@ function (root, d3, forester, phyloXml) {
|
|
|
14241
14241
|
on(CONFIDENCE_VALUES_CB, 'click', confidenceValuesCbClicked);
|
|
14242
14242
|
on(SUPPORT_DOTS_CB, 'click', supportDotsCbClicked);
|
|
14243
14243
|
on(MAD_VALUES_CB, 'click', madValuesCbClicked);
|
|
14244
|
-
on(SEARCH_B_TOGGLE, 'click', revealSearchB);
|
|
14245
14244
|
on(SEARCH_NAV_PREV, 'click', function () {
|
|
14246
14245
|
stepToFoundNode(-1);
|
|
14247
14246
|
});
|
|
@@ -14985,14 +14984,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
14985
14984
|
h = h.concat('<fieldset>');
|
|
14986
14985
|
h = h.concat('<legend>Search</legend>');
|
|
14987
14986
|
h = h.concat(makeSearchBox('Search A', 0));
|
|
14988
|
-
// Search B
|
|
14989
|
-
//
|
|
14990
|
-
//
|
|
14991
|
-
|
|
14992
|
-
|
|
14993
|
-
|
|
14987
|
+
// Search B used to sit behind a '+ Search B' link, to keep 45px out
|
|
14988
|
+
// of the panel. That link was a second, weaker disclosure mechanism
|
|
14989
|
+
// nested inside the section fold: it was forgotten on every reload
|
|
14990
|
+
// (the fold is remembered), it was one-way -- nothing put B back --
|
|
14991
|
+
// and it defeated the fit rule, since revealing B grew a section
|
|
14992
|
+
// that had just been fitted without it, leaving the panel to
|
|
14993
|
+
// scroll. The fold IS the disclosure. B is simply here.
|
|
14994
14994
|
h = h.concat(makeSearchBox('Search B', 1));
|
|
14995
|
-
h = h.concat('</div>');
|
|
14996
14995
|
h = h.concat('<div class="aptx-searchnav" id="' + SEARCH_NAV_ROW + '" style="display:none">');
|
|
14997
14996
|
h = h.concat('<button type="button" class="aptx-gbtn" id="' + SEARCH_NAV_PREV
|
|
14998
14997
|
+ '" title="center the previous search hit">◀</button>');
|
|
@@ -15207,7 +15206,6 @@ function (root, d3, forester, phyloXml) {
|
|
|
15207
15206
|
}
|
|
15208
15207
|
if (_state.searchBinitialValue) {
|
|
15209
15208
|
setValue(SEARCH_FIELD_1, _state.searchBinitialValue);
|
|
15210
|
-
revealSearchB();
|
|
15211
15209
|
}
|
|
15212
15210
|
}
|
|
15213
15211
|
|
package/forester.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
// v 3.
|
|
23
|
+
// v 3.13.0
|
|
24
24
|
// 2026-09-17
|
|
25
25
|
//
|
|
26
26
|
// forester.js is a general suite for dealing with phylogenetic trees.
|
|
@@ -5010,6 +5010,40 @@
|
|
|
5010
5010
|
});
|
|
5011
5011
|
};
|
|
5012
5012
|
|
|
5013
|
+
// Residue normalization for a Nexus character matrix, ported from the
|
|
5014
|
+
// desktop's BasicSequence.createAaSequence / createDnaSequence /
|
|
5015
|
+
// createRnaSequence, which is what its Nexus reader builds a sequence
|
|
5016
|
+
// with. Three steps, in this order:
|
|
5017
|
+
// 1. uppercase;
|
|
5018
|
+
// 2. '.' becomes the gap '-' (before step 3, or it would become X/N);
|
|
5019
|
+
// 3. anything outside the type's alphabet becomes the unspecified
|
|
5020
|
+
// residue, 'X' for protein and 'N' for nucleotides.
|
|
5021
|
+
// So the Nexus missing symbol '?' arrives as X or N, which is what
|
|
5022
|
+
// Christian decided on 2026-09-23 ("normalize to X, same as desktop").
|
|
5023
|
+
// '?' is simply one character outside the alphabet; doing it alone would
|
|
5024
|
+
// have left us differing on '.' and on stray letters instead.
|
|
5025
|
+
//
|
|
5026
|
+
// This is the NEXUS MATRIX reader only. A phyloXML <mol_seq> is kept
|
|
5027
|
+
// exactly as written by both programs -- measured, not assumed: the
|
|
5028
|
+
// desktop reads "MK??AL.N" out of phyloXML unchanged.
|
|
5029
|
+
const AA_NOT_ALPHABET = /[^ARNDBCQEZGHILKMFPSTWYVXUO\-*]/g;
|
|
5030
|
+
const DNA_NOT_ALPHABET = /[^ACGTRYMKWSN\-*]/g;
|
|
5031
|
+
const RNA_NOT_ALPHABET = /[^ACGURYMKWSN\-*]/g;
|
|
5032
|
+
|
|
5033
|
+
function normalizeMatrixResidues(block, datatype) {
|
|
5034
|
+
let out = block.toUpperCase().replace(/\./g, '-');
|
|
5035
|
+
if (datatype === 'protein') {
|
|
5036
|
+
return out.replace(AA_NOT_ALPHABET, 'X');
|
|
5037
|
+
}
|
|
5038
|
+
if (datatype === 'dna') {
|
|
5039
|
+
return out.replace(DNA_NOT_ALPHABET, 'N');
|
|
5040
|
+
}
|
|
5041
|
+
if (datatype === 'rna') {
|
|
5042
|
+
return out.replace(RNA_NOT_ALPHABET, 'N');
|
|
5043
|
+
}
|
|
5044
|
+
return out;
|
|
5045
|
+
}
|
|
5046
|
+
|
|
5013
5047
|
// Parses a Nexus-formatted string and returns an ARRAY of tree objects,
|
|
5014
5048
|
// each in the same shape parseNewHampshire produces (a Nexus file can
|
|
5015
5049
|
// hold any number of trees). Ported from the desktop's
|
|
@@ -5031,6 +5065,10 @@
|
|
|
5031
5065
|
const RESIDUES_RE = /^[A-Za-z\-_*?.]+$/;
|
|
5032
5066
|
const DATATYPE_RE = /datatype\s*=\s*([a-z]+)/;
|
|
5033
5067
|
const MATCHCHAR_RE = /matchchar\s*=\s*['"]?(\S)/;
|
|
5068
|
+
// the desktop's patterns, character for character: one optional space
|
|
5069
|
+
// either side of the '=', then the first non-space as the symbol
|
|
5070
|
+
const MISSING_RE = /missing\s?.\s?(\S)/;
|
|
5071
|
+
const GAP_RE = /gap\s?.\s?(\S)/;
|
|
5034
5072
|
|
|
5035
5073
|
let trees = [];
|
|
5036
5074
|
let taxlabels = [];
|
|
@@ -5058,6 +5096,22 @@
|
|
|
5058
5096
|
let isRooted = false;
|
|
5059
5097
|
let matchchar = null;
|
|
5060
5098
|
let matrixReferenceId = null;
|
|
5099
|
+
// The symbols a block DECLARES for missing data and for a gap. Nexus
|
|
5100
|
+
// lets a file choose them, so assuming '?' and '-' reads an all-missing
|
|
5101
|
+
// row of a "Missing=N" file as real residues -- the invented-sequence
|
|
5102
|
+
// bug the residue scan exists to prevent. Defaults are the conventions.
|
|
5103
|
+
let missingChar = '?';
|
|
5104
|
+
let gapChar = '-';
|
|
5105
|
+
// Matrix ids whose row carried at least one real residue. A row of
|
|
5106
|
+
// nothing but the missing and gap symbols states that the taxon has NO
|
|
5107
|
+
// data, and must not become a sequence: our own writer emits exactly
|
|
5108
|
+
// such rows to keep the matrix rectangular, so without this a save and
|
|
5109
|
+
// reopen invents a sequence of question marks for every tip that never
|
|
5110
|
+
// had one. Tracked here rather than tested on the finished sequence
|
|
5111
|
+
// because the desktop turns '?' into 'X' when it builds an AA
|
|
5112
|
+
// sequence, after which a missing row is indistinguishable from a
|
|
5113
|
+
// genuinely ambiguous one.
|
|
5114
|
+
let seqIdsWithResidues = Object.create(null);
|
|
5061
5115
|
|
|
5062
5116
|
// Nexus treats '_' and ' ' as equivalent, labels may be quoted, and a
|
|
5063
5117
|
// matrix often capitalizes taxon names differently from the tree -- so
|
|
@@ -5168,6 +5222,19 @@
|
|
|
5168
5222
|
}
|
|
5169
5223
|
block = resolved;
|
|
5170
5224
|
}
|
|
5225
|
+
// '*' is NOT absence: it is a residue, the stop codon of a
|
|
5226
|
+
// translated alignment. What counts as absence is what the block
|
|
5227
|
+
// DECLARED, plus '.', which is the matchchar and gap convention.
|
|
5228
|
+
for (let j = 0; j < block.length; ++j) {
|
|
5229
|
+
let c = block.charAt(j).toLowerCase();
|
|
5230
|
+
if (c !== missingChar.toLowerCase() && c !== gapChar.toLowerCase() && c !== '.') {
|
|
5231
|
+
seqIdsWithResidues[id] = true;
|
|
5232
|
+
break;
|
|
5233
|
+
}
|
|
5234
|
+
}
|
|
5235
|
+
// ... and only THEN normalize: afterwards '?' has become X or N
|
|
5236
|
+
// and a row of nothing but missing data would read as residues.
|
|
5237
|
+
block = normalizeMatrixResidues(block, datatype);
|
|
5171
5238
|
seqs[id] = {
|
|
5172
5239
|
value: seqs[id] ? (seqs[id].value + block) : block,
|
|
5173
5240
|
type: datatype
|
|
@@ -5200,7 +5267,9 @@
|
|
|
5200
5267
|
}
|
|
5201
5268
|
let seqsByKey = Object.create(null);
|
|
5202
5269
|
for (let id in seqs) {
|
|
5203
|
-
|
|
5270
|
+
if (seqIdsWithResidues[id]) {
|
|
5271
|
+
seqsByKey[joinKey(id)] = seqs[id];
|
|
5272
|
+
}
|
|
5204
5273
|
}
|
|
5205
5274
|
let externals = forester.getAllExternalNodes(phy);
|
|
5206
5275
|
let annotationNs = null;
|
|
@@ -5331,6 +5400,9 @@
|
|
|
5331
5400
|
// scope the rows to THIS matrix block, so a later block
|
|
5332
5401
|
// cannot cross-contaminate an earlier one
|
|
5333
5402
|
seqs = Object.create(null);
|
|
5403
|
+
seqIdsWithResidues = Object.create(null);
|
|
5404
|
+
missingChar = '?';
|
|
5405
|
+
gapChar = '-';
|
|
5334
5406
|
} else if (inTreesBlock) {
|
|
5335
5407
|
if (lc.startsWith('title')) {
|
|
5336
5408
|
let tm = TITLE_RE.exec(line);
|
|
@@ -5522,6 +5594,14 @@
|
|
|
5522
5594
|
if (mm) {
|
|
5523
5595
|
matchchar = mm[1];
|
|
5524
5596
|
}
|
|
5597
|
+
let miss = MISSING_RE.exec(dlc);
|
|
5598
|
+
if (miss) {
|
|
5599
|
+
missingChar = miss[1];
|
|
5600
|
+
}
|
|
5601
|
+
let gp = GAP_RE.exec(dlc);
|
|
5602
|
+
if (gp) {
|
|
5603
|
+
gapChar = gp[1];
|
|
5604
|
+
}
|
|
5525
5605
|
if (dlc === 'matrix' || dlc.startsWith('matrix ')) {
|
|
5526
5606
|
inMatrix = true;
|
|
5527
5607
|
let after = line.substring(6).trim();
|
|
@@ -6087,9 +6167,90 @@
|
|
|
6087
6167
|
* @param writeConfidences - to write confidence values in brackets
|
|
6088
6168
|
* @returns {*} - a New Hampshire (Newick) formatted string.
|
|
6089
6169
|
*/
|
|
6170
|
+
// The label a node is written under, in New Hampshire and in Nexus alike:
|
|
6171
|
+
// name, then taxonomy (code / scientific / common), then the sequence's
|
|
6172
|
+
// name / symbol / gene name, then its ACCESSION -- each step tried in turn,
|
|
6173
|
+
// so a taxonomy element that is present but empty does not stop the search.
|
|
6174
|
+
// An EXTERNAL node left with nothing gets a 'node<N>' placeholder, N being
|
|
6175
|
+
// its 1-based position in tip order; an empty label would not parse back
|
|
6176
|
+
// out of TaxLabels, and in Newick it names nothing at all. An INTERNAL node
|
|
6177
|
+
// does NOT get one: a placeholder there would invent a name for an
|
|
6178
|
+
// ancestor, and an unlabeled internal node is perfectly ordinary.
|
|
6179
|
+
//
|
|
6180
|
+
// ONE chain for both writers. A Nexus file whose TaxLabels, matrix rows and
|
|
6181
|
+
// trees block disagree about a taxon cannot be joined back up, and a tree
|
|
6182
|
+
// saved as Newick and as Nexus should name its tips the same way -- ours
|
|
6183
|
+
// did not, until 2026-09-23: toNexus applied this chain and toNewHampshire
|
|
6184
|
+
// wrote node.name and nothing else, so a nameless tip was HUMAN in one file
|
|
6185
|
+
// and empty in the other. It is the desktop's chain
|
|
6186
|
+
// (PhylogenyNode.toNewHampshire), adopted on Christian's word, and the two
|
|
6187
|
+
// programs' output is compared byte for byte in the tests.
|
|
6188
|
+
function nhNodeLabel(node, placeholder) {
|
|
6189
|
+
let s = node.name || '';
|
|
6190
|
+
if (!s && node.taxonomies && node.taxonomies.length > 0) {
|
|
6191
|
+
let t = node.taxonomies[0];
|
|
6192
|
+
s = t.code || t.scientific_name || t.common_name || '';
|
|
6193
|
+
}
|
|
6194
|
+
if (!s && node.sequences && node.sequences.length > 0) {
|
|
6195
|
+
let q = node.sequences[0];
|
|
6196
|
+
s = q.name || q.symbol || q.gene_name || '';
|
|
6197
|
+
}
|
|
6198
|
+
if (!s && node.sequences && node.sequences.length > 0) {
|
|
6199
|
+
let a = node.sequences[0].accession;
|
|
6200
|
+
s = (a && a.value) ? a.value : '';
|
|
6201
|
+
}
|
|
6202
|
+
if (!s && placeholder) {
|
|
6203
|
+
s = placeholder;
|
|
6204
|
+
}
|
|
6205
|
+
return s;
|
|
6206
|
+
}
|
|
6207
|
+
|
|
6208
|
+
// A placeholder label for every external node, by tip index, in the order
|
|
6209
|
+
// the tips are written (getAllExternalNodes collects them in pre-order from
|
|
6210
|
+
// the far side, so reversing it gives left-to-right). Used only for a node
|
|
6211
|
+
// that nothing else names.
|
|
6212
|
+
//
|
|
6213
|
+
// A tip may literally be CALLED "node2". Minting that same token for a
|
|
6214
|
+
// different tip gives two taxa one label: illegal Nexus, and our own reader
|
|
6215
|
+
// takes the repeated row for an interleaved continuation and hands both
|
|
6216
|
+
// tips the two sequences joined together. So the labels the tree already
|
|
6217
|
+
// produces are collected first and stepped over.
|
|
6218
|
+
function tipPlaceholders(phy) {
|
|
6219
|
+
let m = new Map();
|
|
6220
|
+
// An EMPTY tree has no tips to number. Its single top node looks like
|
|
6221
|
+
// an external node -- no children -- and numbering it would turn the
|
|
6222
|
+
// empty string, which parseNewHampshire accepts and toNewHampshire has
|
|
6223
|
+
// always written back as the empty string, into "node1;". (The desktop
|
|
6224
|
+
// throws on an empty string rather than parsing it, so there is no
|
|
6225
|
+
// joint behaviour to match here, only ours to keep.)
|
|
6226
|
+
if (!phy.children || phy.children.length !== 1 || !phy.children[0].children) {
|
|
6227
|
+
return m;
|
|
6228
|
+
}
|
|
6229
|
+
let ext = forester.getAllExternalNodes(phy).reverse();
|
|
6230
|
+
let taken = Object.create(null);
|
|
6231
|
+
ext.forEach(function (n) {
|
|
6232
|
+
let label = nhNodeLabel(n, null);
|
|
6233
|
+
if (label.length > 0) {
|
|
6234
|
+
taken[label] = true;
|
|
6235
|
+
}
|
|
6236
|
+
});
|
|
6237
|
+
let i = 1;
|
|
6238
|
+
let spare = ext.length + 1;
|
|
6239
|
+
ext.forEach(function (n) {
|
|
6240
|
+
let candidate = 'node' + (i++);
|
|
6241
|
+
while (taken[candidate]) {
|
|
6242
|
+
candidate = 'node' + (spare++);
|
|
6243
|
+
}
|
|
6244
|
+
taken[candidate] = true;
|
|
6245
|
+
m.set(n, candidate);
|
|
6246
|
+
});
|
|
6247
|
+
return m;
|
|
6248
|
+
}
|
|
6249
|
+
|
|
6090
6250
|
forester.toNewHampshire = function (phy, decPointsMax, replaceChars, writeConfidences) {
|
|
6091
6251
|
void replaceChars; // retired: see the note above; labels are always quoted now
|
|
6092
6252
|
let nh = "";
|
|
6253
|
+
let tips = tipPlaceholders(phy);
|
|
6093
6254
|
if (phy.children && phy.children.length === 1) {
|
|
6094
6255
|
toNewHampshireHelper(phy.children[0], true);
|
|
6095
6256
|
}
|
|
@@ -6107,8 +6268,9 @@
|
|
|
6107
6268
|
}
|
|
6108
6269
|
nh += ")";
|
|
6109
6270
|
}
|
|
6110
|
-
|
|
6111
|
-
|
|
6271
|
+
let label = nhNodeLabel(node, tips.get(node));
|
|
6272
|
+
if (label.length > 0) {
|
|
6273
|
+
nh += sanitizeLabelForNH(label);
|
|
6112
6274
|
}
|
|
6113
6275
|
if (node.branch_length !== undefined && node.branch_length !== null) {
|
|
6114
6276
|
if (decPointsMax && decPointsMax > 0) {
|
|
@@ -6120,10 +6282,21 @@
|
|
|
6120
6282
|
// the support slot holds support: a MAD value (madRoot) never goes
|
|
6121
6283
|
// there -- it would read as support, and would crowd out the
|
|
6122
6284
|
// bootstrap on a branch carrying both. phyloXML keeps it, typed.
|
|
6285
|
+
//
|
|
6286
|
+
// Newick has ONE slot and phyloXML allows many, so a node carrying
|
|
6287
|
+
// both a bootstrap and a posterior has to lose one of them. It
|
|
6288
|
+
// writes the FIRST that is not MAD, which is what the desktop's
|
|
6289
|
+
// BranchData.getSupportConfidence returns and therefore what its
|
|
6290
|
+
// writer emits -- verified by running it: a node with
|
|
6291
|
+
// bootstrap=95 and posterior=0.99 comes out as "ab:0.3[95]".
|
|
6292
|
+
// This used to require EXACTLY one and so wrote nothing at all,
|
|
6293
|
+
// which lost the bootstrap too, on real files: the repo's own
|
|
6294
|
+
// phyloWithConfidences.xml has a node carrying bootstrap and
|
|
6295
|
+
// likelihood together.
|
|
6123
6296
|
let support = writeConfidences && node.confidences
|
|
6124
6297
|
? node.confidences.filter(function (c) { return c.type !== forester.MAD_CONFIDENCE_TYPE; })
|
|
6125
6298
|
: [];
|
|
6126
|
-
if (support.length
|
|
6299
|
+
if (support.length > 0 && support[0].value !== undefined && support[0].value !== null) {
|
|
6127
6300
|
if (decPointsMax && decPointsMax > 0) {
|
|
6128
6301
|
nh += "[" + forester.roundNumber(support[0].value, decPointsMax) + "]";
|
|
6129
6302
|
} else {
|
|
@@ -6137,6 +6310,70 @@
|
|
|
6137
6310
|
|
|
6138
6311
|
};
|
|
6139
6312
|
|
|
6313
|
+
// A node's molecular sequence for the Nexus matrix: the first one carrying
|
|
6314
|
+
// residues, or null. is_aligned is deliberately NOT consulted -- the
|
|
6315
|
+
// desktop's writer does not have that flag and decides on the lengths
|
|
6316
|
+
// instead, and equal lengths are what a character matrix actually
|
|
6317
|
+
// requires. (A JS node may hold several sequences where a desktop node
|
|
6318
|
+
// holds one; the first with residues is the one written.)
|
|
6319
|
+
function molSeqOfNode(node) {
|
|
6320
|
+
if (!node.sequences) {
|
|
6321
|
+
return null;
|
|
6322
|
+
}
|
|
6323
|
+
for (let j = 0; j < node.sequences.length; ++j) {
|
|
6324
|
+
let q = node.sequences[j];
|
|
6325
|
+
if (q.mol_seq && q.mol_seq.value) {
|
|
6326
|
+
return q.mol_seq.value;
|
|
6327
|
+
}
|
|
6328
|
+
}
|
|
6329
|
+
return null;
|
|
6330
|
+
}
|
|
6331
|
+
|
|
6332
|
+
// ForesterUtil.guessMolecularSequenceType, ported verbatim so that the two
|
|
6333
|
+
// programs declare the same DataType for the same residues: the letters a
|
|
6334
|
+
// nucleotide sequence cannot contain, then T for DNA and U for RNA, and
|
|
6335
|
+
// null when the sequence says nothing either way (all gaps, or A/C/G
|
|
6336
|
+
// alone). Note that it reads the residues and ignores any DECLARED type,
|
|
6337
|
+
// which is the desktop's rule and therefore ours -- and the right one:
|
|
6338
|
+
// bunya_glyco.xml declares type="protein" over 121 sequences of pure ACGT.
|
|
6339
|
+
//
|
|
6340
|
+
// F, P and V were added 2026-09-23 on Christian's word, jointly with the
|
|
6341
|
+
// desktop, because the test was missing most of the protein-exclusive
|
|
6342
|
+
// alphabet: under forester's own alphabets that is BDEFHILOPQVXZ and only
|
|
6343
|
+
// DEHILQ were tested. A protein built solely from nucleotide letters
|
|
6344
|
+
// therefore guessed DNA -- MKATSWNP has exactly one protein-exclusive
|
|
6345
|
+
// residue and it was P -- and a matrix wrongly declared DNA comes back
|
|
6346
|
+
// with every non-nucleotide residue replaced by N. Measured against
|
|
6347
|
+
// UniProt residue frequencies, the chance a protein carries none of the
|
|
6348
|
+
// tested letters falls from 12.5% to 3.3% at length 5 and from 1.6% to
|
|
6349
|
+
// 0.11% at length 10; a real alignment of a hundred columns was never at
|
|
6350
|
+
// risk either way. O, X and Z are protein-exclusive too and deliberately
|
|
6351
|
+
// left out: rare enough to buy almost nothing, and every letter added is
|
|
6352
|
+
// one both programs must add.
|
|
6353
|
+
//
|
|
6354
|
+
// V (and B, D, H) are nucleotide ambiguity codes in full IUPAC but not in
|
|
6355
|
+
// forester's DNA alphabet, which maps them to N, so they cannot survive in
|
|
6356
|
+
// a DNA sequence here and testing them for protein is consistent.
|
|
6357
|
+
//
|
|
6358
|
+
// NEITHER PROGRAM RETUNES THIS ALONE. A letter added on one side types the
|
|
6359
|
+
// same file two ways, which is worse than a blind spot they share;
|
|
6360
|
+
// testNexusMatrixDatatype pins the alphabet letter for letter.
|
|
6361
|
+
function guessMolSeqType(v) {
|
|
6362
|
+
let s = v.toUpperCase();
|
|
6363
|
+
if (s.indexOf('L') >= 0 || s.indexOf('I') >= 0 || s.indexOf('E') >= 0
|
|
6364
|
+
|| s.indexOf('H') >= 0 || s.indexOf('D') >= 0 || s.indexOf('Q') >= 0
|
|
6365
|
+
|| s.indexOf('F') >= 0 || s.indexOf('P') >= 0 || s.indexOf('V') >= 0) {
|
|
6366
|
+
return 'Protein';
|
|
6367
|
+
}
|
|
6368
|
+
if (s.indexOf('T') >= 0) {
|
|
6369
|
+
return 'DNA';
|
|
6370
|
+
}
|
|
6371
|
+
if (s.indexOf('U') >= 0) {
|
|
6372
|
+
return 'RNA';
|
|
6373
|
+
}
|
|
6374
|
+
return null;
|
|
6375
|
+
}
|
|
6376
|
+
|
|
6140
6377
|
// Writes a phylogeny as a Nexus-formatted string, ported from the
|
|
6141
6378
|
// desktop's PhylogenyWriter: a TAXA block (Dimensions, TaxLabels) and a
|
|
6142
6379
|
// TREES block (the tree under its name, [&R]/[&U] rootedness, the same
|
|
@@ -6151,95 +6388,148 @@
|
|
|
6151
6388
|
// all three go through sanitizeLabelForNH -- the same helper
|
|
6152
6389
|
// toNewHampshire writes the tree with.
|
|
6153
6390
|
//
|
|
6154
|
-
//
|
|
6155
|
-
//
|
|
6156
|
-
//
|
|
6157
|
-
//
|
|
6158
|
-
//
|
|
6159
|
-
|
|
6160
|
-
//
|
|
6161
|
-
//
|
|
6162
|
-
function
|
|
6163
|
-
|
|
6164
|
-
|
|
6165
|
-
|
|
6166
|
-
|
|
6167
|
-
|
|
6168
|
-
s = t.code || t.scientific_name || t.common_name || '';
|
|
6169
|
-
} else if (node.sequences && node.sequences.length > 0) {
|
|
6170
|
-
let q = node.sequences[0];
|
|
6171
|
-
s = q.name || q.symbol || q.gene_name || '';
|
|
6172
|
-
}
|
|
6173
|
-
if (!s) {
|
|
6174
|
-
s = 'node' + (i + 1); // an empty TaxLabels token would not parse back
|
|
6175
|
-
}
|
|
6176
|
-
return s;
|
|
6391
|
+
// The chain lives in nhNodeLabel, shared with toNewHampshire, and it
|
|
6392
|
+
// returns the label UNQUOTED because every use here quotes it through
|
|
6393
|
+
// sanitizeLabelForNH -- the same helper the tree string goes through.
|
|
6394
|
+
//
|
|
6395
|
+
// No tip is renamed on the way past any more. This used to assign the
|
|
6396
|
+
// computed label to node.name for nameless tips so that toNewHampshire,
|
|
6397
|
+
// which knew only node.name, would write the same token; the caller's
|
|
6398
|
+
// tree was mutated and restored around the call. Now both writers ask
|
|
6399
|
+
// the same function, so there is nothing to patch up.
|
|
6400
|
+
let ext = forester.getAllExternalNodes(phy).reverse();
|
|
6401
|
+
let tips = tipPlaceholders(phy);
|
|
6402
|
+
|
|
6403
|
+
function nexusLabel(node) {
|
|
6404
|
+
return nhNodeLabel(node, tips.get(node));
|
|
6177
6405
|
}
|
|
6178
6406
|
|
|
6179
|
-
let ext = forester.getAllExternalNodes(phy).reverse();
|
|
6180
|
-
// a nameless tip gets its taxa-block label in the TREE as well --
|
|
6181
|
-
// TaxLabels, the Matrix and the Newick must agree on every taxon or
|
|
6182
|
-
// nothing can join them back up (restored before returning, so the
|
|
6183
|
-
// caller's tree is never mutated)
|
|
6184
|
-
let renamed = [];
|
|
6185
|
-
ext.forEach(function (node, i) {
|
|
6186
|
-
if (!node.name) {
|
|
6187
|
-
node.name = nexusLabel(node, i);
|
|
6188
|
-
renamed.push(node);
|
|
6189
|
-
}
|
|
6190
|
-
});
|
|
6191
6407
|
let s = '#NEXUS\n';
|
|
6192
6408
|
s += 'Begin Taxa;\n';
|
|
6193
6409
|
s += ' Dimensions NTax=' + ext.length + ';\n';
|
|
6194
6410
|
s += ' TaxLabels';
|
|
6195
|
-
ext.forEach(function (node
|
|
6196
|
-
s += ' ' + sanitizeLabelForNH(nexusLabel(node
|
|
6411
|
+
ext.forEach(function (node) {
|
|
6412
|
+
s += ' ' + sanitizeLabelForNH(nexusLabel(node));
|
|
6197
6413
|
});
|
|
6198
6414
|
s += ';\n';
|
|
6199
6415
|
s += 'End;\n';
|
|
6200
6416
|
|
|
6201
|
-
|
|
6202
|
-
|
|
6203
|
-
|
|
6417
|
+
// Every tip's label and, where there is one, its molecular sequence.
|
|
6418
|
+
// Written to the desktop's rules (PhylogenyWriter.writeNexusCharactersBlock),
|
|
6419
|
+
// because a Nexus file is a joint artifact: whichever program wrote it,
|
|
6420
|
+
// the other has to read the same bytes back.
|
|
6421
|
+
let labels = ext.map(function (node) {
|
|
6422
|
+
return sanitizeLabelForNH(nexusLabel(node));
|
|
6423
|
+
});
|
|
6424
|
+
let withSeq = [];
|
|
6204
6425
|
ext.forEach(function (node, i) {
|
|
6205
|
-
|
|
6206
|
-
|
|
6426
|
+
let v = molSeqOfNode(node);
|
|
6427
|
+
if (v !== null) {
|
|
6428
|
+
withSeq.push({i: i, value: v});
|
|
6429
|
+
}
|
|
6430
|
+
});
|
|
6431
|
+
if (withSeq.length > 0) {
|
|
6432
|
+
// A matrix is keyed on the taxon label, so two tips sharing one
|
|
6433
|
+
// cannot be told apart: our reader takes the second row for an
|
|
6434
|
+
// interleaved continuation and hands BOTH tips the two sequences
|
|
6435
|
+
// joined together. The Taxa and Trees blocks have always written
|
|
6436
|
+
// such a tree -- invalid Nexus, but only cosmetically; a matrix
|
|
6437
|
+
// would make it corrupting, so it is not written.
|
|
6438
|
+
let seen = Object.create(null);
|
|
6439
|
+
let duplicate = null;
|
|
6440
|
+
for (let k = 0; k < labels.length; ++k) {
|
|
6441
|
+
if (seen[labels[k]]) {
|
|
6442
|
+
duplicate = labels[k];
|
|
6443
|
+
break;
|
|
6444
|
+
}
|
|
6445
|
+
seen[labels[k]] = true;
|
|
6446
|
+
}
|
|
6447
|
+
if (duplicate !== null) {
|
|
6448
|
+
s += '[ Molecular sequences were not written: two or more tips share the taxon label '
|
|
6449
|
+
+ duplicate + ', and a character matrix keyed on an ambiguous label cannot be'
|
|
6450
|
+
+ ' read back. ]\n';
|
|
6451
|
+
withSeq = [];
|
|
6452
|
+
}
|
|
6453
|
+
}
|
|
6454
|
+
if (withSeq.length > 0) {
|
|
6455
|
+
let nchar = withSeq[0].value.length;
|
|
6456
|
+
let ragged = -1;
|
|
6457
|
+
for (let k = 1; k < withSeq.length; ++k) {
|
|
6458
|
+
if (withSeq[k].value.length !== nchar) {
|
|
6459
|
+
ragged = withSeq[k].value.length;
|
|
6460
|
+
break;
|
|
6461
|
+
}
|
|
6207
6462
|
}
|
|
6208
|
-
|
|
6209
|
-
|
|
6210
|
-
|
|
6211
|
-
|
|
6212
|
-
|
|
6213
|
-
|
|
6214
|
-
|
|
6463
|
+
if (ragged >= 0) {
|
|
6464
|
+
// A Nexus matrix is rectangular, so sequences of unequal length
|
|
6465
|
+
// cannot be one: padding them would state an alignment that
|
|
6466
|
+
// does not exist. Say so in the file rather than leaving the
|
|
6467
|
+
// reader to wonder where the data went.
|
|
6468
|
+
s += '[ Molecular sequences were not written: they are of unequal length ('
|
|
6469
|
+
+ nchar + ' vs ' + ragged + '), so they are not an alignment and cannot'
|
|
6470
|
+
+ ' form a Nexus character matrix. ]\n';
|
|
6471
|
+
} else {
|
|
6472
|
+
// The datatype is a property of the whole MATRIX, so every
|
|
6473
|
+
// sequence decides it, not the first one that guesses non-null.
|
|
6474
|
+
// guessMolSeqType looks for residues only protein has, so a
|
|
6475
|
+
// short protein made of nucleotide letters guesses DNA -- and a
|
|
6476
|
+
// matrix wrongly declared DNA is read back with every
|
|
6477
|
+
// non-nucleotide residue replaced by N (MKATSWNP came back
|
|
6478
|
+
// MKATSWNN). Protein therefore wins any disagreement: calling a
|
|
6479
|
+
// nucleotide alignment protein leaves the residues readable,
|
|
6480
|
+
// the reverse destroys them. Gaps and missing symbols are
|
|
6481
|
+
// stripped first so they cannot sway the guess.
|
|
6482
|
+
let datatype = 'Protein';
|
|
6483
|
+
let sawAa = false;
|
|
6484
|
+
let sawNt = false;
|
|
6485
|
+
let isRna = false;
|
|
6486
|
+
for (let k = 0; k < withSeq.length; ++k) {
|
|
6487
|
+
let bare = withSeq[k].value.replace(/[-.?*]/g, '');
|
|
6488
|
+
if (bare.length < 1) {
|
|
6489
|
+
continue;
|
|
6215
6490
|
}
|
|
6216
|
-
|
|
6491
|
+
let t = guessMolSeqType(bare);
|
|
6492
|
+
if (t === 'DNA') {
|
|
6493
|
+
sawNt = true;
|
|
6494
|
+
} else if (t === 'RNA') {
|
|
6495
|
+
sawNt = true;
|
|
6496
|
+
isRna = true;
|
|
6497
|
+
} else if (t !== null) {
|
|
6498
|
+
sawAa = true;
|
|
6499
|
+
}
|
|
6500
|
+
}
|
|
6501
|
+
if (sawNt && !sawAa) {
|
|
6502
|
+
datatype = isRna ? 'RNA' : 'DNA';
|
|
6217
6503
|
}
|
|
6504
|
+
let width = 0;
|
|
6505
|
+
labels.forEach(function (l) {
|
|
6506
|
+
width = Math.max(width, l.length);
|
|
6507
|
+
});
|
|
6508
|
+
++width;
|
|
6509
|
+
let missing = '?'.repeat(nchar);
|
|
6510
|
+
let byTip = Object.create(null);
|
|
6511
|
+
withSeq.forEach(function (r) {
|
|
6512
|
+
byTip[r.i] = r.value;
|
|
6513
|
+
});
|
|
6514
|
+
s += 'Begin Characters;\n';
|
|
6515
|
+
// NChar ONLY: the Nexus standard allows NTax in a CHARACTERS
|
|
6516
|
+
// block's DIMENSIONS solely alongside NEWTAXA (the taxa are the
|
|
6517
|
+
// TAXA block's), and strict readers -- jebl, and so AliView --
|
|
6518
|
+
// reject the file over it
|
|
6519
|
+
s += ' Dimensions NChar=' + nchar + ';\n';
|
|
6520
|
+
s += ' Format DataType=' + datatype + ' Interleave=No Gap=- Missing=?;\n';
|
|
6521
|
+
s += ' Matrix\n';
|
|
6522
|
+
// A row per TAXON, not per sequence: a tip carrying none gets a
|
|
6523
|
+
// row of the missing symbol, so the matrix covers every taxon
|
|
6524
|
+
// the Taxa block declares. parseNexus reads such a row back as
|
|
6525
|
+
// absence of data, never as a sequence of question marks.
|
|
6526
|
+
labels.forEach(function (label, i) {
|
|
6527
|
+
s += ' ' + label + ' '.repeat(width - label.length) + ' '
|
|
6528
|
+
+ (byTip[i] === undefined ? missing : byTip[i]) + '\n';
|
|
6529
|
+
});
|
|
6530
|
+
s += ' ;\n';
|
|
6531
|
+
s += 'End;\n';
|
|
6218
6532
|
}
|
|
6219
|
-
});
|
|
6220
|
-
if (rows.length > 0) {
|
|
6221
|
-
if (!datatype) {
|
|
6222
|
-
// no declared type (e.g. the tree came from Newick plus a
|
|
6223
|
-
// fasta): judge on the residues themselves
|
|
6224
|
-
datatype = forester.msaIsNucleotide(rows[0].value) ? 'dna' : 'protein';
|
|
6225
|
-
}
|
|
6226
|
-
let width = 0;
|
|
6227
|
-
rows.forEach(function (r) {
|
|
6228
|
-
width = Math.max(width, r.label.length);
|
|
6229
|
-
});
|
|
6230
|
-
s += 'Begin Characters;\n';
|
|
6231
|
-
// NChar ONLY: the Nexus standard allows NTax in a CHARACTERS
|
|
6232
|
-
// block's DIMENSIONS solely alongside NEWTAXA (the taxa are the
|
|
6233
|
-
// TAXA block's), and strict readers -- jebl, and so AliView --
|
|
6234
|
-
// reject the file over it
|
|
6235
|
-
s += ' Dimensions NChar=' + nchar + ';\n';
|
|
6236
|
-
s += ' Format DataType=' + datatype + ' Missing=? Gap=-;\n';
|
|
6237
|
-
s += ' Matrix\n';
|
|
6238
|
-
rows.forEach(function (r) {
|
|
6239
|
-
s += ' ' + r.label + ' '.repeat(width - r.label.length + 1) + r.value + '\n';
|
|
6240
|
-
});
|
|
6241
|
-
s += ' ;\n';
|
|
6242
|
-
s += 'End;\n';
|
|
6243
6533
|
}
|
|
6244
6534
|
|
|
6245
6535
|
s += 'Begin Trees;\n';
|
|
@@ -6249,9 +6539,6 @@
|
|
|
6249
6539
|
s += ' Tree ' + (treeName ? sanitizeLabelForNH(treeName) : 'tree1') + '=';
|
|
6250
6540
|
s += (phy.rooted === false) ? '[&U]' : '[&R]';
|
|
6251
6541
|
let nh = forester.toNewHampshire(phy, decPointsMax, true, writeConfidences);
|
|
6252
|
-
renamed.forEach(function (node) {
|
|
6253
|
-
delete node.name;
|
|
6254
|
-
});
|
|
6255
6542
|
if (nh.length === 0) {
|
|
6256
6543
|
// an empty tree would otherwise write "Tree tree1=[&R]" with no
|
|
6257
6544
|
// tree and no terminating ';' -- a syntactically invalid file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "archaeopteryx",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.13.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",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"url": "git+https://github.com/cmzmasek/archaeopteryx-js.git"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"
|
|
36
|
-
"
|
|
35
|
+
"d3": "^7.9.0",
|
|
36
|
+
"phyloxml": "^1.1.1"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@eslint/js": "^9.13.0",
|