archaeopteryx 3.16.0 → 3.18.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 +129 -38
- package/archaeopteryx.d.ts +2 -0
- package/archaeopteryx.js +1166 -255
- package/forester.js +479 -111
- package/package.json +2 -2
package/archaeopteryx.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
// v 3.
|
|
23
|
+
// v 3.18.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.18.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';
|
|
@@ -158,6 +158,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
158
158
|
const XML_SUFFIX = '.xml';
|
|
159
159
|
const FASTA_SUFFIX = '.fasta';
|
|
160
160
|
const NEXUS_SUFFIX = '.nexus';
|
|
161
|
+
const TSV_SUFFIX = '.tsv';
|
|
161
162
|
|
|
162
163
|
|
|
163
164
|
// ---------------------------
|
|
@@ -188,13 +189,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
188
189
|
const BACKGROUND_COLOR_DARK = '#182029';
|
|
189
190
|
const BRANCH_COLOR_DARK = '#8fa1b3';
|
|
190
191
|
const LABEL_COLOR_DARK = '#e7eef5';
|
|
191
|
-
const
|
|
192
|
-
const NAME_FOR_PHYLOXML_DOWNLOAD_DEFAULT = 'archaeopteryx_js' + XML_SUFFIX;
|
|
193
|
-
const NAME_FOR_PDF_DOWNLOAD_DEFAULT = 'archaeopteryx_js' + PDF_SUFFIX;
|
|
194
|
-
const NAME_FOR_PNG_DOWNLOAD_DEFAULT = 'archaeopteryx_js' + PNG_SUFFIX;
|
|
195
|
-
const NAME_FOR_SVG_DOWNLOAD_DEFAULT = 'archaeopteryx_js' + SVG_SUFFIX;
|
|
196
|
-
const NAME_FOR_FASTA_DOWNLOAD_DEFAULT = 'archaeopteryx_js' + FASTA_SUFFIX;
|
|
197
|
-
const NAME_FOR_NEXUS_DOWNLOAD_DEFAULT = 'archaeopteryx_js' + NEXUS_SUFFIX;
|
|
192
|
+
const DOWNLOAD_STEM_DEFAULT = 'archaeopteryx_js';
|
|
198
193
|
const NODE_LABEL_GAP_DEFAULT = 10;
|
|
199
194
|
const NODE_SIZE_DEFAULT_DEFAULT = 3;
|
|
200
195
|
const VISUALIZATIONS_LEGEND_YPOS_DEFAULT = 30;
|
|
@@ -242,6 +237,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
242
237
|
const DUPLICATION_AND_SPECIATION_COLOR_COLOR = '#E69F00';
|
|
243
238
|
const DUPLICATION_COLOR = '#D55E00';
|
|
244
239
|
const FASTA_EXPORT_FORMAT = 'Fasta';
|
|
240
|
+
const TSV_EXPORT_FORMAT = 'TSV';
|
|
245
241
|
const FONT_SIZE_MAX = 26;
|
|
246
242
|
const FONT_SIZE_MIN = 2;
|
|
247
243
|
// branch-data (support / branch-length) text sits 2px under the label
|
|
@@ -361,6 +357,12 @@ function (root, d3, forester, phyloXml) {
|
|
|
361
357
|
const SUPPORT_DOTS_CB = 'suppdots_cb';
|
|
362
358
|
const MAD_VALUES_CB = 'mad_cb';
|
|
363
359
|
const DOWNLOAD_BUTTON = 'dl_b';
|
|
360
|
+
const DOWNLOAD_NAME_FIELD = 'dl_name';
|
|
361
|
+
const DOWNLOAD_AS_SHOWN_BUTTON = 'dl_shown_b';
|
|
362
|
+
const DOWNLOAD_FULL_SIZE_BUTTON = 'dl_full_b';
|
|
363
|
+
const DOWNLOAD_PNG_SCALES = [2, 4, 8];
|
|
364
|
+
const COPY_NEWICK_BUTTON = 'copy_nh_b';
|
|
365
|
+
const COPY_PNG_BUTTON = 'copy_png_b';
|
|
364
366
|
const DYNAHIDE_CB = 'dynahide_cb';
|
|
365
367
|
const MSA_CB = 'msa_cb';
|
|
366
368
|
const MSA_LOGO_CB = 'msa_logo_cb';
|
|
@@ -381,7 +383,6 @@ function (root, d3, forester, phyloXml) {
|
|
|
381
383
|
const TIME_AXIS_CB = 'timeaxis_cb';
|
|
382
384
|
const TIME_GRID_CB = 'timegrid_cb';
|
|
383
385
|
const MSA_SCROLL_ID = 'aptxmsascroll';
|
|
384
|
-
const EXPORT_FORMAT_SELECT = 'exp_f_sel';
|
|
385
386
|
const FONT_SIZE_SLIDER = 'fs_sl';
|
|
386
387
|
const EXTERNAL_LABEL_CB = 'extl_cb';
|
|
387
388
|
const INTERNAL_LABEL_CB = 'intl_cb';
|
|
@@ -498,6 +499,12 @@ function (root, d3, forester, phyloXml) {
|
|
|
498
499
|
// "Instance variables"
|
|
499
500
|
// ---------------------------
|
|
500
501
|
let _baseSvg = null;
|
|
502
|
+
// The Download section's two choices. Full size is the default: a figure
|
|
503
|
+
// is usually wanted whole, and "as shown" crops it to wherever the view
|
|
504
|
+
// happened to be. _pngScale starts from pngExportScale, snapped to the
|
|
505
|
+
// nearest of the scales the section offers.
|
|
506
|
+
let _downloadFullSize = true;
|
|
507
|
+
let _pngScale = 4;
|
|
501
508
|
let _basicTreeProperties = null;
|
|
502
509
|
let _displayHeight = 0;
|
|
503
510
|
let _displayWidth = 0;
|
|
@@ -1567,8 +1574,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
1567
1574
|
}
|
|
1568
1575
|
}
|
|
1569
1576
|
|
|
1570
|
-
// items: [{label, action, danger}]; anchored at the click position
|
|
1571
|
-
|
|
1577
|
+
// items: [{label, action, danger}]; anchored at the click position, or
|
|
1578
|
+
// under (else above) `anchorEl` when one is given. An item may carry a
|
|
1579
|
+
// `detail` line under its label (`warn` tints it), and {heading} starts
|
|
1580
|
+
// a titled group.
|
|
1581
|
+
function showNodeMenu(items, event, titleText, anchorEl) {
|
|
1572
1582
|
removeNodeMenu();
|
|
1573
1583
|
if (!items || items.length < 1) {
|
|
1574
1584
|
return;
|
|
@@ -1596,9 +1606,22 @@ function (root, d3, forester, phyloXml) {
|
|
|
1596
1606
|
menu.appendChild(note);
|
|
1597
1607
|
return;
|
|
1598
1608
|
}
|
|
1609
|
+
if (item.heading) {
|
|
1610
|
+
let heading = document.createElement('div');
|
|
1611
|
+
heading.className = 'aptx-node-menu-title aptx-node-menu-heading';
|
|
1612
|
+
heading.textContent = item.heading;
|
|
1613
|
+
menu.appendChild(heading);
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1599
1616
|
let b = document.createElement('button');
|
|
1600
1617
|
b.type = 'button';
|
|
1601
1618
|
b.textContent = item.label;
|
|
1619
|
+
if (item.detail) {
|
|
1620
|
+
let detail = document.createElement('span');
|
|
1621
|
+
detail.className = 'aptx-menu-detail' + (item.warn ? ' aptx-menu-warn' : '');
|
|
1622
|
+
detail.textContent = item.detail;
|
|
1623
|
+
b.appendChild(detail);
|
|
1624
|
+
}
|
|
1602
1625
|
if (item.title) {
|
|
1603
1626
|
b.title = item.title;
|
|
1604
1627
|
}
|
|
@@ -1618,10 +1641,19 @@ function (root, d3, forester, phyloXml) {
|
|
|
1618
1641
|
document.body.appendChild(menu);
|
|
1619
1642
|
|
|
1620
1643
|
// Place it at the pointer, nudged back inside the window if it would
|
|
1621
|
-
// otherwise hang off the right or bottom edge.
|
|
1644
|
+
// otherwise hang off the right or bottom edge. Opened from a button,
|
|
1645
|
+
// it hangs under the button, or stands on it where there is no room
|
|
1646
|
+
// below -- the Download button sits at the foot of the panel.
|
|
1622
1647
|
let x = (event && event.pageX !== undefined) ? event.pageX : 0;
|
|
1623
1648
|
let y = (event && event.pageY !== undefined) ? event.pageY : 0;
|
|
1624
1649
|
let r = menu.getBoundingClientRect();
|
|
1650
|
+
if (anchorEl) {
|
|
1651
|
+
let a = anchorEl.getBoundingClientRect();
|
|
1652
|
+
let below = document.documentElement.clientHeight - a.bottom;
|
|
1653
|
+
x = window.scrollX + a.left;
|
|
1654
|
+
y = window.scrollY + ((below >= r.height + 12 || a.top < r.height + 12)
|
|
1655
|
+
? a.bottom + 4 : a.top - r.height - 4);
|
|
1656
|
+
}
|
|
1625
1657
|
let maxX = window.scrollX + document.documentElement.clientWidth - r.width - 8;
|
|
1626
1658
|
let maxY = window.scrollY + document.documentElement.clientHeight - r.height - 8;
|
|
1627
1659
|
menu.style.left = Math.max(window.scrollX + 8, Math.min(x, maxX)) + 'px';
|
|
@@ -1643,6 +1675,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
1643
1675
|
if (_nodeMenu && _nodeMenu.contains(e.target)) {
|
|
1644
1676
|
return;
|
|
1645
1677
|
}
|
|
1678
|
+
// A click on the button the menu hangs from closes it and goes no
|
|
1679
|
+
// further: the button's own handler would open it straight
|
|
1680
|
+
// again. Stopped here, in the capture phase at the document, the
|
|
1681
|
+
// click never reaches the button -- so the button toggles.
|
|
1682
|
+
if (anchorEl && anchorEl.contains(e.target)) {
|
|
1683
|
+
e.stopPropagation();
|
|
1684
|
+
e.preventDefault();
|
|
1685
|
+
}
|
|
1646
1686
|
removeNodeMenu();
|
|
1647
1687
|
};
|
|
1648
1688
|
let onKey = function (e) {
|
|
@@ -3207,8 +3247,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
3207
3247
|
if (radialDisplay()) {
|
|
3208
3248
|
return 0;
|
|
3209
3249
|
}
|
|
3210
|
-
|
|
3211
|
-
|
|
3250
|
+
let p = drawnParent(d);
|
|
3251
|
+
if (p) {
|
|
3252
|
+
return (p.y - d.y + 1);
|
|
3212
3253
|
} else {
|
|
3213
3254
|
return 0;
|
|
3214
3255
|
}
|
|
@@ -3228,8 +3269,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
3228
3269
|
if (radialDisplay()) {
|
|
3229
3270
|
return 0;
|
|
3230
3271
|
}
|
|
3231
|
-
|
|
3232
|
-
|
|
3272
|
+
let p = drawnParent(d);
|
|
3273
|
+
if (p) {
|
|
3274
|
+
return (0.5 * (p.y - d.y));
|
|
3233
3275
|
} else {
|
|
3234
3276
|
return 0;
|
|
3235
3277
|
}
|
|
@@ -3250,7 +3292,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
3250
3292
|
if (radialDisplay()) {
|
|
3251
3293
|
return 0;
|
|
3252
3294
|
}
|
|
3253
|
-
|
|
3295
|
+
let p = drawnParent(d);
|
|
3296
|
+
return p ? (0.5 * (p.y - d.y)) : 0;
|
|
3254
3297
|
});
|
|
3255
3298
|
|
|
3256
3299
|
node.select('text.brancheventlabel')
|
|
@@ -3264,8 +3307,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
3264
3307
|
if (radialDisplay()) {
|
|
3265
3308
|
return 0;
|
|
3266
3309
|
}
|
|
3267
|
-
|
|
3268
|
-
|
|
3310
|
+
let p = drawnParent(d);
|
|
3311
|
+
if (p) {
|
|
3312
|
+
return (0.5 * (p.y - d.y));
|
|
3269
3313
|
}
|
|
3270
3314
|
});
|
|
3271
3315
|
|
|
@@ -3978,12 +4022,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
3978
4022
|
if (d.ux === undefined) {
|
|
3979
4023
|
return null;
|
|
3980
4024
|
}
|
|
3981
|
-
let p = (d
|
|
4025
|
+
let p = drawnParent(d);
|
|
4026
|
+
p = (p && p.ux !== undefined) ? p : null;
|
|
3982
4027
|
return p ? [0.5 * (d.ux + p.ux), 0.5 * (d.uy + p.uy), angle] : [d.ux, d.uy, angle];
|
|
3983
4028
|
}
|
|
3984
4029
|
let r = radialRadius(d.y);
|
|
3985
|
-
|
|
3986
|
-
|
|
4030
|
+
let p = drawnParent(d);
|
|
4031
|
+
if (p) {
|
|
4032
|
+
r = 0.5 * (r + radialRadius(p.y));
|
|
3987
4033
|
}
|
|
3988
4034
|
let pt = polarXY(radialAngle(d.x), r);
|
|
3989
4035
|
return [pt[0], pt[1], angle];
|
|
@@ -4019,7 +4065,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
4019
4065
|
// used to claim nothing -- so the first child's number, landing in
|
|
4020
4066
|
// almost the same place, was granted and painted over them. The one
|
|
4021
4067
|
// place the pass could not keep its promise.
|
|
4022
|
-
let
|
|
4068
|
+
let p = drawnParent(d);
|
|
4069
|
+
let span = p ? (p.y - d.y) : 0;
|
|
4023
4070
|
let x = at.mid
|
|
4024
4071
|
? (d.y + (0.5 * span) - (w / 2)) // middle-anchored
|
|
4025
4072
|
: (d.y + span + 1); // start-anchored
|
|
@@ -4435,7 +4482,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
4435
4482
|
// do not rotate, so the radial case needs only the midpoint
|
|
4436
4483
|
let at = radialDisplay()
|
|
4437
4484
|
? radialBranchMid(d)
|
|
4438
|
-
: [d.y + (0.5 * (d.
|
|
4485
|
+
: [d.y + (0.5 * (drawnParent(d).y - d.y)), d.x]; // one use
|
|
4439
4486
|
if (at && !symbols.claim(at[0] - r, at[1] - r, 2 * r, 2 * r)) {
|
|
4440
4487
|
d._suppDot = false;
|
|
4441
4488
|
++_branchMarksHidden;
|
|
@@ -4747,24 +4794,39 @@ function (root, d3, forester, phyloXml) {
|
|
|
4747
4794
|
}
|
|
4748
4795
|
};
|
|
4749
4796
|
|
|
4797
|
+
// The node d's branch is DRAWN from: its parent in this layout. That is
|
|
4798
|
+
// d.parent for every node but one -- in a subtree view the top node hangs
|
|
4799
|
+
// from the wrapper the view was entered with, while its own parent keeps
|
|
4800
|
+
// the coordinates of whatever layout last drew it. Every mark placed
|
|
4801
|
+
// "halfway back to the parent" (support dots, confidence and branch length
|
|
4802
|
+
// values, events) went there: into empty space, and further off with each
|
|
4803
|
+
// subtree entered inside a subtree (Christian, 2026-09-28). Asked, not
|
|
4804
|
+
// stored: a node link kept on every node made the representatives copy
|
|
4805
|
+
// (copyTreeKeepingTips) deep-copy each node's whole ancestor chain --
|
|
4806
|
+
// 5.4 s instead of 5 ms on a 6,000-deep tree (the review measured it).
|
|
4807
|
+
function drawnParent(d) {
|
|
4808
|
+
return (d === topNode()) ? _root : d.parent;
|
|
4809
|
+
}
|
|
4810
|
+
|
|
4750
4811
|
// How long node d's branch is DRAWN, in layout units -- not its
|
|
4751
4812
|
// branch_length: the gate for decorations that need room on the branch.
|
|
4752
4813
|
function drawnBranchSpan(d) {
|
|
4753
|
-
|
|
4814
|
+
let p = drawnParent(d);
|
|
4815
|
+
if (!p) {
|
|
4754
4816
|
return 0;
|
|
4755
4817
|
}
|
|
4756
4818
|
if (_state.unrootedDisplay) {
|
|
4757
|
-
if (d.ux === undefined ||
|
|
4819
|
+
if (d.ux === undefined || p.ux === undefined) {
|
|
4758
4820
|
return 0;
|
|
4759
4821
|
}
|
|
4760
|
-
let dx = d.ux -
|
|
4761
|
-
let dy = d.uy -
|
|
4822
|
+
let dx = d.ux - p.ux;
|
|
4823
|
+
let dy = d.uy - p.uy;
|
|
4762
4824
|
return Math.sqrt((dx * dx) + (dy * dy));
|
|
4763
4825
|
}
|
|
4764
4826
|
if (_state.circularDisplay) {
|
|
4765
|
-
return Math.abs(radialRadius(
|
|
4827
|
+
return Math.abs(radialRadius(p.y) - radialRadius(d.y));
|
|
4766
4828
|
}
|
|
4767
|
-
return Math.abs(
|
|
4829
|
+
return Math.abs(p.y - d.y);
|
|
4768
4830
|
}
|
|
4769
4831
|
|
|
4770
4832
|
// The Support Dots threshold on the tree's own scale: the setting is a
|
|
@@ -4798,7 +4860,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
4798
4860
|
}
|
|
4799
4861
|
|
|
4800
4862
|
function showSupportDot(d) {
|
|
4801
|
-
if (!_state.showSupportDots || !d
|
|
4863
|
+
if (!_state.showSupportDots || !drawnParent(d)
|
|
4802
4864
|
|| !d.confidences || d.confidences.length === 0) {
|
|
4803
4865
|
return false;
|
|
4804
4866
|
}
|
|
@@ -4872,17 +4934,18 @@ function (root, d3, forester, phyloXml) {
|
|
|
4872
4934
|
// Transform for a branch-data label (confidence / branch length / events):
|
|
4873
4935
|
// rotate to the node's angle and sit at the midpoint of the branch (radially).
|
|
4874
4936
|
function branchLabelTransform(d) {
|
|
4875
|
-
|
|
4937
|
+
let p = drawnParent(d);
|
|
4938
|
+
if (!p || (_state.unrootedDisplay && p.ux === undefined)) {
|
|
4876
4939
|
// no laid-out parent (the displayed subtree's root): angle only
|
|
4877
4940
|
return 'rotate(' + labelAngleDeg(d) + ')';
|
|
4878
4941
|
}
|
|
4879
4942
|
let mid;
|
|
4880
4943
|
if (_state.unrootedDisplay) {
|
|
4881
|
-
let dx = d.ux -
|
|
4882
|
-
let dy = d.uy -
|
|
4944
|
+
let dx = d.ux - p.ux;
|
|
4945
|
+
let dy = d.uy - p.uy;
|
|
4883
4946
|
mid = -Math.sqrt((dx * dx) + (dy * dy)) / 2;
|
|
4884
4947
|
} else {
|
|
4885
|
-
mid = (radialRadius(
|
|
4948
|
+
mid = (radialRadius(p.y) - radialRadius(d.y)) / 2;
|
|
4886
4949
|
}
|
|
4887
4950
|
return 'rotate(' + labelAngleDeg(d) + ') translate(' + mid + ',0)' + (labelFlip(d) ? ' rotate(180)' : '');
|
|
4888
4951
|
}
|
|
@@ -5396,11 +5459,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
5396
5459
|
internalNodeFontSize: 'font size is fixed at launch (11px) and changed only via the in-panel Font slider -- not a launch config key',
|
|
5397
5460
|
branchDataFontSize: 'derived from the fixed label size (2px smaller, floor 6px) and changed only via the in-panel Font slider -- not a launch config key',
|
|
5398
5461
|
// download filenames follow the tree's name
|
|
5399
|
-
nameForNhDownload: 'download names follow "treeName"',
|
|
5400
|
-
nameForPhyloXmlDownload: 'download names follow "treeName"',
|
|
5401
|
-
nameForPngDownload: 'download names follow "treeName"',
|
|
5402
|
-
nameForSvgDownload: 'download names follow "treeName"',
|
|
5403
|
-
nameForFastaDownload: 'download names follow "treeName"',
|
|
5462
|
+
nameForNhDownload: 'download names follow "treeName", or the Download section\'s Name field',
|
|
5463
|
+
nameForPhyloXmlDownload: 'download names follow "treeName", or the Download section\'s Name field',
|
|
5464
|
+
nameForPngDownload: 'download names follow "treeName", or the Download section\'s Name field',
|
|
5465
|
+
nameForSvgDownload: 'download names follow "treeName", or the Download section\'s Name field',
|
|
5466
|
+
nameForFastaDownload: 'download names follow "treeName", or the Download section\'s Name field',
|
|
5404
5467
|
// which taxonomy / sequence field to label with is read off the tree
|
|
5405
5468
|
showTaxonomyCode: 'taxonomy labelling follows what the tree contains',
|
|
5406
5469
|
showTaxonomyScientificName: 'taxonomy labelling follows what the tree contains',
|
|
@@ -6004,20 +6067,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
6004
6067
|
// The tree names itself; a caller-supplied name only ever disagreed with
|
|
6005
6068
|
// the file. It is the stem of every download filename.
|
|
6006
6069
|
_state.treeName = _treeData.name ? _treeData.name.trim().replace(/\W+/g, '_') : null;
|
|
6007
|
-
_state.
|
|
6008
|
-
? (_state.treeName + NH_SUFFIX) : NAME_FOR_NH_DOWNLOAD_DEFAULT;
|
|
6009
|
-
_state.nameForPhyloXmlDownload = _state.treeName
|
|
6010
|
-
? (_state.treeName + XML_SUFFIX) : NAME_FOR_PHYLOXML_DOWNLOAD_DEFAULT;
|
|
6011
|
-
_state.nameForPngDownload = _state.treeName
|
|
6012
|
-
? (_state.treeName + PNG_SUFFIX) : NAME_FOR_PNG_DOWNLOAD_DEFAULT;
|
|
6013
|
-
_state.nameForPdfDownload = _state.treeName
|
|
6014
|
-
? (_state.treeName + PDF_SUFFIX) : NAME_FOR_PDF_DOWNLOAD_DEFAULT;
|
|
6015
|
-
_state.nameForSvgDownload = _state.treeName
|
|
6016
|
-
? (_state.treeName + SVG_SUFFIX) : NAME_FOR_SVG_DOWNLOAD_DEFAULT;
|
|
6017
|
-
_state.nameForFastaDownload = _state.treeName
|
|
6018
|
-
? (_state.treeName + FASTA_SUFFIX) : NAME_FOR_FASTA_DOWNLOAD_DEFAULT;
|
|
6019
|
-
_state.nameForNexusDownload = _state.treeName
|
|
6020
|
-
? (_state.treeName + NEXUS_SUFFIX) : NAME_FOR_NEXUS_DOWNLOAD_DEFAULT;
|
|
6070
|
+
_state.downloadStem = _state.treeName || DOWNLOAD_STEM_DEFAULT;
|
|
6021
6071
|
|
|
6022
6072
|
// === undefined, not a truthy check: 0 is a legitimate explicit
|
|
6023
6073
|
// position (the legend's top-left corner) and must survive, not be
|
|
@@ -6399,11 +6449,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
6399
6449
|
// and a tree whose dates carry a unit is never touched. The desktop
|
|
6400
6450
|
// converts at the same point, next to its internal-label policy
|
|
6401
6451
|
// (their HeightDateConverter, 0.11.151).
|
|
6402
|
-
//
|
|
6403
|
-
// lengths AS LOADED, before anything rewrites them
|
|
6404
|
-
//
|
|
6405
|
-
// divergence
|
|
6406
|
-
//
|
|
6452
|
+
// What the Time | Div switch needs to be lossless is kept from the
|
|
6453
|
+
// branch lengths AS LOADED, before anything rewrites them: the
|
|
6454
|
+
// lengths a tree arrived with in time, and on a BEAST clock-model
|
|
6455
|
+
// tree its divergence, each length times its rate (see
|
|
6456
|
+
// forester.captureDivergence).
|
|
6407
6457
|
trees.forEach(function (t) {
|
|
6408
6458
|
forester.captureDivergence(t);
|
|
6409
6459
|
});
|
|
@@ -6884,10 +6934,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
6884
6934
|
} else {
|
|
6885
6935
|
filename = 'External_Node_Data_for_' + ext_nodes.length + '_Nodes.tsv';
|
|
6886
6936
|
}
|
|
6887
|
-
|
|
6888
|
-
return (n.viewId !== undefined) ? n.viewId : i + 1;
|
|
6889
|
-
});
|
|
6890
|
-
saveAs(new Blob([tsv], {type: 'text/tab-separated-values'}), filename);
|
|
6937
|
+
saveAs(new Blob([tipTableTsv(ext_nodes)], {type: 'text/tab-separated-values'}), filename);
|
|
6891
6938
|
update();
|
|
6892
6939
|
}
|
|
6893
6940
|
|
|
@@ -7170,6 +7217,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
7170
7217
|
items.push({label: deleteLabel, danger: true, action: function () {
|
|
7171
7218
|
forester.deleteSubtree(tree, d);
|
|
7172
7219
|
_treeData = tree;
|
|
7220
|
+
branchScaleAfterEdit();
|
|
7173
7221
|
_basicTreeProperties = forester.collectBasicTreeProperties(_treeData);
|
|
7174
7222
|
refreshVisualizations(true);
|
|
7175
7223
|
search0();
|
|
@@ -10239,10 +10287,12 @@ function (root, d3, forester, phyloXml) {
|
|
|
10239
10287
|
|
|
10240
10288
|
/**
|
|
10241
10289
|
* Redraws the tree with its branch lengths taken from the chosen metric:
|
|
10242
|
-
* TIME (the
|
|
10243
|
-
*
|
|
10244
|
-
* tree
|
|
10245
|
-
*
|
|
10290
|
+
* TIME (the lengths the tree arrived with, where it arrived in time; its
|
|
10291
|
+
* date gaps otherwise) or DIVERGENCE (what its nodes record, or on a BEAST
|
|
10292
|
+
* clock-model tree each branch's time times its clock rate). Both stay on
|
|
10293
|
+
* the tree, so this is reversible and lossless -- a round trip restores
|
|
10294
|
+
* the branch lengths exactly. Always the WHOLE tree, whatever is on view.
|
|
10295
|
+
* The rules are forester.hasTimeAndDivergence's, shared with the desktop.
|
|
10246
10296
|
*
|
|
10247
10297
|
* @param scale 'time' or 'divergence'
|
|
10248
10298
|
*/
|
|
@@ -10257,13 +10307,53 @@ function (root, d3, forester, phyloXml) {
|
|
|
10257
10307
|
forester.applyDivergenceBranchLengths(_treeData);
|
|
10258
10308
|
}
|
|
10259
10309
|
// the branch lengths ARE the layout, so everything measured from them
|
|
10260
|
-
// has to be measured again
|
|
10261
|
-
|
|
10262
|
-
|
|
10263
|
-
|
|
10310
|
+
// has to be measured again -- for the tree ON VIEW, as switchToSubtree
|
|
10311
|
+
// measures it: a clade's properties are not the whole tree's
|
|
10312
|
+
_basicTreeProperties = forester.collectBasicTreeProperties(displayedRoot());
|
|
10313
|
+
syncBranchScaleControls();
|
|
10264
10314
|
zoomToFit();
|
|
10265
10315
|
}
|
|
10266
10316
|
|
|
10317
|
+
// The switch's two buttons and whether the pair is shown at all, from the
|
|
10318
|
+
// state: most trees state only one thing, and a control offering a choice
|
|
10319
|
+
// that does not exist is worse than no control.
|
|
10320
|
+
function syncBranchScaleControls() {
|
|
10321
|
+
setCheckboxValue(BRANCH_SCALE_TIME_BUTTON, _state.branchScale === 'time');
|
|
10322
|
+
setCheckboxValue(BRANCH_SCALE_DIV_BUTTON, _state.branchScale !== 'time');
|
|
10323
|
+
let scaleGroup = document.querySelector('.' + BRANCH_SCALE_CONTROLGROUP);
|
|
10324
|
+
if (scaleGroup) {
|
|
10325
|
+
scaleGroup.style.display = _branchScaleAvailable ? '' : 'none';
|
|
10326
|
+
}
|
|
10327
|
+
}
|
|
10328
|
+
|
|
10329
|
+
// The tree was EDITED (a node deleted). Whether it has two layouts is asked
|
|
10330
|
+
// of the tree as it is now, and the layout on screen is laid out again
|
|
10331
|
+
// from what the tree keeps and records: the branch that took the place of
|
|
10332
|
+
// a removed node's is as long as both were, in either layout. A tree that
|
|
10333
|
+
// was never offered the switch still has the branch lengths of its file,
|
|
10334
|
+
// so what it keeps is taken afresh -- deleting the one tip without a
|
|
10335
|
+
// clock rate leaves a tree with a rate on every branch.
|
|
10336
|
+
function branchScaleAfterEdit() {
|
|
10337
|
+
if (!_treeData) {
|
|
10338
|
+
return;
|
|
10339
|
+
}
|
|
10340
|
+
let was = _branchScaleAvailable;
|
|
10341
|
+
if (!was) {
|
|
10342
|
+
forester.captureDivergence(_treeData);
|
|
10343
|
+
}
|
|
10344
|
+
_branchScaleAvailable = forester.hasTimeAndDivergence(_treeData);
|
|
10345
|
+
if (was && _branchScaleAvailable && _state.branchScale === 'divergence') {
|
|
10346
|
+
forester.applyDivergenceBranchLengths(_treeData);
|
|
10347
|
+
} else if (was) {
|
|
10348
|
+
// in time, or no longer a tree with two layouts: back in time
|
|
10349
|
+
forester.applyTimeBranchLengths(_treeData);
|
|
10350
|
+
_state.branchScale = 'time';
|
|
10351
|
+
} else {
|
|
10352
|
+
_state.branchScale = forester.branchLengthScale(_treeData);
|
|
10353
|
+
}
|
|
10354
|
+
syncBranchScaleControls();
|
|
10355
|
+
}
|
|
10356
|
+
|
|
10267
10357
|
function branchScaleButtonClicked() {
|
|
10268
10358
|
setBranchScale(getCheckboxValue(BRANCH_SCALE_TIME_BUTTON) ? 'time' : 'divergence');
|
|
10269
10359
|
}
|
|
@@ -11476,23 +11566,37 @@ function (root, d3, forester, phyloXml) {
|
|
|
11476
11566
|
// the tree's own branch scale, so the bands line up with the branches
|
|
11477
11567
|
// even on a fossil-only tree whose youngest tip is far from the present.
|
|
11478
11568
|
|
|
11569
|
+
// Whether the branch lengths on screen measure divergence: the switch says
|
|
11570
|
+
// so where it is offered; the tree's own unit where it is not (a build
|
|
11571
|
+
// that opened in divergence, a tree saved from Div).
|
|
11572
|
+
function branchesShowDivergence() {
|
|
11573
|
+
if (_branchScaleAvailable) {
|
|
11574
|
+
return _state.branchScale === 'divergence';
|
|
11575
|
+
}
|
|
11576
|
+
return !!_treeData && typeof _treeData.branch_length_unit === 'string'
|
|
11577
|
+
&& _treeData.branch_length_unit.trim() === 'subs/site';
|
|
11578
|
+
}
|
|
11579
|
+
|
|
11479
11580
|
function timeAxisShown() {
|
|
11480
|
-
return _state.showTimeAxis === true && !
|
|
11581
|
+
return _state.showTimeAxis === true && !_state.unrootedDisplay
|
|
11481
11582
|
&& _state.phylogram === true
|
|
11482
11583
|
&& _timeInfo !== null && _timeInfo.type !== null
|
|
11483
11584
|
&& _basicTreeProperties.branchLengths === true
|
|
11484
11585
|
// The axis is an overlay calibrated to the tree's own branch scale,
|
|
11485
|
-
// so it must be hidden when the branches are showing DIVERGENCE
|
|
11486
|
-
//
|
|
11487
|
-
//
|
|
11488
|
-
//
|
|
11489
|
-
//
|
|
11490
|
-
//
|
|
11491
|
-
|
|
11586
|
+
// so it must be hidden when the branches are showing DIVERGENCE --
|
|
11587
|
+
// calendar years against substitutions reads as a confident lie.
|
|
11588
|
+
// Whether the switch is offered is not the question: a Nextstrain
|
|
11589
|
+
// build with one undated node opens in divergence, says so in its
|
|
11590
|
+
// unit, and is refused the switch; its axis was drawn over
|
|
11591
|
+
// substitutions until 2026-09-29. A tree in time shows its axis
|
|
11592
|
+
// (a converted BEAST tree its years) whether or not it has a
|
|
11593
|
+
// second layout.
|
|
11594
|
+
&& !branchesShowDivergence();
|
|
11492
11595
|
}
|
|
11493
11596
|
|
|
11494
11597
|
function timeAxisBottomReserve() {
|
|
11495
|
-
|
|
11598
|
+
// circular draws its ruler in the tree's own gap, not under it
|
|
11599
|
+
if (!timeAxisShown() || _state.circularDisplay) {
|
|
11496
11600
|
return 0;
|
|
11497
11601
|
}
|
|
11498
11602
|
return _timeInfo.type === 'geologic' ? TIME_GEO_RESERVE : TIME_CAL_RESERVE;
|
|
@@ -11652,6 +11756,48 @@ function (root, d3, forester, phyloXml) {
|
|
|
11652
11756
|
}
|
|
11653
11757
|
|
|
11654
11758
|
let sc = info.type === 'calendar' ? -corr : corr;
|
|
11759
|
+
let circular = _state.circularDisplay;
|
|
11760
|
+
|
|
11761
|
+
// A bar spanning depths x0..x1 at node d: a band across the branch in
|
|
11762
|
+
// rectangular, a segment along the node's spoke in circular.
|
|
11763
|
+
function timeBar(d, x0, x1, thick, fill, cls) { // bound to d, for probes
|
|
11764
|
+
let el;
|
|
11765
|
+
if (circular) {
|
|
11766
|
+
// A bound older than the root's own estimate is a NEGATIVE
|
|
11767
|
+
// radius, and the bar runs on through the centre onto the far
|
|
11768
|
+
// side of its spoke's line, as the rectangular bar straddles
|
|
11769
|
+
// the root's x. As on the desktop (hpdRadialRange).
|
|
11770
|
+
let a = radialAngle(d.x);
|
|
11771
|
+
let p0 = polarXY(a, radialRadius(x0));
|
|
11772
|
+
let p1 = polarXY(a, radialRadius(x1));
|
|
11773
|
+
el = g.append('line').attr('x1', p0[0]).attr('y1', p0[1])
|
|
11774
|
+
.attr('x2', p1[0]).attr('y2', p1[1])
|
|
11775
|
+
.attr('stroke', fill).attr('stroke-width', thick);
|
|
11776
|
+
} else {
|
|
11777
|
+
el = g.append('rect').attr('x', Math.min(x0, x1)).attr('y', d.x - (thick / 2))
|
|
11778
|
+
.attr('width', Math.max(1, Math.abs(x1 - x0))).attr('height', thick)
|
|
11779
|
+
.attr('fill', fill);
|
|
11780
|
+
}
|
|
11781
|
+
el.datum(d).attr('class', cls);
|
|
11782
|
+
}
|
|
11783
|
+
// a short stroke ACROSS the branch at depth x: a fossil range's end cap
|
|
11784
|
+
function timeCap(d, x) {
|
|
11785
|
+
let p;
|
|
11786
|
+
let q;
|
|
11787
|
+
if (circular) {
|
|
11788
|
+
let a = radialAngle(d.x);
|
|
11789
|
+
let r = radialRadius(x); // negative past the centre, as the bar's end is
|
|
11790
|
+
let half = 4 / Math.max(Math.abs(r), 4); // 4px either side, as an angle
|
|
11791
|
+
p = polarXY(a - half, r);
|
|
11792
|
+
q = polarXY(a + half, r);
|
|
11793
|
+
} else {
|
|
11794
|
+
p = [x, d.x - 4];
|
|
11795
|
+
q = [x, d.x + 4];
|
|
11796
|
+
}
|
|
11797
|
+
g.append('line').attr('x1', p[0]).attr('y1', p[1]).attr('x2', q[0]).attr('y2', q[1])
|
|
11798
|
+
.attr('stroke', FOSSIL_BAR_COLOR).attr('stroke-width', 1)
|
|
11799
|
+
.datum(d).attr('class', 'aptx-fossil-cap');
|
|
11800
|
+
}
|
|
11655
11801
|
|
|
11656
11802
|
// ---- HPD age bars (internal) + tip bars: fossil ranges, or sampling dates ----
|
|
11657
11803
|
forEachDisplayed(function (d) {
|
|
@@ -11672,9 +11818,15 @@ function (root, d3, forester, phyloXml) {
|
|
|
11672
11818
|
let s = sc;
|
|
11673
11819
|
let xa = d.y - ((max - value) * s);
|
|
11674
11820
|
let xb = d.y + ((value - min) * s);
|
|
11675
|
-
|
|
11676
|
-
|
|
11677
|
-
|
|
11821
|
+
if (circular && Math.abs(radialRadius(xb) - radialRadius(xa)) < 1) {
|
|
11822
|
+
// the 1px floor, along the spoke: from the bar's inner end
|
|
11823
|
+
// outward, as the rectangular bar is floored from its left
|
|
11824
|
+
// end and as the desktop draws it (hpdRadialRange). It was
|
|
11825
|
+
// centred on the interval until 2026-09-29.
|
|
11826
|
+
let inner = Math.min(xa, xb);
|
|
11827
|
+
xa = inner;
|
|
11828
|
+
xb = inner + (_radial.maxY / _radial.maxRad);
|
|
11829
|
+
}
|
|
11678
11830
|
// What a TIP's interval means is the axis's to say. On geologic
|
|
11679
11831
|
// time it is a fossil's observed range, FAD to LAD, and is drawn
|
|
11680
11832
|
// as one below. On CALENDAR time it is the uncertainty of a
|
|
@@ -11687,27 +11839,24 @@ function (root, d3, forester, phyloXml) {
|
|
|
11687
11839
|
// the desktop alike). The width test is the entry guard above.
|
|
11688
11840
|
let sampledTip = !d.children && info.type === 'calendar';
|
|
11689
11841
|
if (d.children) {
|
|
11690
|
-
|
|
11691
|
-
.attr('width', w).attr('height', 7)
|
|
11692
|
-
.attr('fill', HPD_BAR_COLOR);
|
|
11842
|
+
timeBar(d, xa, xb, 7, HPD_BAR_COLOR, 'aptx-age-bar');
|
|
11693
11843
|
} else if (sampledTip) {
|
|
11694
|
-
|
|
11695
|
-
.attr('x', left).attr('y', y - 2.5)
|
|
11696
|
-
.attr('width', w).attr('height', 5)
|
|
11697
|
-
.attr('fill', HPD_BAR_COLOR);
|
|
11844
|
+
timeBar(d, xa, xb, 5, HPD_BAR_COLOR, 'aptx-sampling-bar');
|
|
11698
11845
|
} else {
|
|
11699
|
-
|
|
11700
|
-
.attr('width', w).attr('height', 5)
|
|
11701
|
-
.attr('fill', FOSSIL_BAR_COLOR);
|
|
11846
|
+
timeBar(d, xa, xb, 5, FOSSIL_BAR_COLOR, 'aptx-fossil-bar');
|
|
11702
11847
|
// FAD/LAD end caps, so the range reads as a bracketed interval
|
|
11703
|
-
|
|
11704
|
-
|
|
11705
|
-
|
|
11706
|
-
|
|
11707
|
-
});
|
|
11848
|
+
let lo = Math.min(xa, xb);
|
|
11849
|
+
let hi = Math.max(xa, xb, lo + 1);
|
|
11850
|
+
timeCap(d, lo);
|
|
11851
|
+
timeCap(d, hi);
|
|
11708
11852
|
}
|
|
11709
11853
|
});
|
|
11710
11854
|
|
|
11855
|
+
if (circular) {
|
|
11856
|
+
drawCircularTimeRuler(info, anchor, maxTipX, corr, g, grid, ink);
|
|
11857
|
+
return;
|
|
11858
|
+
}
|
|
11859
|
+
|
|
11711
11860
|
// ---- the axis itself: a FLOATING strip (see floatStripGroup) ----
|
|
11712
11861
|
let ax = floatStripGroup('aptx-time-axis', axisTop - 4, timeAxisBottomReserve() + 4);
|
|
11713
11862
|
function axisBackdrop(x0, x1) {
|
|
@@ -11864,6 +12013,151 @@ function (root, d3, forester, phyloXml) {
|
|
|
11864
12013
|
placeFloatingOverlays();
|
|
11865
12014
|
}
|
|
11866
12015
|
|
|
12016
|
+
// The circular layout's time axis. Its ruler runs along the gap between
|
|
12017
|
+
// the last tip and the first -- the one ray no branch crosses -- from the
|
|
12018
|
+
// root out to the tips, so it turns with the tree. "Time Grid" draws each
|
|
12019
|
+
// tick as a full ring behind the tree; a geologic axis lays its intervals
|
|
12020
|
+
// down as coloured rings, as the desktop does.
|
|
12021
|
+
function drawCircularTimeRuler(info, anchor, maxTipX, corr, g, grid, ink) {
|
|
12022
|
+
let depthOf; // a date -> depth, the axis the radius is drawn from
|
|
12023
|
+
let ticks = [];
|
|
12024
|
+
let from; // the root end and the tip end, as dates
|
|
12025
|
+
let to;
|
|
12026
|
+
let bands = [];
|
|
12027
|
+
if (info.type === 'geologic') {
|
|
12028
|
+
let rootAge = info.rootAge;
|
|
12029
|
+
if (!(rootAge > 0)) {
|
|
12030
|
+
return;
|
|
12031
|
+
}
|
|
12032
|
+
let anchorAge = anchor ? anchor.date.value : rootAge;
|
|
12033
|
+
let anchorX = anchor ? anchor.y : _root.y;
|
|
12034
|
+
depthOf = function (age) {
|
|
12035
|
+
return anchorX + ((anchorAge - age) * corr);
|
|
12036
|
+
};
|
|
12037
|
+
from = rootAge;
|
|
12038
|
+
to = Math.max(0, anchorAge - ((maxTipX - anchorX) / corr));
|
|
12039
|
+
forester.maAxisTickValues(rootAge).forEach(function (v) {
|
|
12040
|
+
if (v >= to - 1e-9 && v <= rootAge + 1e-9) {
|
|
12041
|
+
ticks.push({value: v, text: String(v)});
|
|
12042
|
+
}
|
|
12043
|
+
});
|
|
12044
|
+
if (to > 0) {
|
|
12045
|
+
ticks.push({value: to, text: String(Math.round(to * 100) / 100), first: true});
|
|
12046
|
+
}
|
|
12047
|
+
bands = forester.geoOverlapping(forester.geoBandRanks(to, rootAge)[1], to, rootAge);
|
|
12048
|
+
} else {
|
|
12049
|
+
let present = info.presentDate;
|
|
12050
|
+
if (!(present > 0)) {
|
|
12051
|
+
return;
|
|
12052
|
+
}
|
|
12053
|
+
let anchorYear = anchor ? anchor.date.value : present;
|
|
12054
|
+
let anchorX = anchor ? anchor.y : maxTipX;
|
|
12055
|
+
depthOf = function (yv) {
|
|
12056
|
+
return anchorX - ((anchorYear - yv) * corr);
|
|
12057
|
+
};
|
|
12058
|
+
from = anchorYear - ((anchorX - _root.y) / corr);
|
|
12059
|
+
to = present;
|
|
12060
|
+
forester.calendarTickYears(from, to).forEach(function (yv) {
|
|
12061
|
+
ticks.push({value: yv, text: String(Math.round(yv))});
|
|
12062
|
+
});
|
|
12063
|
+
}
|
|
12064
|
+
let radiusOf = function (v) {
|
|
12065
|
+
return Math.max(0, radialRadius(depthOf(v)));
|
|
12066
|
+
};
|
|
12067
|
+
let r0 = radiusOf(from);
|
|
12068
|
+
let r1 = radiusOf(to);
|
|
12069
|
+
if (!(r1 > r0)) {
|
|
12070
|
+
return;
|
|
12071
|
+
}
|
|
12072
|
+
// behind the tree: the geologic rings, then the grid rings
|
|
12073
|
+
let behind = null;
|
|
12074
|
+
if (bands.length > 0 || grid) {
|
|
12075
|
+
behind = grid || _svgGroup.insert('g', 'g').attr('class', 'aptx-timegrid')
|
|
12076
|
+
.style('pointer-events', 'none');
|
|
12077
|
+
}
|
|
12078
|
+
let ring = d3.arc().startAngle(0).endAngle(2 * Math.PI);
|
|
12079
|
+
let named = [];
|
|
12080
|
+
bands.forEach(function (iv) {
|
|
12081
|
+
let a = radiusOf(Math.min(iv.old, from));
|
|
12082
|
+
let b = radiusOf(Math.max(iv.young, to));
|
|
12083
|
+
if (b - a <= 0) {
|
|
12084
|
+
return;
|
|
12085
|
+
}
|
|
12086
|
+
behind.append('path').attr('d', ring.innerRadius(a).outerRadius(b)())
|
|
12087
|
+
.attr('fill', iv.color).attr('fill-opacity', 0.35);
|
|
12088
|
+
named.push({r: (a + b) / 2, span: b - a, text: iv.name, color: iv.color});
|
|
12089
|
+
});
|
|
12090
|
+
if (grid) {
|
|
12091
|
+
ticks.forEach(function (t) {
|
|
12092
|
+
let r = radiusOf(t.value);
|
|
12093
|
+
if (r > r0 + 0.5 && r < r1 - 0.5) {
|
|
12094
|
+
grid.append('circle').attr('r', r).attr('fill', 'none')
|
|
12095
|
+
.attr('stroke', ink).attr('stroke-opacity', 0.18).attr('stroke-width', 1);
|
|
12096
|
+
}
|
|
12097
|
+
});
|
|
12098
|
+
}
|
|
12099
|
+
|
|
12100
|
+
// the ruler, on the gap's centre line
|
|
12101
|
+
let gap = _radialRotation + _radial.angleSpan + ((2 * Math.PI - _radial.angleSpan) / 2);
|
|
12102
|
+
let screen = gap - (Math.PI / 2);
|
|
12103
|
+
let along = [Math.cos(screen), Math.sin(screen)];
|
|
12104
|
+
let across = [-along[1], along[0]];
|
|
12105
|
+
let flip = along[0] < -1e-9; // keep every number upright
|
|
12106
|
+
let deg = (screen * 180 / Math.PI) + (flip ? 180 : 0);
|
|
12107
|
+
let at = function (r, off) {
|
|
12108
|
+
return [(along[0] * r) + (across[0] * off), (along[1] * r) + (across[1] * off)];
|
|
12109
|
+
};
|
|
12110
|
+
let ax = g.append('g').attr('class', 'aptx-time-ruler');
|
|
12111
|
+
let e0 = at(r0, 0);
|
|
12112
|
+
let e1 = at(r1, 0);
|
|
12113
|
+
ax.append('line').attr('x1', e0[0]).attr('y1', e0[1]).attr('x2', e1[0]).attr('y2', e1[1])
|
|
12114
|
+
.attr('stroke', ink).attr('stroke-width', 1);
|
|
12115
|
+
// numbers on one side of the ruler, interval names on the other; the
|
|
12116
|
+
// headline value first, then the round ticks outward, none overlapping
|
|
12117
|
+
function label(r, off, text, fill, backdrop) {
|
|
12118
|
+
let p = at(r, off);
|
|
12119
|
+
let t = ax.append('g')
|
|
12120
|
+
.attr('transform', 'translate(' + p[0] + ',' + p[1] + ') rotate(' + deg + ')');
|
|
12121
|
+
let w = text.length * 5.5;
|
|
12122
|
+
if (backdrop) {
|
|
12123
|
+
t.append('rect').attr('x', -(w / 2) - 2).attr('y', -6).attr('width', w + 4).attr('height', 12)
|
|
12124
|
+
.attr('rx', 2).attr('fill', _state.backgroundColorDefault).attr('fill-opacity', 0.85);
|
|
12125
|
+
}
|
|
12126
|
+
t.append('text').attr('text-anchor', 'middle').attr('dominant-baseline', 'central')
|
|
12127
|
+
.style('font-size', '9px').style('fill', fill).text(text);
|
|
12128
|
+
}
|
|
12129
|
+
let side = flip ? -1 : 1;
|
|
12130
|
+
let placed = [];
|
|
12131
|
+
ticks.sort(function (p, q) {
|
|
12132
|
+
return (q.first ? 1 : 0) - (p.first ? 1 : 0) || radiusOf(p.value) - radiusOf(q.value);
|
|
12133
|
+
});
|
|
12134
|
+
ticks.forEach(function (t) {
|
|
12135
|
+
let r = radiusOf(t.value);
|
|
12136
|
+
let c0 = at(r, -4);
|
|
12137
|
+
let c1 = at(r, 4);
|
|
12138
|
+
ax.append('line').attr('x1', c0[0]).attr('y1', c0[1]).attr('x2', c1[0]).attr('y2', c1[1])
|
|
12139
|
+
.attr('stroke', ink).attr('stroke-width', 1);
|
|
12140
|
+
let half = t.text.length * 2.8;
|
|
12141
|
+
for (let i = 0; i < placed.length; ++i) {
|
|
12142
|
+
if (Math.abs(placed[i] - r) < (half * 2) + 6) {
|
|
12143
|
+
if (!t.first) {
|
|
12144
|
+
return;
|
|
12145
|
+
}
|
|
12146
|
+
}
|
|
12147
|
+
}
|
|
12148
|
+
label(r, side * 11, t.text, ink, true);
|
|
12149
|
+
placed.push(r);
|
|
12150
|
+
});
|
|
12151
|
+
if (info.type === 'geologic') {
|
|
12152
|
+
label(r1 + 14, 0, 'Ma', ink, false);
|
|
12153
|
+
named.forEach(function (n) {
|
|
12154
|
+
if ((n.text.length * 5.5) + 4 <= n.span) {
|
|
12155
|
+
label(n.r, -side * 11, n.text, ink, true);
|
|
12156
|
+
}
|
|
12157
|
+
});
|
|
12158
|
+
}
|
|
12159
|
+
}
|
|
12160
|
+
|
|
11867
12161
|
function timeAxisCbClicked() {
|
|
11868
12162
|
_state.showTimeAxis = getCheckboxValue(TIME_AXIS_CB);
|
|
11869
12163
|
scheduleUpdate(null, 0);
|
|
@@ -13020,12 +13314,13 @@ function (root, d3, forester, phyloXml) {
|
|
|
13020
13314
|
syncHeatmapControls();
|
|
13021
13315
|
let timeCb = byId(TIME_AXIS_CB);
|
|
13022
13316
|
if (timeCb) {
|
|
13023
|
-
|
|
13317
|
+
// circular draws it along the gap; only unrooted cannot
|
|
13318
|
+
timeCb.disabled = _state.unrootedDisplay === true;
|
|
13024
13319
|
}
|
|
13025
13320
|
let timeGridCb = byId(TIME_GRID_CB);
|
|
13026
13321
|
if (timeGridCb) {
|
|
13027
13322
|
// grid lines hang off the time axis: no axis, nothing to grid
|
|
13028
|
-
timeGridCb.disabled =
|
|
13323
|
+
timeGridCb.disabled = _state.unrootedDisplay === true || _state.showTimeAxis !== true;
|
|
13029
13324
|
}
|
|
13030
13325
|
let minus = byId(ZOOM_OUT_X);
|
|
13031
13326
|
let plus = byId(ZOOM_IN_X);
|
|
@@ -13272,14 +13567,165 @@ function (root, d3, forester, phyloXml) {
|
|
|
13272
13567
|
scheduleUpdate(null, 0);
|
|
13273
13568
|
}
|
|
13274
13569
|
|
|
13275
|
-
|
|
13276
|
-
|
|
13277
|
-
|
|
13278
|
-
|
|
13279
|
-
|
|
13570
|
+
// The Download button opens the format menu under itself; pressed again
|
|
13571
|
+
// while the menu is open, it closes it.
|
|
13572
|
+
function downloadButtonPressed(event) {
|
|
13573
|
+
let btn = event.currentTarget;
|
|
13574
|
+
showNodeMenu(downloadMenuItems(), null, null, btn);
|
|
13575
|
+
// opened from the keyboard (a click with no pointer), the keyboard
|
|
13576
|
+
// goes into the menu: Tab walks the formats, Escape closes it
|
|
13577
|
+
if (event.detail === 0 && _nodeMenu) {
|
|
13578
|
+
let first = _nodeMenu.querySelector('button');
|
|
13579
|
+
if (first) {
|
|
13580
|
+
first.focus();
|
|
13581
|
+
}
|
|
13280
13582
|
}
|
|
13281
13583
|
}
|
|
13282
13584
|
|
|
13585
|
+
// Every format says what it keeps: a Newick file silently drops
|
|
13586
|
+
// everything but names, lengths and support, and a user picking by
|
|
13587
|
+
// extension would never know.
|
|
13588
|
+
function downloadMenuItems() {
|
|
13589
|
+
let items = [];
|
|
13590
|
+
let entry = function (format) {
|
|
13591
|
+
return function () {
|
|
13592
|
+
downloadTree(format);
|
|
13593
|
+
};
|
|
13594
|
+
};
|
|
13595
|
+
items.push({heading: 'Graphics, ' + (_downloadFullSize ? 'full size' : 'as shown')});
|
|
13596
|
+
items.push({label: 'SVG', detail: 'vector: edit it in Inkscape or Illustrator',
|
|
13597
|
+
action: entry(SVG_EXPORT_FORMAT)});
|
|
13598
|
+
if (pdfExportAvailable()) {
|
|
13599
|
+
items.push({label: 'PDF', detail: 'vector: ready to print or to put in a paper',
|
|
13600
|
+
action: entry(PDF_EXPORT_FORMAT)});
|
|
13601
|
+
}
|
|
13602
|
+
if (pngExportAvailable()) {
|
|
13603
|
+
items.push({label: 'PNG', detail: 'image, at ' + _pngScale + '× the screen\'s resolution',
|
|
13604
|
+
action: entry(PNG_EXPORT_FORMAT)});
|
|
13605
|
+
}
|
|
13606
|
+
let part = subtreeExtentNote();
|
|
13607
|
+
items.push({heading: part ? 'Tree: ' + part : 'Tree'});
|
|
13608
|
+
items.push({label: 'phyloXML', detail: 'keeps everything the tree carries: taxonomy, sequences, dates, properties',
|
|
13609
|
+
action: entry(PHYLOXML_EXPORT_FORMAT)});
|
|
13610
|
+
items.push({label: 'Newick', detail: 'names, branch lengths and support values only',
|
|
13611
|
+
action: entry(NH_EXPORT_FORMAT)});
|
|
13612
|
+
let nexus = nexusDownloadDetail();
|
|
13613
|
+
items.push({label: 'Nexus', detail: nexus.text, warn: nexus.warn, action: entry(NEXUS_EXPORT_FORMAT)});
|
|
13614
|
+
// FASTA only when there is something to write: a tip carrying a
|
|
13615
|
+
// molecular sequence, aligned or not (same gate as the node menu's
|
|
13616
|
+
// Fasta entries) -- on any other tree the download would be empty
|
|
13617
|
+
items.push({heading: part ? 'Tip data: ' + part : 'Tip data'});
|
|
13618
|
+
items.push({label: 'TSV', detail: 'a table, one row per tip: names, taxonomy, sequence names, '
|
|
13619
|
+
+ 'branch lengths and every property', action: entry(TSV_EXPORT_FORMAT)});
|
|
13620
|
+
if (_basicTreeProperties && _basicTreeProperties.maxMolSeqLength > 0) {
|
|
13621
|
+
items.push({label: 'FASTA', detail: 'every molecular sequence the tips carry',
|
|
13622
|
+
action: entry(FASTA_EXPORT_FORMAT)});
|
|
13623
|
+
}
|
|
13624
|
+
return items;
|
|
13625
|
+
}
|
|
13626
|
+
|
|
13627
|
+
// What a Nexus download will hold, asked of the WRITER rather than
|
|
13628
|
+
// predicted: toNexus decides whether the sequences can form a matrix
|
|
13629
|
+
// (equal lengths, unambiguous labels), and when they cannot it says why
|
|
13630
|
+
// in a bracketed comment -- which is the sentence shown here, so the menu
|
|
13631
|
+
// and the file can never disagree.
|
|
13632
|
+
function nexusDownloadDetail() {
|
|
13633
|
+
let plain = {text: 'the tree: names, branch lengths and support values', warn: false};
|
|
13634
|
+
if (!_root || !_basicTreeProperties || !(_basicTreeProperties.maxMolSeqLength > 0)) {
|
|
13635
|
+
return plain;
|
|
13636
|
+
}
|
|
13637
|
+
let nex;
|
|
13638
|
+
try {
|
|
13639
|
+
nex = forester.toNexus(treeForExport(), 9, _settings.nhExportWriteConfidences);
|
|
13640
|
+
} catch {
|
|
13641
|
+
return plain;
|
|
13642
|
+
}
|
|
13643
|
+
if (nex.indexOf('Begin Characters;') >= 0) {
|
|
13644
|
+
return {text: 'the tree and its alignment, in one file', warn: false};
|
|
13645
|
+
}
|
|
13646
|
+
// to the comment's own closing " ]" at the end of its line: a taxon
|
|
13647
|
+
// label quoted inside the reason may itself hold a ']'
|
|
13648
|
+
let why = /\[ Molecular sequences were not written: (.*?) \]$/m.exec(nex);
|
|
13649
|
+
return {
|
|
13650
|
+
text: 'the tree only, without the sequences: ' + (why ? why[1] : 'they cannot form a matrix')
|
|
13651
|
+
+ ' phyloXML and FASTA keep them.',
|
|
13652
|
+
warn: true
|
|
13653
|
+
};
|
|
13654
|
+
}
|
|
13655
|
+
|
|
13656
|
+
// The file name the user typed, made safe, with the format's extension.
|
|
13657
|
+
// A typed extension is not doubled up ("tree.svg" saves as tree.svg, and
|
|
13658
|
+
// as tree.png from the PNG entry), and an empty field falls back to the
|
|
13659
|
+
// tree's name.
|
|
13660
|
+
function downloadFileName(suffix) {
|
|
13661
|
+
let field = byId(DOWNLOAD_NAME_FIELD);
|
|
13662
|
+
let stem = (field ? field.value : '').trim().replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '_');
|
|
13663
|
+
let lower = stem.toLowerCase();
|
|
13664
|
+
[NH_SUFFIX, NEXUS_SUFFIX, XML_SUFFIX, FASTA_SUFFIX, TSV_SUFFIX, SVG_SUFFIX, PDF_SUFFIX, PNG_SUFFIX,
|
|
13665
|
+
'.nex', '.nwk', '.newick', '.nh', '.nhx', '.tree', '.phyloxml', '.fa', '.fas', '.txt',
|
|
13666
|
+
'.csv'].some(function (x) {
|
|
13667
|
+
if (lower.length > x.length && lower.endsWith(x)) {
|
|
13668
|
+
stem = stem.substring(0, stem.length - x.length);
|
|
13669
|
+
return true;
|
|
13670
|
+
}
|
|
13671
|
+
return false;
|
|
13672
|
+
});
|
|
13673
|
+
if (stem.replace(/[._\s]/g, '') === '') {
|
|
13674
|
+
stem = _state.downloadStem;
|
|
13675
|
+
}
|
|
13676
|
+
return stem + suffix;
|
|
13677
|
+
}
|
|
13678
|
+
|
|
13679
|
+
function copyNewickPressed(event) {
|
|
13680
|
+
let btn = event.currentTarget;
|
|
13681
|
+
let nh = _root ? forester.toNewHampshire(_root, 9, true, _settings.nhExportWriteConfidences) : '';
|
|
13682
|
+
copyText(nh).then(function (ok) {
|
|
13683
|
+
flashButton(btn, ok ? 'Copied ✓' : 'Copy failed');
|
|
13684
|
+
});
|
|
13685
|
+
}
|
|
13686
|
+
|
|
13687
|
+
// The async clipboard API where the page allows it (a secure context);
|
|
13688
|
+
// otherwise the old select-and-copy, so a page served over plain http
|
|
13689
|
+
// still copies. Resolves to whether the text made it.
|
|
13690
|
+
function copyText(text) {
|
|
13691
|
+
let legacy = function () {
|
|
13692
|
+
let ta = document.createElement('textarea');
|
|
13693
|
+
ta.value = text;
|
|
13694
|
+
ta.setAttribute('readonly', '');
|
|
13695
|
+
ta.style.position = 'fixed';
|
|
13696
|
+
ta.style.left = '-10000px';
|
|
13697
|
+
document.body.appendChild(ta);
|
|
13698
|
+
ta.select();
|
|
13699
|
+
let ok = false;
|
|
13700
|
+
try {
|
|
13701
|
+
ok = document.execCommand('copy');
|
|
13702
|
+
} catch {
|
|
13703
|
+
ok = false;
|
|
13704
|
+
}
|
|
13705
|
+
ta.remove();
|
|
13706
|
+
return ok;
|
|
13707
|
+
};
|
|
13708
|
+
if (navigator.clipboard && window.isSecureContext) {
|
|
13709
|
+
return navigator.clipboard.writeText(text).then(function () {
|
|
13710
|
+
return true;
|
|
13711
|
+
}, legacy);
|
|
13712
|
+
}
|
|
13713
|
+
return Promise.resolve(legacy());
|
|
13714
|
+
}
|
|
13715
|
+
|
|
13716
|
+
// A button says what just happened on its own face for a moment, then
|
|
13717
|
+
// goes back to its name: no toast to find, and nothing left behind.
|
|
13718
|
+
function flashButton(btn, text) {
|
|
13719
|
+
if (btn.dataset.label === undefined) {
|
|
13720
|
+
btn.dataset.label = btn.textContent;
|
|
13721
|
+
}
|
|
13722
|
+
btn.textContent = text;
|
|
13723
|
+
clearTimeout(btn._flashTimer);
|
|
13724
|
+
btn._flashTimer = setTimeout(function () {
|
|
13725
|
+
btn.textContent = btn.dataset.label;
|
|
13726
|
+
}, 1600);
|
|
13727
|
+
}
|
|
13728
|
+
|
|
13283
13729
|
function changeBaseBackgoundColor(color) {
|
|
13284
13730
|
setStylesAll('.' + BASE_BACKGROUND, {
|
|
13285
13731
|
'fill': color
|
|
@@ -13543,7 +13989,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
13543
13989
|
// of open sections: three open sections measure anywhere from 215px to
|
|
13544
13990
|
// 562px, so a count would not have bounded the height at all.
|
|
13545
13991
|
const PANEL_SECTIONS_KEY = 'aptx-panel-sections';
|
|
13546
|
-
|
|
13992
|
+
// Download too (Christian, 2026-09-28): four rows, and not something a
|
|
13993
|
+
// session uses often.
|
|
13994
|
+
const PANEL_SECTIONS_CLOSED_BY_DEFAULT = ['View & Tools', 'Sizes', 'Domain Architectures', 'Download'];
|
|
13547
13995
|
|
|
13548
13996
|
function loadPanelSections() {
|
|
13549
13997
|
if (_panelSections) {
|
|
@@ -13558,6 +14006,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
13558
14006
|
_panelSections[k] = saved[k];
|
|
13559
14007
|
}
|
|
13560
14008
|
});
|
|
14009
|
+
// the section was called Zoom until 2026-09-30: a fold
|
|
14010
|
+
// remembered under that name still counts
|
|
14011
|
+
if (typeof saved.Zoom === 'boolean' && _panelSections['View & Tools'] === undefined) {
|
|
14012
|
+
_panelSections['View & Tools'] = saved.Zoom;
|
|
14013
|
+
}
|
|
13561
14014
|
}
|
|
13562
14015
|
} catch {
|
|
13563
14016
|
// no storage (private mode), or junk written by something else:
|
|
@@ -13998,13 +14451,13 @@ function (root, d3, forester, phyloXml) {
|
|
|
13998
14451
|
}
|
|
13999
14452
|
// Dark palette tokens, shared by the system-preference default and the
|
|
14000
14453
|
// explicit "dark" choice from the header light/dark switch.
|
|
14001
|
-
let dark = ' --p-bg:rgba(24,35,46,0.86); --p-ink:#e7eef5; --p-muted:#94a4b3; --p-faint:#6f8090;'
|
|
14454
|
+
let dark = ' --p-bg:rgba(24,35,46,0.86); --p-ink:#e7eef5; --p-muted:#94a4b3; --p-faint:#6f8090; --p-warn:#f0a35e;'
|
|
14002
14455
|
+ ' --p-line:#27343f; --p-line-strong:#35434f; --p-surface2:#202d38;'
|
|
14003
14456
|
+ ' --p-accent:#57a6ff; --p-accent-ink:#9cc7ff; --p-accent-weak:rgba(87,166,255,0.18);'
|
|
14004
14457
|
+ ' --p-shadow-sm:0 1px 2px rgba(0,0,0,0.4);';
|
|
14005
14458
|
let css = ''
|
|
14006
14459
|
+ '.aptx-panel {'
|
|
14007
|
-
+ ' --p-bg: rgba(255,255,255,0.86); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2;'
|
|
14460
|
+
+ ' --p-bg: rgba(255,255,255,0.86); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2; --p-warn:#b15a0b;'
|
|
14008
14461
|
+ ' --p-line:#e3e9f0; --p-line-strong:#cad6e1; --p-surface2:#f3f6fa;'
|
|
14009
14462
|
+ ' --p-accent:#2f83f2; --p-accent-ink:#1c5fbf; --p-accent-weak:rgba(47,131,242,0.12);'
|
|
14010
14463
|
+ ' --p-shadow-sm:0 1px 2px rgba(23,34,46,0.12);'
|
|
@@ -14031,7 +14484,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
14031
14484
|
// (they cannot live inside the panel), so they carry their own
|
|
14032
14485
|
// copy of the palette and follow the same light / dark rules.
|
|
14033
14486
|
+ '.aptx-node-menu, .aptx-suggest {'
|
|
14034
|
-
+ ' --p-bg: rgba(255,255,255,0.97); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2;'
|
|
14487
|
+
+ ' --p-bg: rgba(255,255,255,0.97); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2; --p-warn:#b15a0b;'
|
|
14035
14488
|
+ ' --p-line:#e3e9f0; --p-line-strong:#cad6e1; --p-surface2:#f3f6fa;'
|
|
14036
14489
|
+ ' --p-accent:#2f83f2; --p-accent-ink:#1c5fbf; --p-accent-weak:rgba(47,131,242,0.12);'
|
|
14037
14490
|
+ '}'
|
|
@@ -14061,6 +14514,17 @@ function (root, d3, forester, phyloXml) {
|
|
|
14061
14514
|
+ ' background:#e5484d; color:#fff; }'
|
|
14062
14515
|
+ '.aptx-node-menu hr { border:0; border-top:1px solid var(--p-line); margin:3px 4px; }'
|
|
14063
14516
|
+ '.aptx-node-menu-note { padding:4px 9px 7px; color:var(--p-ink); white-space:normal; }'
|
|
14517
|
+
// A group title inside a menu (the Download menu's Graphics / Tree
|
|
14518
|
+
// / Sequences): the title's type, set off from the group above.
|
|
14519
|
+
+ '.aptx-node-menu-heading { margin-top:4px; }'
|
|
14520
|
+
+ '.aptx-node-menu-heading:first-child { margin-top:0; }'
|
|
14521
|
+
// an entry's second line: what that choice keeps or does
|
|
14522
|
+
+ '.aptx-node-menu button .aptx-menu-detail { display:block; white-space:normal; font-size:10px;'
|
|
14523
|
+
+ ' line-height:1.35; color:var(--p-muted); margin-top:1px; }'
|
|
14524
|
+
+ '.aptx-node-menu button .aptx-menu-detail.aptx-menu-warn { color:var(--p-warn); }'
|
|
14525
|
+
+ '.aptx-node-menu button:hover .aptx-menu-detail, .aptx-node-menu button:focus-visible .aptx-menu-detail {'
|
|
14526
|
+
+ ' color:inherit; opacity:0.86; }'
|
|
14527
|
+
+ '.aptx-node-menu:has(.aptx-menu-detail) { width:264px; max-width:264px; }'
|
|
14064
14528
|
// The search suggestions: the node menu's chrome under the value box.
|
|
14065
14529
|
+ '.aptx-suggest { position:absolute; z-index:1000; max-width:320px; padding:4px; box-sizing:border-box;'
|
|
14066
14530
|
+ ' border:1px solid var(--p-line-strong); border-radius:10px; background:var(--p-bg); color:var(--p-ink);'
|
|
@@ -14079,7 +14543,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
14079
14543
|
+ ' font-size:10px; color:var(--p-faint); }'
|
|
14080
14544
|
// The node-data dialog, on the same palette as the panel and the menu.
|
|
14081
14545
|
+ '.aptx-dialog, .aptx-busy, .aptx-msa-nav {'
|
|
14082
|
-
+ ' --p-bg: rgba(255,255,255,0.98); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2;'
|
|
14546
|
+
+ ' --p-bg: rgba(255,255,255,0.98); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2; --p-warn:#b15a0b;'
|
|
14083
14547
|
+ ' --p-line:#e3e9f0; --p-line-strong:#cad6e1; --p-surface2:#f3f6fa;'
|
|
14084
14548
|
+ ' --p-accent:#2f83f2; --p-accent-ink:#1c5fbf; --p-accent-weak:rgba(47,131,242,0.12);'
|
|
14085
14549
|
+ '}'
|
|
@@ -14229,7 +14693,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
14229
14693
|
+ ' font-size:11px; white-space:pre-wrap; overflow-wrap:anywhere; }'
|
|
14230
14694
|
// The hover tooltip, on the same palette.
|
|
14231
14695
|
+ '.aptx-tip {'
|
|
14232
|
-
+ ' --p-bg: rgba(255,255,255,0.97); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2;'
|
|
14696
|
+
+ ' --p-bg: rgba(255,255,255,0.97); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2; --p-warn:#b15a0b;'
|
|
14233
14697
|
+ ' --p-line:#e3e9f0; --p-line-strong:#cad6e1;'
|
|
14234
14698
|
+ '}'
|
|
14235
14699
|
+ '@media (prefers-color-scheme:dark){ .aptx-tip:not(.aptx-light):not(.aptx-dark) {' + dark + '} }'
|
|
@@ -14384,6 +14848,16 @@ function (root, d3, forester, phyloXml) {
|
|
|
14384
14848
|
+ '.aptx-panel .aptx-zoomrow .aptx-gbtn { flex:1 1 0; padding:0; }'
|
|
14385
14849
|
+ '.aptx-panel .aptx-zoomrow .aptx-gbtn:last-child { margin-right:0; }'
|
|
14386
14850
|
+ '.aptx-panel .aptx-domrow { display:flex; align-items:center; gap:4px; margin:3px 0; }'
|
|
14851
|
+
// The Download section: a label column, then a control that fills
|
|
14852
|
+
// the row -- the name field, a segmented choice, or the two buttons
|
|
14853
|
+
// sharing it equally (their words centred, never clipped).
|
|
14854
|
+
+ '.aptx-panel .aptx-dlrow { display:flex; align-items:center; gap:6px; margin:4px 0; }'
|
|
14855
|
+
+ '.aptx-panel .aptx-dlrow .aptx-dllabel { flex:0 0 52px; font-size:10px; color:var(--p-muted); }'
|
|
14856
|
+
+ '.aptx-panel .aptx-dlrow input[type=text] { flex:1 1 auto; min-width:0; height:24px; box-sizing:border-box; }'
|
|
14857
|
+
+ '.aptx-panel .aptx-dlrow .aptx-segmented { flex:1 1 auto; display:flex; }'
|
|
14858
|
+
+ '.aptx-panel .aptx-dlrow .aptx-seg { flex:1 1 0; padding:3px 4px; font-weight:500; white-space:nowrap; }'
|
|
14859
|
+
+ '.aptx-panel .aptx-dlrow .aptx-dlbtn { flex:1 1 0; min-width:0; margin:0; padding:0 6px;'
|
|
14860
|
+
+ ' display:flex; align-items:center; justify-content:center; white-space:nowrap; }'
|
|
14387
14861
|
+ '.aptx-panel .aptx-domrow .aptx-domlabel { flex:0 0 64px; font-size:10px; color:var(--p-muted); }'
|
|
14388
14862
|
+ '.aptx-panel .aptx-domrow input[type=button] { width:30px; padding:0; margin:0; }'
|
|
14389
14863
|
// ... except one that has a WORD on it: the rule above sizes the
|
|
@@ -14412,7 +14886,6 @@ function (root, d3, forester, phyloXml) {
|
|
|
14412
14886
|
+ ' line-height:1; color:var(--p-muted); transition:transform .15s,color .15s; }'
|
|
14413
14887
|
+ '.aptx-panel legend.aptx-legend-toggle:hover::after { color:var(--p-accent-ink); }'
|
|
14414
14888
|
+ '.aptx-panel legend.aptx-legend-toggle:hover { color:var(--p-accent-ink); }'
|
|
14415
|
-
+ '.aptx-panel #' + EXPORT_FORMAT_SELECT + ' { margin-left:6px; }'
|
|
14416
14889
|
+ '.aptx-panel fieldset.aptx-collapsed > legend.aptx-legend-toggle::after { transform:rotate(-90deg); }'
|
|
14417
14890
|
+ '.aptx-panel fieldset.aptx-collapsed > legend { margin-bottom:0; }'
|
|
14418
14891
|
+ '.aptx-panel fieldset.aptx-collapsed > .aptx-fieldset-body { display:none; }'
|
|
@@ -15618,14 +16091,6 @@ function (root, d3, forester, phyloXml) {
|
|
|
15618
16091
|
// the stylesheet's job now: the search row sizes its R button, and the
|
|
15619
16092
|
// zoom grid stretches Y+/Y- to the exact width of the button row.)
|
|
15620
16093
|
|
|
15621
|
-
const downloadButton = byId(DOWNLOAD_BUTTON);
|
|
15622
|
-
|
|
15623
|
-
if (downloadButton) {
|
|
15624
|
-
setStyles(downloadButton, {
|
|
15625
|
-
'width': '60px', 'margin-bottom': '3px'
|
|
15626
|
-
});
|
|
15627
|
-
}
|
|
15628
|
-
|
|
15629
16094
|
|
|
15630
16095
|
on(SEARCH_FIELD_0, 'keyup', searchValueKeyup);
|
|
15631
16096
|
|
|
@@ -15842,12 +16307,19 @@ function (root, d3, forester, phyloXml) {
|
|
|
15842
16307
|
|
|
15843
16308
|
// ----------------
|
|
15844
16309
|
|
|
15845
|
-
|
|
15846
|
-
|
|
15847
|
-
|
|
15848
|
-
|
|
15849
|
-
|
|
15850
|
-
|
|
16310
|
+
on(DOWNLOAD_BUTTON, 'click', downloadButtonPressed);
|
|
16311
|
+
on(COPY_NEWICK_BUTTON, 'click', copyNewickPressed);
|
|
16312
|
+
on(COPY_PNG_BUTTON, 'click', copyPngPressed);
|
|
16313
|
+
on(DOWNLOAD_AS_SHOWN_BUTTON, 'change', function () {
|
|
16314
|
+
_downloadFullSize = false;
|
|
16315
|
+
});
|
|
16316
|
+
on(DOWNLOAD_FULL_SIZE_BUTTON, 'change', function () {
|
|
16317
|
+
_downloadFullSize = true;
|
|
16318
|
+
});
|
|
16319
|
+
DOWNLOAD_PNG_SCALES.forEach(function (k) {
|
|
16320
|
+
on('dl_png' + k + '_b', 'change', function () {
|
|
16321
|
+
_pngScale = k;
|
|
16322
|
+
});
|
|
15851
16323
|
});
|
|
15852
16324
|
|
|
15853
16325
|
// ---------------
|
|
@@ -16143,14 +16615,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
16143
16615
|
h = h.concat(makeSegment(makeGlyph('aligned_phylogram'), PHYLOGRAM_ALIGNED_BUTTON, radioGroup, PHYLOGRAM_ALIGNED_WHAT));
|
|
16144
16616
|
h = h.concat(makeSegment(makeGlyph('cladogram'), CLADOGRAM_BUTTON, radioGroup, CLADOGRAM_WHAT));
|
|
16145
16617
|
h = h.concat('</div>');
|
|
16146
|
-
// The branch SCALE, when the tree
|
|
16147
|
-
//
|
|
16618
|
+
// The branch SCALE, when the tree can be laid out by time and by
|
|
16619
|
+
// divergence (forester.hasTimeAndDivergence). Hidden otherwise,
|
|
16148
16620
|
// which is most trees.
|
|
16149
16621
|
h = h.concat('<div class="' + BRANCH_SCALE_CONTROLGROUP + ' aptx-segmented">');
|
|
16150
16622
|
h = h.concat(makeSegment('Time', BRANCH_SCALE_TIME_BUTTON, 'branch_scale_radio',
|
|
16151
|
-
'
|
|
16623
|
+
'lay the tree out by TIME (each branch at its stated length in time; from the node dates where the tree arrived showing divergence)'));
|
|
16152
16624
|
h = h.concat(makeSegment('Div', BRANCH_SCALE_DIV_BUTTON, 'branch_scale_radio',
|
|
16153
|
-
'
|
|
16625
|
+
'lay the tree out by DIVERGENCE (substitutions per site: as the tree records it, or on a BEAST tree each branch\'s time x its clock rate)'));
|
|
16154
16626
|
h = h.concat('</div>');
|
|
16155
16627
|
h = h.concat('</div>');
|
|
16156
16628
|
h = h.concat('</fieldset>');
|
|
@@ -16252,8 +16724,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
16252
16724
|
if (_timeInfo && _timeInfo.type) {
|
|
16253
16725
|
opts.push(makeCheckboxItem('Time Axis', TIME_AXIS_CB, 'to show/hide the '
|
|
16254
16726
|
+ (_timeInfo.type === 'geologic' ? 'geologic (ICS) time axis' : 'calendar time axis')
|
|
16255
|
-
+ ' and node-age bars (phylogram
|
|
16256
|
-
opts.push(makeCheckboxItem('Time Grid', TIME_GRID_CB, 'to show/hide
|
|
16727
|
+
+ ' and node-age bars (phylogram; rectangular and circular layouts)'));
|
|
16728
|
+
opts.push(makeCheckboxItem('Time Grid', TIME_GRID_CB, 'to show/hide grid lines (rings, in circular) at the '
|
|
16257
16729
|
+ (_timeInfo.type === 'geologic' ? 'geologic interval boundaries' : 'calendar year ticks')));
|
|
16258
16730
|
}
|
|
16259
16731
|
|
|
@@ -16272,7 +16744,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
16272
16744
|
// syncZoomRowButtons). The Y buttons span the whole row.
|
|
16273
16745
|
let h = "";
|
|
16274
16746
|
h = h.concat('<fieldset>');
|
|
16275
|
-
h = h.concat('<legend>
|
|
16747
|
+
h = h.concat('<legend>View & Tools</legend>');
|
|
16276
16748
|
h = h.concat('<div class="aptx-zoomgrid">');
|
|
16277
16749
|
h = h.concat(makeButton('Y+', ZOOM_IN_Y, 'zoom in vertically (or Shift+mousewheel)'));
|
|
16278
16750
|
h = h.concat('<div class="aptx-zoomrow">');
|
|
@@ -16284,8 +16756,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
16284
16756
|
h = h.concat('</div>');
|
|
16285
16757
|
h = h.concat(makeButton('Y-', ZOOM_OUT_Y, 'zoom out vertically (or Shift+mousewheel)'));
|
|
16286
16758
|
h = h.concat('</div>');
|
|
16287
|
-
// the tool buttons share the
|
|
16288
|
-
// a fieldset of their own
|
|
16759
|
+
// the tool buttons share the section with the zoom row rather
|
|
16760
|
+
// than paying for a fieldset of their own -- hence its name
|
|
16761
|
+
// (Christian, 2026-09-30: "ZOOM" named only half of it)
|
|
16289
16762
|
h = h.concat('<div class="aptx-toolrow">');
|
|
16290
16763
|
h = h.concat(makeGlyphButton('ladderize_asc', LADDERIZE_BUTTON, 'ladderize all'));
|
|
16291
16764
|
h = h.concat(makeGlyphButton('whole_tree', RETURN_TO_SUPERTREE_BUTTON, 'return all the way to the complete tree (if in a sub-tree)'));
|
|
@@ -16298,34 +16771,49 @@ function (root, d3, forester, phyloXml) {
|
|
|
16298
16771
|
return h;
|
|
16299
16772
|
}
|
|
16300
16773
|
|
|
16774
|
+
// A titled section like the others, so it folds. The format is picked
|
|
16775
|
+
// from a menu the Download button opens (showDownloadMenu), where each
|
|
16776
|
+
// format says what it keeps; the rows above it are the choices every
|
|
16777
|
+
// graphic, or every file, shares.
|
|
16301
16778
|
function makeDownloadSection() {
|
|
16302
|
-
let h = "";
|
|
16303
|
-
h = h.concat('<
|
|
16304
|
-
h = h.concat('<
|
|
16305
|
-
|
|
16306
|
-
|
|
16307
|
-
|
|
16308
|
-
+ '
|
|
16779
|
+
let h = '<fieldset class="aptx-download">';
|
|
16780
|
+
h = h.concat('<legend>Download</legend>');
|
|
16781
|
+
h = h.concat('<div class="aptx-dlrow"><label class="aptx-dllabel" for="' + DOWNLOAD_NAME_FIELD
|
|
16782
|
+
+ '" title="the file name: each format adds its own extension">Name</label>');
|
|
16783
|
+
h = h.concat('<input type="text" id="' + DOWNLOAD_NAME_FIELD + '" name="' + DOWNLOAD_NAME_FIELD
|
|
16784
|
+
+ '" spellcheck="false" autocomplete="off"'
|
|
16785
|
+
+ ' title="the file name: each format adds its own extension"></div>');
|
|
16786
|
+
h = h.concat('<div class="aptx-dlrow"><span class="aptx-dllabel">Graphics</span>');
|
|
16787
|
+
h = h.concat('<div class="aptx-segmented">');
|
|
16788
|
+
h = h.concat(makeSegment('As shown', DOWNLOAD_AS_SHOWN_BUTTON, 'dl_extent_radio',
|
|
16789
|
+
'SVG, PDF and PNG show what is on screen: the part of the tree in view, at the current zoom'));
|
|
16790
|
+
h = h.concat(makeSegment('Full size', DOWNLOAD_FULL_SIZE_BUTTON, 'dl_extent_radio',
|
|
16791
|
+
'SVG, PDF and PNG show the whole tree, drawn large enough for every name'));
|
|
16792
|
+
h = h.concat('</div></div>');
|
|
16309
16793
|
if (pngExportAvailable()) {
|
|
16310
|
-
h = h.concat('<
|
|
16311
|
-
|
|
16312
|
-
|
|
16313
|
-
|
|
16314
|
-
|
|
16315
|
-
|
|
16316
|
-
|
|
16317
|
-
h = h.concat('<option value="' + NH_EXPORT_FORMAT + '">' + NH_EXPORT_FORMAT + '</option>');
|
|
16318
|
-
h = h.concat('<option value="' + NEXUS_EXPORT_FORMAT + '">' + NEXUS_EXPORT_FORMAT + '</option>');
|
|
16319
|
-
// only when there is something to write: a tip carrying a
|
|
16320
|
-
// molecular sequence, aligned or not (same gate as the node
|
|
16321
|
-
// menu's Fasta entries) -- on any other tree the download would
|
|
16322
|
-
// just be an empty file
|
|
16323
|
-
if (_basicTreeProperties.maxMolSeqLength > 0) {
|
|
16324
|
-
h = h.concat('<option value="' + FASTA_EXPORT_FORMAT + '">' + FASTA_EXPORT_FORMAT + '</option>');
|
|
16794
|
+
h = h.concat('<div class="aptx-dlrow"><span class="aptx-dllabel">PNG</span>');
|
|
16795
|
+
h = h.concat('<div class="aptx-segmented">');
|
|
16796
|
+
DOWNLOAD_PNG_SCALES.forEach(function (k) {
|
|
16797
|
+
h = h.concat(makeSegment(k + '×', 'dl_png' + k + '_b', 'dl_png_radio',
|
|
16798
|
+
'PNG at ' + k + ' times the resolution of the screen'));
|
|
16799
|
+
});
|
|
16800
|
+
h = h.concat('</div></div>');
|
|
16325
16801
|
}
|
|
16326
|
-
h = h.concat('
|
|
16802
|
+
h = h.concat('<div class="aptx-dlrow">');
|
|
16803
|
+
h = h.concat('<button type="button" class="aptx-gbtn aptx-dlbtn" id="' + DOWNLOAD_BUTTON + '" name="'
|
|
16804
|
+
+ DOWNLOAD_BUTTON + '" aria-haspopup="menu"'
|
|
16805
|
+
+ ' title="download the tree: choose the format from the menu">Download…</button>');
|
|
16806
|
+
h = h.concat('</div>');
|
|
16807
|
+
h = h.concat('<div class="aptx-dlrow">');
|
|
16808
|
+
h = h.concat('<button type="button" class="aptx-gbtn aptx-dlbtn" id="' + COPY_NEWICK_BUTTON + '" name="'
|
|
16809
|
+
+ COPY_NEWICK_BUTTON + '" title="copy the tree to the clipboard in Newick format">Copy Newick</button>');
|
|
16810
|
+
if (pngCopyAvailable()) {
|
|
16811
|
+
h = h.concat('<button type="button" class="aptx-gbtn aptx-dlbtn" id="' + COPY_PNG_BUTTON + '" name="'
|
|
16812
|
+
+ COPY_PNG_BUTTON + '" title="copy the tree to the clipboard as a PNG image, to paste into'
|
|
16813
|
+
+ ' a document or a slide: drawn as the Graphics and PNG choices above say">Copy PNG</button>');
|
|
16814
|
+
}
|
|
16815
|
+
h = h.concat('</div>');
|
|
16327
16816
|
h = h.concat('</fieldset>');
|
|
16328
|
-
h = h.concat('</form>');
|
|
16329
16817
|
return h;
|
|
16330
16818
|
}
|
|
16331
16819
|
|
|
@@ -16578,6 +17066,27 @@ function (root, d3, forester, phyloXml) {
|
|
|
16578
17066
|
setCheckboxValue(VISUAL_STYLES_CB, _state.useVisualStyles);
|
|
16579
17067
|
setCheckboxValue(VIS_CB, _state.showVisualizations);
|
|
16580
17068
|
setCheckboxValue(DYNAHIDE_CB, _state.dynahide);
|
|
17069
|
+
{
|
|
17070
|
+
let nameField = byId(DOWNLOAD_NAME_FIELD);
|
|
17071
|
+
if (nameField) {
|
|
17072
|
+
nameField.value = _state.downloadStem;
|
|
17073
|
+
}
|
|
17074
|
+
// once per launch, not in initializeSettings: zoom-to-fit re-runs
|
|
17075
|
+
// that, and would throw the user's pick away
|
|
17076
|
+
_pngScale = DOWNLOAD_PNG_SCALES.reduce(function (best, k) {
|
|
17077
|
+
let want = _settings.pngExportScale;
|
|
17078
|
+
return Math.abs(Math.log(k / want)) < Math.abs(Math.log(best / want)) ? k : best;
|
|
17079
|
+
});
|
|
17080
|
+
// any positive number used to be taken as it was; the panel offers
|
|
17081
|
+
// 2x, 4x and 8x, and the setting is now the one it starts on
|
|
17082
|
+
// (Christian, 2026-09-28) -- said, not done silently
|
|
17083
|
+
if (_pngScale !== _settings.pngExportScale) {
|
|
17084
|
+
console.warn(WARNING + ': pngExportScale ' + _settings.pngExportScale + ' is not one of '
|
|
17085
|
+
+ DOWNLOAD_PNG_SCALES.join(', ') + ': the PNG starts at ' + _pngScale + 'x');
|
|
17086
|
+
}
|
|
17087
|
+
setRadioButtonValue(_downloadFullSize ? DOWNLOAD_FULL_SIZE_BUTTON : DOWNLOAD_AS_SHOWN_BUTTON, true);
|
|
17088
|
+
setRadioButtonValue('dl_png' + _pngScale + '_b', true);
|
|
17089
|
+
}
|
|
16581
17090
|
setCheckboxValue(MSA_CB, _state.showMsa);
|
|
16582
17091
|
setCheckboxValue(MSA_LOGO_CB, _state.showMsaLogo === true);
|
|
16583
17092
|
setCheckboxValue(HEATMAP_CB, _state.showHeatmap);
|
|
@@ -16681,14 +17190,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
16681
17190
|
disableCheckbox('#' + PHYLOGRAM_BUTTON);
|
|
16682
17191
|
disableCheckbox('#' + PHYLOGRAM_ALIGNED_BUTTON);
|
|
16683
17192
|
}
|
|
16684
|
-
|
|
16685
|
-
setCheckboxValue(BRANCH_SCALE_DIV_BUTTON, _state.branchScale !== 'time');
|
|
16686
|
-
// most trees state only one thing, and a control offering a choice
|
|
16687
|
-
// that does not exist is worse than no control
|
|
16688
|
-
let scaleGroup = document.querySelector('.' + BRANCH_SCALE_CONTROLGROUP);
|
|
16689
|
-
if (scaleGroup) {
|
|
16690
|
-
scaleGroup.style.display = _branchScaleAvailable ? '' : 'none';
|
|
16691
|
-
}
|
|
17193
|
+
syncBranchScaleControls();
|
|
16692
17194
|
}
|
|
16693
17195
|
|
|
16694
17196
|
|
|
@@ -16846,70 +17348,348 @@ function (root, d3, forester, phyloXml) {
|
|
|
16846
17348
|
return svgText;
|
|
16847
17349
|
}
|
|
16848
17350
|
|
|
16849
|
-
|
|
16850
|
-
|
|
16851
|
-
|
|
17351
|
+
// The live tree's <svg>, copied and cleaned for a file: the overview, the
|
|
17352
|
+
// search halos and the hover glow are on-screen aids, not the tree.
|
|
17353
|
+
function exportCopy(svg) {
|
|
17354
|
+
return cleanExportCopy(svg.cloneNode(true));
|
|
17355
|
+
}
|
|
17356
|
+
|
|
17357
|
+
function cleanExportCopy(copy) {
|
|
17358
|
+
// Serialize a COPY with the overview taken out of it: the overview is
|
|
17359
|
+
// an on-screen navigation aid, not part of the tree, and working on a
|
|
17360
|
+
// copy leaves the live display untouched.
|
|
17361
|
+
let overview = copy.querySelector('.aptx-overview');
|
|
17362
|
+
if (overview) {
|
|
17363
|
+
overview.remove();
|
|
17364
|
+
}
|
|
17365
|
+
// the halo discs' translucency and pulse live in page CSS the
|
|
17366
|
+
// export cannot carry -- serialized as-is they become solid blobs
|
|
17367
|
+
copy.querySelectorAll('circle.foundHalo').forEach(function (h) {
|
|
17368
|
+
h.remove();
|
|
17369
|
+
});
|
|
17370
|
+
let glow = copy.querySelector('.aptx-hoverglow');
|
|
17371
|
+
if (glow) {
|
|
17372
|
+
glow.remove();
|
|
17373
|
+
}
|
|
17374
|
+
return copy;
|
|
17375
|
+
}
|
|
17376
|
+
|
|
17377
|
+
// The same element of the copy as `live` is of the svg: children are
|
|
17378
|
+
// cloned in order, so the index finds it -- in a copy nothing has been
|
|
17379
|
+
// taken out of yet (cleaning it removes the overview, and every child
|
|
17380
|
+
// after that would be off by one).
|
|
17381
|
+
function copyCounterpart(svg, copy, live) {
|
|
17382
|
+
let i = Array.prototype.indexOf.call(svg.children, live);
|
|
17383
|
+
return i >= 0 ? copy.children[i] : null;
|
|
17384
|
+
}
|
|
17385
|
+
|
|
17386
|
+
// The graphic an SVG, PDF or PNG download draws, as {text, w, h}: the
|
|
17387
|
+
// serialized light-theme svg and its size in px. Which one follows the
|
|
17388
|
+
// Download section's choice.
|
|
17389
|
+
function exportGraphic() {
|
|
17390
|
+
if (!treeSvgElement() || typeof window.XMLSerializer === 'undefined') {
|
|
16852
17391
|
return null;
|
|
16853
17392
|
}
|
|
16854
|
-
|
|
16855
|
-
|
|
16856
|
-
|
|
16857
|
-
|
|
16858
|
-
|
|
16859
|
-
|
|
16860
|
-
|
|
16861
|
-
|
|
16862
|
-
|
|
16863
|
-
|
|
16864
|
-
|
|
16865
|
-
|
|
16866
|
-
|
|
16867
|
-
|
|
16868
|
-
|
|
16869
|
-
|
|
16870
|
-
|
|
16871
|
-
|
|
16872
|
-
|
|
16873
|
-
|
|
16874
|
-
|
|
16875
|
-
|
|
16876
|
-
|
|
16877
|
-
|
|
16878
|
-
|
|
16879
|
-
|
|
16880
|
-
|
|
17393
|
+
return _downloadFullSize ? graphicFullSize() : graphicAsShown();
|
|
17394
|
+
}
|
|
17395
|
+
|
|
17396
|
+
// "As shown": the viewport, exactly as it is on screen.
|
|
17397
|
+
function graphicAsShown() {
|
|
17398
|
+
let svg = treeSvgElement();
|
|
17399
|
+
let copy = exportCopy(svg);
|
|
17400
|
+
// The floating strips re-anchor to the TREE for export: on screen
|
|
17401
|
+
// a strip may be stuck at the viewport bottom, but a figure must
|
|
17402
|
+
// not carry an artefact of where the user happened to be scrolled
|
|
17403
|
+
// -- the axis belongs at the bottom of the tree. (As the desktop
|
|
17404
|
+
// does for PDF and graphics export.)
|
|
17405
|
+
let t = d3.zoomTransform(svg);
|
|
17406
|
+
copy.querySelectorAll('.aptx-float > g').forEach(function (strip) {
|
|
17407
|
+
strip.setAttribute('transform', 'translate(' + t.x + ',' + t.y + ') scale(' + t.k + ')');
|
|
17408
|
+
});
|
|
17409
|
+
return {
|
|
17410
|
+
text: toLightExport((new XMLSerializer()).serializeToString(copy)),
|
|
17411
|
+
w: svg.width.baseVal.value || _displayWidth,
|
|
17412
|
+
h: svg.height.baseVal.value || _displayHeight
|
|
17413
|
+
};
|
|
17414
|
+
}
|
|
17415
|
+
|
|
17416
|
+
// How far apart a full-size drawing puts its tips, in label heights, and
|
|
17417
|
+
// how large it may grow at the very most -- a bound on the file, not a
|
|
17418
|
+
// size anyone should meet short of tens of thousands of tips.
|
|
17419
|
+
const FULL_SIZE_ROW_PITCH = 1.3;
|
|
17420
|
+
const FULL_SIZE_MAX_EXTENT = 200000;
|
|
17421
|
+
const FULL_SIZE_MARGIN = 20;
|
|
17422
|
+
const FULL_SIZE_LEGEND_GAP = 24;
|
|
17423
|
+
|
|
17424
|
+
// "Full size": the whole tree, whatever part of it is on screen and
|
|
17425
|
+
// however far it is zoomed out, drawn large enough for every tip name.
|
|
17426
|
+
//
|
|
17427
|
+
// The tree is re-laid-out at that size -- not scaled: scaling the picture
|
|
17428
|
+
// would enlarge the names with it and leave them exactly as crowded --
|
|
17429
|
+
// serialized, and put back. All of it runs in one task, so the screen
|
|
17430
|
+
// never paints the enlarged tree, and the zoom transform is never
|
|
17431
|
+
// touched: the copy simply leaves it out.
|
|
17432
|
+
function graphicFullSize() {
|
|
17433
|
+
let svg = treeSvgElement();
|
|
17434
|
+
let keepW = _displayWidth;
|
|
17435
|
+
let keepH = _displayHeight;
|
|
17436
|
+
let keepHide = _state.dynahide;
|
|
17437
|
+
try {
|
|
17438
|
+
growToFullSize(keepHide);
|
|
17439
|
+
return serializeFullSize(svg);
|
|
17440
|
+
} finally {
|
|
17441
|
+
_state.dynahide = keepHide;
|
|
17442
|
+
_displayWidth = keepW;
|
|
17443
|
+
_displayHeight = keepH;
|
|
17444
|
+
update(null, 0);
|
|
17445
|
+
}
|
|
17446
|
+
}
|
|
17447
|
+
|
|
17448
|
+
// Enlarge the layout until no tip name has to be dropped. Rectangular: the
|
|
17449
|
+
// row pitch is set outright. Circular: the ring is sized from its tips
|
|
17450
|
+
// first. Then every layout is ASKED -- the auto-hide rules, switched on
|
|
17451
|
+
// for the question, are what decide whether a name fits -- and grown
|
|
17452
|
+
// again until none is refused. The width never shrinks below the window,
|
|
17453
|
+
// so a tree zoomed out to a sliver comes out at a readable width.
|
|
17454
|
+
function growToFullSize(userHides) {
|
|
17455
|
+
let pitch = Math.max(4, _state.externalNodeFontSize || 0) * FULL_SIZE_ROW_PITCH;
|
|
17456
|
+
let fit = _settings.enableDynamicSizing ? displaySizeFromContainer() : null;
|
|
17457
|
+
let fitW = (fit && fit.w > 0) ? fit.w : _settings.displayWidth;
|
|
17458
|
+
let fitH = (fit && fit.h > 0) ? fit.h : _settings.displayHeight;
|
|
17459
|
+
let cap = function (v) {
|
|
17460
|
+
return Math.min(FULL_SIZE_MAX_EXTENT, v);
|
|
17461
|
+
};
|
|
17462
|
+
_state.dynahide = true;
|
|
17463
|
+
if (radialDisplay()) {
|
|
17464
|
+
let side = cap(Math.max(Math.min(_displayWidth, _displayHeight), Math.min(fitW, fitH)));
|
|
17465
|
+
_displayWidth = side;
|
|
17466
|
+
_displayHeight = side;
|
|
17467
|
+
update(null, 0);
|
|
17468
|
+
// Sized first so every tip has a label's height of arc: on the
|
|
17469
|
+
// ring in circular, where the names stand; in unrooted, where
|
|
17470
|
+
// there is no ring, as if the tips were spread evenly around the
|
|
17471
|
+
// fan's rim -- the most a fan's size can promise.
|
|
17472
|
+
let rows = 0;
|
|
17473
|
+
forEachDisplayed(function (n) {
|
|
17474
|
+
if (!n.children || isCollapsed(n)) {
|
|
17475
|
+
++rows;
|
|
17476
|
+
}
|
|
16881
17477
|
});
|
|
16882
|
-
|
|
16883
|
-
|
|
16884
|
-
|
|
17478
|
+
let span = (_state.circularDisplay && _radial) ? Math.max(0.1, _radial.angleSpan) : 2 * Math.PI;
|
|
17479
|
+
let radius = (_state.circularDisplay && _radial) ? _radial.maxRad : _displayWidth * 0.42;
|
|
17480
|
+
let need = (rows * pitch) / span;
|
|
17481
|
+
if (radius > 0 && need > radius) {
|
|
17482
|
+
_displayWidth = cap(_displayWidth * need / radius);
|
|
17483
|
+
_displayHeight = _displayWidth;
|
|
17484
|
+
update(null, 0);
|
|
17485
|
+
}
|
|
17486
|
+
// Then grown while growing still frees names, and no further. In
|
|
17487
|
+
// an unrooted fan names collide that no size will part: flu_h5's
|
|
17488
|
+
// clusters sit on branches a thousand times shorter than the tree,
|
|
17489
|
+
// and at 67,000 px across 176 of its 354 names were still refused
|
|
17490
|
+
// (332 at 850 px, 322 at 2,300) -- chasing them only made a file
|
|
17491
|
+
// too large to paint as a PNG. The drawing kept is the smallest
|
|
17492
|
+
// that showed the fewest refusals.
|
|
17493
|
+
let best = {hidden: _radialNamesHidden, side: _displayWidth};
|
|
17494
|
+
for (let i = 0; i < 12 && _radialNamesHidden > 0 && _displayWidth < FULL_SIZE_MAX_EXTENT; ++i) {
|
|
17495
|
+
_displayWidth = cap(_displayWidth * 1.4);
|
|
17496
|
+
_displayHeight = _displayWidth;
|
|
17497
|
+
update(null, 0);
|
|
17498
|
+
if (_radialNamesHidden < best.hidden * 0.95) {
|
|
17499
|
+
best = {hidden: _radialNamesHidden, side: _displayWidth};
|
|
17500
|
+
} else {
|
|
17501
|
+
break;
|
|
17502
|
+
}
|
|
17503
|
+
}
|
|
17504
|
+
if (_displayWidth !== best.side) {
|
|
17505
|
+
_displayWidth = best.side;
|
|
17506
|
+
_displayHeight = best.side;
|
|
17507
|
+
update(null, 0);
|
|
17508
|
+
}
|
|
17509
|
+
} else {
|
|
17510
|
+
_displayWidth = cap(Math.max(_displayWidth, fitW));
|
|
17511
|
+
_displayHeight = cap(Math.max(_displayHeight, fitH));
|
|
17512
|
+
update(null, 0);
|
|
17513
|
+
for (let i = 0; i < 4 && (_rowUnit < pitch * 0.999 || _dynahide_factor >= 2)
|
|
17514
|
+
&& _displayHeight < FULL_SIZE_MAX_EXTENT; ++i) {
|
|
17515
|
+
// the vertical span less the fixed reserves is what the rows
|
|
17516
|
+
// share, so that is the part that scales
|
|
17517
|
+
let span = layoutSpans().vertical;
|
|
17518
|
+
let reserve = _displayHeight - span;
|
|
17519
|
+
let f = Math.max(pitch / Math.max(_rowUnit, 1e-6), 1.05);
|
|
17520
|
+
_displayHeight = cap(reserve + (span * f));
|
|
17521
|
+
update(null, 0);
|
|
17522
|
+
}
|
|
17523
|
+
}
|
|
17524
|
+
// drawn once more under the user's own auto-hide choice: with it off,
|
|
17525
|
+
// what it would have hidden is drawn, as it is on screen
|
|
17526
|
+
if (!userHides) {
|
|
17527
|
+
_state.dynahide = false;
|
|
17528
|
+
update(null, 0);
|
|
16885
17529
|
}
|
|
16886
|
-
|
|
17530
|
+
}
|
|
17531
|
+
|
|
17532
|
+
// The smallest box around every non-empty one of `boxes` (getBBox
|
|
17533
|
+
// results), as {x0, y0, x1, y1}; null when none has any size.
|
|
17534
|
+
function unionBox(boxes) {
|
|
17535
|
+
let u = null;
|
|
17536
|
+
boxes.forEach(function (b) {
|
|
17537
|
+
if (!b || !(b.width > 0 || b.height > 0)) {
|
|
17538
|
+
return;
|
|
17539
|
+
}
|
|
17540
|
+
if (!u) {
|
|
17541
|
+
u = {x0: b.x, y0: b.y, x1: b.x + b.width, y1: b.y + b.height};
|
|
17542
|
+
} else {
|
|
17543
|
+
u.x0 = Math.min(u.x0, b.x);
|
|
17544
|
+
u.y0 = Math.min(u.y0, b.y);
|
|
17545
|
+
u.x1 = Math.max(u.x1, b.x + b.width);
|
|
17546
|
+
u.y1 = Math.max(u.y1, b.y + b.height);
|
|
17547
|
+
}
|
|
17548
|
+
});
|
|
17549
|
+
return u;
|
|
17550
|
+
}
|
|
17551
|
+
|
|
17552
|
+
// Every card the viewer pins to the WINDOW rather than to the tree: the
|
|
17553
|
+
// colour and shape legends, the domain legend, the heat-map ring's scale.
|
|
17554
|
+
// On screen they sit where the window has room; in a full-size figure the
|
|
17555
|
+
// window is gone, so they are stacked beside the tree instead.
|
|
17556
|
+
const WINDOW_LEGEND_CLASSES = [LEGEND_LABEL_COLOR, LEGEND_NODE_SHAPE, LEGEND_DOMAINS, 'aptx-heatmap-ringlegend'];
|
|
17557
|
+
const FULL_SIZE_LEGEND_STACK_GAP = 12;
|
|
17558
|
+
|
|
17559
|
+
// The enlarged drawing, cropped to what is actually drawn: the tree
|
|
17560
|
+
// (with its floating strips, anchored to it) and, in a column to its left
|
|
17561
|
+
// rather than over it, the legend cards.
|
|
17562
|
+
function serializeFullSize(svg) {
|
|
17563
|
+
let M = FULL_SIZE_MARGIN;
|
|
17564
|
+
// a strip's content is drawn in tree coordinates; its own transform
|
|
17565
|
+
// only places it on screen, and getBBox leaves that out
|
|
17566
|
+
let strips = _floatGroup ? Array.prototype.slice.call(_floatGroup.node().children) : [];
|
|
17567
|
+
let box = unionBox([_svgGroup.node().getBBox()].concat(strips.map(function (g) {
|
|
17568
|
+
return g.getBBox();
|
|
17569
|
+
})));
|
|
17570
|
+
if (!box) {
|
|
17571
|
+
return graphicAsShown();
|
|
17572
|
+
}
|
|
17573
|
+
let cards = [];
|
|
17574
|
+
WINDOW_LEGEND_CLASSES.forEach(function (cls) {
|
|
17575
|
+
let g = svg.querySelector(':scope > g.' + cls);
|
|
17576
|
+
let b = g ? g.getBBox() : null;
|
|
17577
|
+
if (b && (b.width > 0 || b.height > 0)) {
|
|
17578
|
+
cards.push({g: g, b: b});
|
|
17579
|
+
}
|
|
17580
|
+
});
|
|
17581
|
+
let columnW = 0;
|
|
17582
|
+
let columnH = 0;
|
|
17583
|
+
cards.forEach(function (c, i) {
|
|
17584
|
+
columnW = Math.max(columnW, c.b.width);
|
|
17585
|
+
columnH += c.b.height + (i > 0 ? FULL_SIZE_LEGEND_STACK_GAP : 0);
|
|
17586
|
+
});
|
|
17587
|
+
let legendW = cards.length > 0 ? columnW + FULL_SIZE_LEGEND_GAP : 0;
|
|
17588
|
+
let treeW = box.x1 - box.x0;
|
|
17589
|
+
let treeH = box.y1 - box.y0;
|
|
17590
|
+
let W = Math.ceil(M + legendW + treeW + M);
|
|
17591
|
+
let H = Math.ceil(M + Math.max(treeH, columnH) + M);
|
|
17592
|
+
|
|
17593
|
+
let copy = svg.cloneNode(true);
|
|
17594
|
+
let place = 'translate(' + (M + legendW - box.x0) + ',' + (M - box.y0) + ')';
|
|
17595
|
+
let tree = copyCounterpart(svg, copy, _svgGroup.node());
|
|
17596
|
+
if (tree) {
|
|
17597
|
+
tree.setAttribute('transform', place);
|
|
17598
|
+
}
|
|
17599
|
+
copy.querySelectorAll('.aptx-float > g').forEach(function (strip) {
|
|
17600
|
+
strip.setAttribute('transform', place);
|
|
17601
|
+
});
|
|
17602
|
+
// A card's own transform, where it has one, only placed it in the
|
|
17603
|
+
// window (getBBox leaves it out), so it is replaced, not added to.
|
|
17604
|
+
let y = M;
|
|
17605
|
+
cards.forEach(function (c) {
|
|
17606
|
+
let cc = copyCounterpart(svg, copy, c.g);
|
|
17607
|
+
if (cc) {
|
|
17608
|
+
cc.setAttribute('transform', 'translate(' + (M - c.b.x) + ',' + (y - c.b.y) + ')');
|
|
17609
|
+
}
|
|
17610
|
+
y += c.b.height + FULL_SIZE_LEGEND_STACK_GAP;
|
|
17611
|
+
});
|
|
17612
|
+
cleanExportCopy(copy);
|
|
17613
|
+
copy.setAttribute('width', W);
|
|
17614
|
+
copy.setAttribute('height', H);
|
|
17615
|
+
copy.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
|
|
17616
|
+
return {text: toLightExport((new XMLSerializer()).serializeToString(copy)), w: W, h: H};
|
|
16887
17617
|
}
|
|
16888
17618
|
|
|
16889
17619
|
function downloadTree(format) {
|
|
16890
|
-
if (format ===
|
|
16891
|
-
changeBaseBackgoundColor(_state.backgroundColorForPrintExportDefault);
|
|
16892
|
-
downloadAsPng();
|
|
16893
|
-
changeBaseBackgoundColor(_state.backgroundColorDefault);
|
|
16894
|
-
} else if (format === SVG_EXPORT_FORMAT) {
|
|
16895
|
-
changeBaseBackgoundColor(_state.backgroundColorForPrintExportDefault);
|
|
16896
|
-
downloadAsSVG();
|
|
16897
|
-
changeBaseBackgoundColor(_state.backgroundColorDefault);
|
|
16898
|
-
} else if (format === NH_EXPORT_FORMAT) {
|
|
17620
|
+
if (format === NH_EXPORT_FORMAT) {
|
|
16899
17621
|
downloadAsNH();
|
|
16900
17622
|
} else if (format === NEXUS_EXPORT_FORMAT) {
|
|
16901
17623
|
downloadAsNexus();
|
|
16902
17624
|
} else if (format === PHYLOXML_EXPORT_FORMAT) {
|
|
16903
17625
|
downloadAsPhyloXml();
|
|
16904
|
-
} else if (format === PDF_EXPORT_FORMAT) {
|
|
16905
|
-
changeBaseBackgoundColor(_state.backgroundColorForPrintExportDefault);
|
|
16906
|
-
downloadAsPdf();
|
|
16907
|
-
changeBaseBackgoundColor(_state.backgroundColorDefault);
|
|
16908
17626
|
} else if (format === FASTA_EXPORT_FORMAT) {
|
|
16909
17627
|
downloadAsFastaAll();
|
|
17628
|
+
} else if (format === TSV_EXPORT_FORMAT) {
|
|
17629
|
+
downloadAsTsvAll();
|
|
17630
|
+
} else if (format === SVG_EXPORT_FORMAT || format === PDF_EXPORT_FORMAT || format === PNG_EXPORT_FORMAT) {
|
|
17631
|
+
withGraphic(function (graphic) {
|
|
17632
|
+
if (format === SVG_EXPORT_FORMAT) {
|
|
17633
|
+
downloadAsSVG(graphic);
|
|
17634
|
+
} else if (format === PDF_EXPORT_FORMAT) {
|
|
17635
|
+
downloadAsPdf(graphic);
|
|
17636
|
+
} else {
|
|
17637
|
+
downloadAsPng(graphic);
|
|
17638
|
+
}
|
|
17639
|
+
});
|
|
16910
17640
|
}
|
|
16911
17641
|
}
|
|
16912
17642
|
|
|
17643
|
+
// Hands fn the graphic the Download section's choices describe, drawn in
|
|
17644
|
+
// the light export colours; a failure -- no graphic, or a throw anywhere
|
|
17645
|
+
// on the way -- goes to fail instead, so a caller waiting on the result
|
|
17646
|
+
// (Copy PNG's clipboard write) always hears back. A full-size drawing
|
|
17647
|
+
// re-lays-out the tree several times over, each pass larger than the
|
|
17648
|
+
// redraw last timed, so the card goes up when that redraw was even a
|
|
17649
|
+
// fraction of the card's threshold.
|
|
17650
|
+
function withGraphic(fn, fail) {
|
|
17651
|
+
// without a handler the error is thrown on, as it was before there
|
|
17652
|
+
// was one -- to the page's error handling and the harnesses -- rather
|
|
17653
|
+
// than turned into a console line and a click that did nothing
|
|
17654
|
+
let failed = fail || function (err) {
|
|
17655
|
+
throw err;
|
|
17656
|
+
};
|
|
17657
|
+
let draw = function () {
|
|
17658
|
+
let graphic;
|
|
17659
|
+
try {
|
|
17660
|
+
changeBaseBackgoundColor(_state.backgroundColorForPrintExportDefault);
|
|
17661
|
+
try {
|
|
17662
|
+
graphic = exportGraphic();
|
|
17663
|
+
} finally {
|
|
17664
|
+
changeBaseBackgoundColor(_state.backgroundColorDefault);
|
|
17665
|
+
}
|
|
17666
|
+
if (!graphic) {
|
|
17667
|
+
throw new Error('this browser cannot serialize the tree');
|
|
17668
|
+
}
|
|
17669
|
+
fn(graphic);
|
|
17670
|
+
} catch (err) {
|
|
17671
|
+
failed(err);
|
|
17672
|
+
}
|
|
17673
|
+
};
|
|
17674
|
+
if (_downloadFullSize && _redrawMs * FULL_SIZE_REDRAWS_EXPECTED >= REDRAW_CARD_MS && _container) {
|
|
17675
|
+
showBusy(_container, 'Drawing the whole tree');
|
|
17676
|
+
afterPaint(function () {
|
|
17677
|
+
try {
|
|
17678
|
+
draw();
|
|
17679
|
+
} finally {
|
|
17680
|
+
hideBusy();
|
|
17681
|
+
}
|
|
17682
|
+
});
|
|
17683
|
+
} else {
|
|
17684
|
+
draw();
|
|
17685
|
+
}
|
|
17686
|
+
}
|
|
17687
|
+
|
|
17688
|
+
// How many redraws a full-size drawing typically costs: the drawing at the
|
|
17689
|
+
// window's size, one or two to reach the size, and the one that puts the
|
|
17690
|
+
// window back -- more on a crowded fan.
|
|
17691
|
+
const FULL_SIZE_REDRAWS_EXPECTED = 4;
|
|
17692
|
+
|
|
16913
17693
|
/**
|
|
16914
17694
|
* Saves a Blob to a file using native browser APIs (an <a download> link
|
|
16915
17695
|
* pointing at an object URL). This replaces the former FileSaver.js
|
|
@@ -16937,9 +17717,42 @@ function (root, d3, forester, phyloXml) {
|
|
|
16937
17717
|
}, 1000);
|
|
16938
17718
|
}
|
|
16939
17719
|
|
|
17720
|
+
// What a tree file is written from. The whole tree as it is; a subtree
|
|
17721
|
+
// view's root is a bare wrapper made when the subtree was entered, and
|
|
17722
|
+
// written as it stands it gave the file no name at all (phyloXML wrote
|
|
17723
|
+
// none, Nexus "tree1"). So it goes out under the tree's own name, marked
|
|
17724
|
+
// as a subtree. A shallow copy: the drawing's wrapper is left alone, and
|
|
17725
|
+
// the whole-tree files are byte for byte what they were.
|
|
17726
|
+
function treeForExport() {
|
|
17727
|
+
if (_root === _root_const || !_root_const) {
|
|
17728
|
+
return _root;
|
|
17729
|
+
}
|
|
17730
|
+
let name = _root_const.name ? String(_root_const.name).trim() : '';
|
|
17731
|
+
// what the phylogeny itself states goes with the part: without its
|
|
17732
|
+
// branch_length_unit a subtree saved from Div opened again as time
|
|
17733
|
+
let part = Object.assign({}, _root, {name: name ? name + ' (subtree)' : undefined});
|
|
17734
|
+
['rooted', 'rerootable', 'type', 'branch_length_unit'].forEach(function (k) {
|
|
17735
|
+
if (_root_const[k] !== undefined && _root_const[k] !== null) {
|
|
17736
|
+
part[k] = _root_const[k];
|
|
17737
|
+
}
|
|
17738
|
+
});
|
|
17739
|
+
return part;
|
|
17740
|
+
}
|
|
17741
|
+
|
|
17742
|
+
// "subtree, 19 of 50 tips" while a subtree is shown, else null: said in
|
|
17743
|
+
// the Download menu, so nobody writes a part of the tree thinking it the
|
|
17744
|
+
// whole.
|
|
17745
|
+
function subtreeExtentNote() {
|
|
17746
|
+
if (_root === _root_const || !_root_const) {
|
|
17747
|
+
return null;
|
|
17748
|
+
}
|
|
17749
|
+
return 'subtree, ' + forester.getAllExternalNodes(_root).length.toLocaleString() + ' of '
|
|
17750
|
+
+ forester.getAllExternalNodes(_root_const).length.toLocaleString() + ' tips';
|
|
17751
|
+
}
|
|
17752
|
+
|
|
16940
17753
|
function downloadAsPhyloXml() {
|
|
16941
|
-
let x = phyloXml.toPhyloXML(
|
|
16942
|
-
saveAs(new Blob([x], {type: "application/xml"}),
|
|
17754
|
+
let x = phyloXml.toPhyloXML(treeForExport(), 9);
|
|
17755
|
+
saveAs(new Blob([x], {type: "application/xml"}), downloadFileName(XML_SUFFIX));
|
|
16943
17756
|
}
|
|
16944
17757
|
|
|
16945
17758
|
function downloadAsNH() {
|
|
@@ -16947,25 +17760,43 @@ function (root, d3, forester, phyloXml) {
|
|
|
16947
17760
|
// not a preference: writing them out produces a file that will not parse
|
|
16948
17761
|
// back in.
|
|
16949
17762
|
let nh = forester.toNewHampshire(_root, 9, true, _settings.nhExportWriteConfidences);
|
|
16950
|
-
saveAs(new Blob([nh], {type: "application/txt"}),
|
|
17763
|
+
saveAs(new Blob([nh], {type: "application/txt"}), downloadFileName(NH_SUFFIX));
|
|
16951
17764
|
}
|
|
16952
17765
|
|
|
16953
17766
|
function downloadAsNexus() {
|
|
16954
17767
|
// taxa and trees blocks as on the desktop, plus a characters block
|
|
16955
17768
|
// when the tips carry an alignment -- carrying tree and alignment
|
|
16956
17769
|
// in one file is what Nexus is for
|
|
16957
|
-
let
|
|
16958
|
-
|
|
17770
|
+
let phy = treeForExport();
|
|
17771
|
+
let nex = forester.toNexus(phy, 9, _settings.nhExportWriteConfidences);
|
|
17772
|
+
saveAs(new Blob([nex], {type: "application/txt"}), downloadFileName(NEXUS_SUFFIX));
|
|
17773
|
+
}
|
|
17774
|
+
|
|
17775
|
+
function downloadAsSVG(graphic) {
|
|
17776
|
+
saveAs(new Blob([graphic.text], {type: "image/svg+xml"}), downloadFileName(SVG_SUFFIX));
|
|
16959
17777
|
}
|
|
16960
17778
|
|
|
16961
|
-
|
|
16962
|
-
|
|
16963
|
-
|
|
17779
|
+
// Every tip's data, one row per tip top to bottom as drawn: the node
|
|
17780
|
+
// menu's "Download Ext. Node Data" for the whole tree -- the desktop's
|
|
17781
|
+
// columns, and a table that joins back onto the tree as metadata.
|
|
17782
|
+
function downloadAsTsvAll() {
|
|
17783
|
+
saveAs(new Blob([tipTableTsv(forester.getAllExternalNodes(_root).reverse())],
|
|
17784
|
+
{type: 'text/tab-separated-values'}), downloadFileName(TSV_SUFFIX));
|
|
17785
|
+
}
|
|
17786
|
+
|
|
17787
|
+
// The table of `tips` (top to bottom as drawn: getAllExternalNodes
|
|
17788
|
+
// reversed) -- one writer for the Download menu's TSV and the node
|
|
17789
|
+
// menu's Download Ext. Node Data, so their columns and ids cannot drift
|
|
17790
|
+
// apart. The caller walks the tips, once, and may count them too.
|
|
17791
|
+
function tipTableTsv(tips) {
|
|
17792
|
+
return forester.externalNodeDataTsv(tips, function (n, i) {
|
|
17793
|
+
return (n.viewId !== undefined) ? n.viewId : i + 1;
|
|
17794
|
+
});
|
|
16964
17795
|
}
|
|
16965
17796
|
|
|
16966
17797
|
function downloadAsFastaAll() {
|
|
16967
17798
|
let fasta_text = forester.getMolecularSequencesAsFasta(_root, '\n');
|
|
16968
|
-
saveAs(new Blob([fasta_text], {type: "application/txt"}),
|
|
17799
|
+
saveAs(new Blob([fasta_text], {type: "application/txt"}), downloadFileName(FASTA_SUFFIX));
|
|
16969
17800
|
}
|
|
16970
17801
|
|
|
16971
17802
|
// Vector PDF via the OPTIONAL page-level libraries jsPDF and svg2pdf.js
|
|
@@ -16979,13 +17810,18 @@ function (root, d3, forester, phyloXml) {
|
|
|
16979
17810
|
&& window.jspdf.jsPDF.API.svg);
|
|
16980
17811
|
}
|
|
16981
17812
|
|
|
16982
|
-
|
|
17813
|
+
// The largest page a PDF may have: 200 inches a side (ISO 32000, Annex C).
|
|
17814
|
+
// Readers refuse, or quietly clip, anything past it -- and a full-size
|
|
17815
|
+
// tree of a few thousand tips is past it -- so a larger drawing is scaled
|
|
17816
|
+
// onto the largest page instead. It is vector: nothing is lost by that.
|
|
17817
|
+
const PDF_MAX_PAGE_PT = 14400;
|
|
17818
|
+
|
|
17819
|
+
function downloadAsPdf(graphic) {
|
|
16983
17820
|
if (!pdfExportAvailable()) {
|
|
16984
17821
|
console.error(ERROR + 'PDF export needs the optional jspdf and svg2pdf.js libraries on the page');
|
|
16985
17822
|
return;
|
|
16986
17823
|
}
|
|
16987
|
-
let
|
|
16988
|
-
let el = new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement;
|
|
17824
|
+
let el = new DOMParser().parseFromString(graphic.text, 'image/svg+xml').documentElement;
|
|
16989
17825
|
// the full-canvas background rect is sized in percentages, which
|
|
16990
17826
|
// svg2pdf renders literally (a dark 100pt square at the origin); the
|
|
16991
17827
|
// PDF page is white anyway, so the rect is simply dropped
|
|
@@ -17017,9 +17853,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
17017
17853
|
}
|
|
17018
17854
|
});
|
|
17019
17855
|
});
|
|
17020
|
-
let
|
|
17021
|
-
let w =
|
|
17022
|
-
let h =
|
|
17856
|
+
let fit = Math.min(1, PDF_MAX_PAGE_PT / Math.max(graphic.w, graphic.h));
|
|
17857
|
+
let w = graphic.w * fit;
|
|
17858
|
+
let h = graphic.h * fit;
|
|
17023
17859
|
let pdf = new window.jspdf.jsPDF({
|
|
17024
17860
|
orientation: w >= h ? 'landscape' : 'portrait',
|
|
17025
17861
|
unit: 'pt',
|
|
@@ -17027,7 +17863,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
17027
17863
|
compress: true
|
|
17028
17864
|
});
|
|
17029
17865
|
pdf.svg(el, {x: 0, y: 0, width: w, height: h}).then(function () {
|
|
17030
|
-
pdf.save(
|
|
17866
|
+
pdf.save(downloadFileName(PDF_SUFFIX));
|
|
17031
17867
|
holder.remove();
|
|
17032
17868
|
}, function (err) {
|
|
17033
17869
|
holder.remove();
|
|
@@ -17044,23 +17880,62 @@ function (root, d3, forester, phyloXml) {
|
|
|
17044
17880
|
return !!(window.Canvg && window.Canvg.fromString);
|
|
17045
17881
|
}
|
|
17046
17882
|
|
|
17047
|
-
|
|
17883
|
+
// The biggest canvas this browser will actually paint, at no more than
|
|
17884
|
+
// the scale asked for. Browsers cap a canvas by side and by area, and
|
|
17885
|
+
// differently (Safari's area cap is a sixteenth of Chrome's); past the
|
|
17886
|
+
// cap a canvas is not an error, it is just blank -- which is what a
|
|
17887
|
+
// full-size PNG of a large tree would silently have been. So the canvas
|
|
17888
|
+
// is TRIED: filled, and its far corner read back.
|
|
17889
|
+
function paintableCanvas(w, h, scale) {
|
|
17890
|
+
for (let k = scale; k > 0.05; k *= 0.8) {
|
|
17891
|
+
let canvas = document.createElement('canvas');
|
|
17892
|
+
canvas.width = Math.max(1, Math.round(w * k));
|
|
17893
|
+
canvas.height = Math.max(1, Math.round(h * k));
|
|
17894
|
+
let ctx = canvas.getContext('2d');
|
|
17895
|
+
if (ctx) {
|
|
17896
|
+
ctx.fillStyle = '#ffffff';
|
|
17897
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
17898
|
+
let px;
|
|
17899
|
+
try {
|
|
17900
|
+
px = ctx.getImageData(canvas.width - 1, canvas.height - 1, 1, 1).data;
|
|
17901
|
+
} catch {
|
|
17902
|
+
px = null;
|
|
17903
|
+
}
|
|
17904
|
+
if (px && px[3] === 255) {
|
|
17905
|
+
return {canvas: canvas, ctx: ctx, scale: k};
|
|
17906
|
+
}
|
|
17907
|
+
}
|
|
17908
|
+
}
|
|
17909
|
+
return null;
|
|
17910
|
+
}
|
|
17911
|
+
|
|
17912
|
+
function downloadAsPng(graphic) {
|
|
17913
|
+
renderPng(graphic).then(function (blob) {
|
|
17914
|
+
saveAs(blob, downloadFileName(PNG_SUFFIX));
|
|
17915
|
+
}, function (err) {
|
|
17916
|
+
console.error(ERROR + 'PNG export failed: ' + err);
|
|
17917
|
+
});
|
|
17918
|
+
}
|
|
17919
|
+
|
|
17920
|
+
// The graphic as a PNG Blob, at the Download section's 2x/4x/8x -- less
|
|
17921
|
+
// where the browser cannot paint a canvas that large (paintableCanvas).
|
|
17922
|
+
function renderPng(graphic) {
|
|
17923
|
+
if (!graphic) {
|
|
17924
|
+
return Promise.reject(new Error('nothing to draw'));
|
|
17925
|
+
}
|
|
17048
17926
|
if (!pngExportAvailable()) {
|
|
17049
|
-
|
|
17050
|
-
return;
|
|
17927
|
+
return Promise.reject(new Error('PNG export needs the optional canvg library on the page'));
|
|
17051
17928
|
}
|
|
17052
|
-
let
|
|
17053
|
-
|
|
17054
|
-
|
|
17055
|
-
|
|
17056
|
-
|
|
17057
|
-
|
|
17058
|
-
|
|
17059
|
-
|
|
17060
|
-
let canvas =
|
|
17061
|
-
|
|
17062
|
-
canvas.height = Math.round(h * scale);
|
|
17063
|
-
let ctx = canvas.getContext('2d');
|
|
17929
|
+
let target = paintableCanvas(graphic.w, graphic.h, _pngScale);
|
|
17930
|
+
if (!target) {
|
|
17931
|
+
return Promise.reject(new Error('this browser cannot paint a canvas of that size'));
|
|
17932
|
+
}
|
|
17933
|
+
if (target.scale < _pngScale) {
|
|
17934
|
+
console.warn(WARNING + ': PNG exported at ' + target.scale.toFixed(2) + 'x rather than ' + _pngScale
|
|
17935
|
+
+ 'x: the largest image this browser can paint');
|
|
17936
|
+
}
|
|
17937
|
+
let canvas = target.canvas;
|
|
17938
|
+
let ctx = target.ctx;
|
|
17064
17939
|
// The up-scaling is done on the CONTEXT, not through canvg's own
|
|
17065
17940
|
// scaleWidth/scaleHeight: canvg 4's scaling pipeline mis-scales (a
|
|
17066
17941
|
// requested 4x came out at 2.5x, leaving unpainted margins -- and any
|
|
@@ -17069,20 +17944,56 @@ function (root, d3, forester, phyloXml) {
|
|
|
17069
17944
|
// background before canvg draws a thing. ignoreDimensions keeps canvg
|
|
17070
17945
|
// from resizing the canvas to the SVG's size (which would erase both
|
|
17071
17946
|
// the transform and the fill); ignoreClear keeps it from clearing the
|
|
17072
|
-
// fill back to transparent.
|
|
17073
|
-
// falls outside the canvas and is cropped, so the export shows
|
|
17074
|
-
// exactly the visible view. v4's render() is async (v1's canvg()
|
|
17947
|
+
// fill back to transparent. v4's render() is async (v1's canvg()
|
|
17075
17948
|
// call drew synchronously, so toBlob could follow on the next line).
|
|
17076
17949
|
ctx.fillStyle = _state.backgroundColorForPrintExportDefault;
|
|
17077
17950
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
17078
|
-
ctx.scale(scale, scale);
|
|
17079
|
-
let v = window.Canvg.fromString(ctx,
|
|
17080
|
-
v.render({ignoreDimensions: true, ignoreClear: true}).then(function () {
|
|
17081
|
-
|
|
17082
|
-
|
|
17951
|
+
ctx.scale(target.scale, target.scale);
|
|
17952
|
+
let v = window.Canvg.fromString(ctx, graphic.text);
|
|
17953
|
+
return v.render({ignoreDimensions: true, ignoreClear: true}).then(function () {
|
|
17954
|
+
return new Promise(function (resolve, reject) {
|
|
17955
|
+
canvas.toBlob(function (blob) {
|
|
17956
|
+
if (blob) {
|
|
17957
|
+
resolve(blob);
|
|
17958
|
+
} else {
|
|
17959
|
+
reject(new Error('the canvas gave no image'));
|
|
17960
|
+
}
|
|
17961
|
+
}, 'image/png');
|
|
17083
17962
|
});
|
|
17963
|
+
});
|
|
17964
|
+
}
|
|
17965
|
+
|
|
17966
|
+
// Copying an image needs the async clipboard's write() and ClipboardItem
|
|
17967
|
+
// (a secure page: https, or localhost) as well as canvg; the button is
|
|
17968
|
+
// only offered where all three are there.
|
|
17969
|
+
function pngCopyAvailable() {
|
|
17970
|
+
return pngExportAvailable() && typeof window.ClipboardItem === 'function'
|
|
17971
|
+
&& !!(navigator.clipboard && navigator.clipboard.write);
|
|
17972
|
+
}
|
|
17973
|
+
|
|
17974
|
+
// The desktop's "copy image": the picture straight onto the clipboard,
|
|
17975
|
+
// for a Word document or a slide. The ClipboardItem is made at once, with
|
|
17976
|
+
// the image as a PROMISE, inside the click: Safari allows a clipboard
|
|
17977
|
+
// write only during the gesture, and drawing the PNG takes longer than
|
|
17978
|
+
// that -- a promise-valued item is how it waits (Chrome takes it too).
|
|
17979
|
+
function copyPngPressed(event) {
|
|
17980
|
+
let btn = event.currentTarget;
|
|
17981
|
+
let png = new Promise(function (resolve, reject) {
|
|
17982
|
+
withGraphic(function (graphic) {
|
|
17983
|
+
renderPng(graphic).then(resolve, reject);
|
|
17984
|
+
}, reject);
|
|
17985
|
+
});
|
|
17986
|
+
let written;
|
|
17987
|
+
try {
|
|
17988
|
+
written = navigator.clipboard.write([new window.ClipboardItem({'image/png': png})]);
|
|
17989
|
+
} catch (e) {
|
|
17990
|
+
written = Promise.reject(e);
|
|
17991
|
+
}
|
|
17992
|
+
written.then(function () {
|
|
17993
|
+
flashButton(btn, 'Copied ✓');
|
|
17084
17994
|
}, function (err) {
|
|
17085
|
-
console.error(ERROR + 'PNG
|
|
17995
|
+
console.error(ERROR + 'copying the PNG failed: ' + err);
|
|
17996
|
+
flashButton(btn, 'Copy failed');
|
|
17086
17997
|
});
|
|
17087
17998
|
}
|
|
17088
17999
|
|