archaeopteryx 3.16.0 → 3.17.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 +61 -9
- package/archaeopteryx.d.ts +2 -0
- package/archaeopteryx.js +852 -196
- package/forester.js +1 -1
- package/package.json +1 -1
package/archaeopteryx.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
// v 3.
|
|
23
|
+
// v 3.17.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.17.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
|
|
@@ -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
|
|
|
@@ -13272,12 +13319,163 @@ function (root, d3, forester, phyloXml) {
|
|
|
13272
13319
|
scheduleUpdate(null, 0);
|
|
13273
13320
|
}
|
|
13274
13321
|
|
|
13275
|
-
|
|
13276
|
-
|
|
13277
|
-
|
|
13278
|
-
|
|
13279
|
-
|
|
13322
|
+
// The Download button opens the format menu under itself; pressed again
|
|
13323
|
+
// while the menu is open, it closes it.
|
|
13324
|
+
function downloadButtonPressed(event) {
|
|
13325
|
+
let btn = event.currentTarget;
|
|
13326
|
+
showNodeMenu(downloadMenuItems(), null, null, btn);
|
|
13327
|
+
// opened from the keyboard (a click with no pointer), the keyboard
|
|
13328
|
+
// goes into the menu: Tab walks the formats, Escape closes it
|
|
13329
|
+
if (event.detail === 0 && _nodeMenu) {
|
|
13330
|
+
let first = _nodeMenu.querySelector('button');
|
|
13331
|
+
if (first) {
|
|
13332
|
+
first.focus();
|
|
13333
|
+
}
|
|
13334
|
+
}
|
|
13335
|
+
}
|
|
13336
|
+
|
|
13337
|
+
// Every format says what it keeps: a Newick file silently drops
|
|
13338
|
+
// everything but names, lengths and support, and a user picking by
|
|
13339
|
+
// extension would never know.
|
|
13340
|
+
function downloadMenuItems() {
|
|
13341
|
+
let items = [];
|
|
13342
|
+
let entry = function (format) {
|
|
13343
|
+
return function () {
|
|
13344
|
+
downloadTree(format);
|
|
13345
|
+
};
|
|
13346
|
+
};
|
|
13347
|
+
items.push({heading: 'Graphics, ' + (_downloadFullSize ? 'full size' : 'as shown')});
|
|
13348
|
+
items.push({label: 'SVG', detail: 'vector: edit it in Inkscape or Illustrator',
|
|
13349
|
+
action: entry(SVG_EXPORT_FORMAT)});
|
|
13350
|
+
if (pdfExportAvailable()) {
|
|
13351
|
+
items.push({label: 'PDF', detail: 'vector: ready to print or to put in a paper',
|
|
13352
|
+
action: entry(PDF_EXPORT_FORMAT)});
|
|
13353
|
+
}
|
|
13354
|
+
if (pngExportAvailable()) {
|
|
13355
|
+
items.push({label: 'PNG', detail: 'image, at ' + _pngScale + '× the screen\'s resolution',
|
|
13356
|
+
action: entry(PNG_EXPORT_FORMAT)});
|
|
13357
|
+
}
|
|
13358
|
+
let part = subtreeExtentNote();
|
|
13359
|
+
items.push({heading: part ? 'Tree: ' + part : 'Tree'});
|
|
13360
|
+
items.push({label: 'phyloXML', detail: 'keeps everything the tree carries: taxonomy, sequences, dates, properties',
|
|
13361
|
+
action: entry(PHYLOXML_EXPORT_FORMAT)});
|
|
13362
|
+
items.push({label: 'Newick', detail: 'names, branch lengths and support values only',
|
|
13363
|
+
action: entry(NH_EXPORT_FORMAT)});
|
|
13364
|
+
let nexus = nexusDownloadDetail();
|
|
13365
|
+
items.push({label: 'Nexus', detail: nexus.text, warn: nexus.warn, action: entry(NEXUS_EXPORT_FORMAT)});
|
|
13366
|
+
// FASTA only when there is something to write: a tip carrying a
|
|
13367
|
+
// molecular sequence, aligned or not (same gate as the node menu's
|
|
13368
|
+
// Fasta entries) -- on any other tree the download would be empty
|
|
13369
|
+
items.push({heading: part ? 'Tip data: ' + part : 'Tip data'});
|
|
13370
|
+
items.push({label: 'TSV', detail: 'a table, one row per tip: names, taxonomy, sequence names, '
|
|
13371
|
+
+ 'branch lengths and every property', action: entry(TSV_EXPORT_FORMAT)});
|
|
13372
|
+
if (_basicTreeProperties && _basicTreeProperties.maxMolSeqLength > 0) {
|
|
13373
|
+
items.push({label: 'FASTA', detail: 'every molecular sequence the tips carry',
|
|
13374
|
+
action: entry(FASTA_EXPORT_FORMAT)});
|
|
13375
|
+
}
|
|
13376
|
+
return items;
|
|
13377
|
+
}
|
|
13378
|
+
|
|
13379
|
+
// What a Nexus download will hold, asked of the WRITER rather than
|
|
13380
|
+
// predicted: toNexus decides whether the sequences can form a matrix
|
|
13381
|
+
// (equal lengths, unambiguous labels), and when they cannot it says why
|
|
13382
|
+
// in a bracketed comment -- which is the sentence shown here, so the menu
|
|
13383
|
+
// and the file can never disagree.
|
|
13384
|
+
function nexusDownloadDetail() {
|
|
13385
|
+
let plain = {text: 'the tree: names, branch lengths and support values', warn: false};
|
|
13386
|
+
if (!_root || !_basicTreeProperties || !(_basicTreeProperties.maxMolSeqLength > 0)) {
|
|
13387
|
+
return plain;
|
|
13388
|
+
}
|
|
13389
|
+
let nex;
|
|
13390
|
+
try {
|
|
13391
|
+
nex = forester.toNexus(treeForExport(), 9, _settings.nhExportWriteConfidences);
|
|
13392
|
+
} catch {
|
|
13393
|
+
return plain;
|
|
13394
|
+
}
|
|
13395
|
+
if (nex.indexOf('Begin Characters;') >= 0) {
|
|
13396
|
+
return {text: 'the tree and its alignment, in one file', warn: false};
|
|
13397
|
+
}
|
|
13398
|
+
// to the comment's own closing " ]" at the end of its line: a taxon
|
|
13399
|
+
// label quoted inside the reason may itself hold a ']'
|
|
13400
|
+
let why = /\[ Molecular sequences were not written: (.*?) \]$/m.exec(nex);
|
|
13401
|
+
return {
|
|
13402
|
+
text: 'the tree only, without the sequences: ' + (why ? why[1] : 'they cannot form a matrix')
|
|
13403
|
+
+ ' phyloXML and FASTA keep them.',
|
|
13404
|
+
warn: true
|
|
13405
|
+
};
|
|
13406
|
+
}
|
|
13407
|
+
|
|
13408
|
+
// The file name the user typed, made safe, with the format's extension.
|
|
13409
|
+
// A typed extension is not doubled up ("tree.svg" saves as tree.svg, and
|
|
13410
|
+
// as tree.png from the PNG entry), and an empty field falls back to the
|
|
13411
|
+
// tree's name.
|
|
13412
|
+
function downloadFileName(suffix) {
|
|
13413
|
+
let field = byId(DOWNLOAD_NAME_FIELD);
|
|
13414
|
+
let stem = (field ? field.value : '').trim().replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '_');
|
|
13415
|
+
let lower = stem.toLowerCase();
|
|
13416
|
+
[NH_SUFFIX, NEXUS_SUFFIX, XML_SUFFIX, FASTA_SUFFIX, TSV_SUFFIX, SVG_SUFFIX, PDF_SUFFIX, PNG_SUFFIX,
|
|
13417
|
+
'.nex', '.nwk', '.newick', '.nh', '.nhx', '.tree', '.phyloxml', '.fa', '.fas', '.txt',
|
|
13418
|
+
'.csv'].some(function (x) {
|
|
13419
|
+
if (lower.length > x.length && lower.endsWith(x)) {
|
|
13420
|
+
stem = stem.substring(0, stem.length - x.length);
|
|
13421
|
+
return true;
|
|
13422
|
+
}
|
|
13423
|
+
return false;
|
|
13424
|
+
});
|
|
13425
|
+
if (stem.replace(/[._\s]/g, '') === '') {
|
|
13426
|
+
stem = _state.downloadStem;
|
|
13280
13427
|
}
|
|
13428
|
+
return stem + suffix;
|
|
13429
|
+
}
|
|
13430
|
+
|
|
13431
|
+
function copyNewickPressed(event) {
|
|
13432
|
+
let btn = event.currentTarget;
|
|
13433
|
+
let nh = _root ? forester.toNewHampshire(_root, 9, true, _settings.nhExportWriteConfidences) : '';
|
|
13434
|
+
copyText(nh).then(function (ok) {
|
|
13435
|
+
flashButton(btn, ok ? 'Copied ✓' : 'Copy failed');
|
|
13436
|
+
});
|
|
13437
|
+
}
|
|
13438
|
+
|
|
13439
|
+
// The async clipboard API where the page allows it (a secure context);
|
|
13440
|
+
// otherwise the old select-and-copy, so a page served over plain http
|
|
13441
|
+
// still copies. Resolves to whether the text made it.
|
|
13442
|
+
function copyText(text) {
|
|
13443
|
+
let legacy = function () {
|
|
13444
|
+
let ta = document.createElement('textarea');
|
|
13445
|
+
ta.value = text;
|
|
13446
|
+
ta.setAttribute('readonly', '');
|
|
13447
|
+
ta.style.position = 'fixed';
|
|
13448
|
+
ta.style.left = '-10000px';
|
|
13449
|
+
document.body.appendChild(ta);
|
|
13450
|
+
ta.select();
|
|
13451
|
+
let ok = false;
|
|
13452
|
+
try {
|
|
13453
|
+
ok = document.execCommand('copy');
|
|
13454
|
+
} catch {
|
|
13455
|
+
ok = false;
|
|
13456
|
+
}
|
|
13457
|
+
ta.remove();
|
|
13458
|
+
return ok;
|
|
13459
|
+
};
|
|
13460
|
+
if (navigator.clipboard && window.isSecureContext) {
|
|
13461
|
+
return navigator.clipboard.writeText(text).then(function () {
|
|
13462
|
+
return true;
|
|
13463
|
+
}, legacy);
|
|
13464
|
+
}
|
|
13465
|
+
return Promise.resolve(legacy());
|
|
13466
|
+
}
|
|
13467
|
+
|
|
13468
|
+
// A button says what just happened on its own face for a moment, then
|
|
13469
|
+
// goes back to its name: no toast to find, and nothing left behind.
|
|
13470
|
+
function flashButton(btn, text) {
|
|
13471
|
+
if (btn.dataset.label === undefined) {
|
|
13472
|
+
btn.dataset.label = btn.textContent;
|
|
13473
|
+
}
|
|
13474
|
+
btn.textContent = text;
|
|
13475
|
+
clearTimeout(btn._flashTimer);
|
|
13476
|
+
btn._flashTimer = setTimeout(function () {
|
|
13477
|
+
btn.textContent = btn.dataset.label;
|
|
13478
|
+
}, 1600);
|
|
13281
13479
|
}
|
|
13282
13480
|
|
|
13283
13481
|
function changeBaseBackgoundColor(color) {
|
|
@@ -13543,7 +13741,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
13543
13741
|
// of open sections: three open sections measure anywhere from 215px to
|
|
13544
13742
|
// 562px, so a count would not have bounded the height at all.
|
|
13545
13743
|
const PANEL_SECTIONS_KEY = 'aptx-panel-sections';
|
|
13546
|
-
|
|
13744
|
+
// Download too (Christian, 2026-09-28): four rows, and not something a
|
|
13745
|
+
// session uses often.
|
|
13746
|
+
const PANEL_SECTIONS_CLOSED_BY_DEFAULT = ['Zoom', 'Sizes', 'Domain Architectures', 'Download'];
|
|
13547
13747
|
|
|
13548
13748
|
function loadPanelSections() {
|
|
13549
13749
|
if (_panelSections) {
|
|
@@ -13998,13 +14198,13 @@ function (root, d3, forester, phyloXml) {
|
|
|
13998
14198
|
}
|
|
13999
14199
|
// Dark palette tokens, shared by the system-preference default and the
|
|
14000
14200
|
// 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;'
|
|
14201
|
+
let dark = ' --p-bg:rgba(24,35,46,0.86); --p-ink:#e7eef5; --p-muted:#94a4b3; --p-faint:#6f8090; --p-warn:#f0a35e;'
|
|
14002
14202
|
+ ' --p-line:#27343f; --p-line-strong:#35434f; --p-surface2:#202d38;'
|
|
14003
14203
|
+ ' --p-accent:#57a6ff; --p-accent-ink:#9cc7ff; --p-accent-weak:rgba(87,166,255,0.18);'
|
|
14004
14204
|
+ ' --p-shadow-sm:0 1px 2px rgba(0,0,0,0.4);';
|
|
14005
14205
|
let css = ''
|
|
14006
14206
|
+ '.aptx-panel {'
|
|
14007
|
-
+ ' --p-bg: rgba(255,255,255,0.86); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2;'
|
|
14207
|
+
+ ' --p-bg: rgba(255,255,255,0.86); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2; --p-warn:#b15a0b;'
|
|
14008
14208
|
+ ' --p-line:#e3e9f0; --p-line-strong:#cad6e1; --p-surface2:#f3f6fa;'
|
|
14009
14209
|
+ ' --p-accent:#2f83f2; --p-accent-ink:#1c5fbf; --p-accent-weak:rgba(47,131,242,0.12);'
|
|
14010
14210
|
+ ' --p-shadow-sm:0 1px 2px rgba(23,34,46,0.12);'
|
|
@@ -14031,7 +14231,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
14031
14231
|
// (they cannot live inside the panel), so they carry their own
|
|
14032
14232
|
// copy of the palette and follow the same light / dark rules.
|
|
14033
14233
|
+ '.aptx-node-menu, .aptx-suggest {'
|
|
14034
|
-
+ ' --p-bg: rgba(255,255,255,0.97); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2;'
|
|
14234
|
+
+ ' --p-bg: rgba(255,255,255,0.97); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2; --p-warn:#b15a0b;'
|
|
14035
14235
|
+ ' --p-line:#e3e9f0; --p-line-strong:#cad6e1; --p-surface2:#f3f6fa;'
|
|
14036
14236
|
+ ' --p-accent:#2f83f2; --p-accent-ink:#1c5fbf; --p-accent-weak:rgba(47,131,242,0.12);'
|
|
14037
14237
|
+ '}'
|
|
@@ -14061,6 +14261,17 @@ function (root, d3, forester, phyloXml) {
|
|
|
14061
14261
|
+ ' background:#e5484d; color:#fff; }'
|
|
14062
14262
|
+ '.aptx-node-menu hr { border:0; border-top:1px solid var(--p-line); margin:3px 4px; }'
|
|
14063
14263
|
+ '.aptx-node-menu-note { padding:4px 9px 7px; color:var(--p-ink); white-space:normal; }'
|
|
14264
|
+
// A group title inside a menu (the Download menu's Graphics / Tree
|
|
14265
|
+
// / Sequences): the title's type, set off from the group above.
|
|
14266
|
+
+ '.aptx-node-menu-heading { margin-top:4px; }'
|
|
14267
|
+
+ '.aptx-node-menu-heading:first-child { margin-top:0; }'
|
|
14268
|
+
// an entry's second line: what that choice keeps or does
|
|
14269
|
+
+ '.aptx-node-menu button .aptx-menu-detail { display:block; white-space:normal; font-size:10px;'
|
|
14270
|
+
+ ' line-height:1.35; color:var(--p-muted); margin-top:1px; }'
|
|
14271
|
+
+ '.aptx-node-menu button .aptx-menu-detail.aptx-menu-warn { color:var(--p-warn); }'
|
|
14272
|
+
+ '.aptx-node-menu button:hover .aptx-menu-detail, .aptx-node-menu button:focus-visible .aptx-menu-detail {'
|
|
14273
|
+
+ ' color:inherit; opacity:0.86; }'
|
|
14274
|
+
+ '.aptx-node-menu:has(.aptx-menu-detail) { width:264px; max-width:264px; }'
|
|
14064
14275
|
// The search suggestions: the node menu's chrome under the value box.
|
|
14065
14276
|
+ '.aptx-suggest { position:absolute; z-index:1000; max-width:320px; padding:4px; box-sizing:border-box;'
|
|
14066
14277
|
+ ' border:1px solid var(--p-line-strong); border-radius:10px; background:var(--p-bg); color:var(--p-ink);'
|
|
@@ -14079,7 +14290,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
14079
14290
|
+ ' font-size:10px; color:var(--p-faint); }'
|
|
14080
14291
|
// The node-data dialog, on the same palette as the panel and the menu.
|
|
14081
14292
|
+ '.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;'
|
|
14293
|
+
+ ' --p-bg: rgba(255,255,255,0.98); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2; --p-warn:#b15a0b;'
|
|
14083
14294
|
+ ' --p-line:#e3e9f0; --p-line-strong:#cad6e1; --p-surface2:#f3f6fa;'
|
|
14084
14295
|
+ ' --p-accent:#2f83f2; --p-accent-ink:#1c5fbf; --p-accent-weak:rgba(47,131,242,0.12);'
|
|
14085
14296
|
+ '}'
|
|
@@ -14229,7 +14440,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
14229
14440
|
+ ' font-size:11px; white-space:pre-wrap; overflow-wrap:anywhere; }'
|
|
14230
14441
|
// The hover tooltip, on the same palette.
|
|
14231
14442
|
+ '.aptx-tip {'
|
|
14232
|
-
+ ' --p-bg: rgba(255,255,255,0.97); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2;'
|
|
14443
|
+
+ ' --p-bg: rgba(255,255,255,0.97); --p-ink:#1e2a35; --p-muted:#6b7a89; --p-faint:#93a3b2; --p-warn:#b15a0b;'
|
|
14233
14444
|
+ ' --p-line:#e3e9f0; --p-line-strong:#cad6e1;'
|
|
14234
14445
|
+ '}'
|
|
14235
14446
|
+ '@media (prefers-color-scheme:dark){ .aptx-tip:not(.aptx-light):not(.aptx-dark) {' + dark + '} }'
|
|
@@ -14384,6 +14595,16 @@ function (root, d3, forester, phyloXml) {
|
|
|
14384
14595
|
+ '.aptx-panel .aptx-zoomrow .aptx-gbtn { flex:1 1 0; padding:0; }'
|
|
14385
14596
|
+ '.aptx-panel .aptx-zoomrow .aptx-gbtn:last-child { margin-right:0; }'
|
|
14386
14597
|
+ '.aptx-panel .aptx-domrow { display:flex; align-items:center; gap:4px; margin:3px 0; }'
|
|
14598
|
+
// The Download section: a label column, then a control that fills
|
|
14599
|
+
// the row -- the name field, a segmented choice, or the two buttons
|
|
14600
|
+
// sharing it equally (their words centred, never clipped).
|
|
14601
|
+
+ '.aptx-panel .aptx-dlrow { display:flex; align-items:center; gap:6px; margin:4px 0; }'
|
|
14602
|
+
+ '.aptx-panel .aptx-dlrow .aptx-dllabel { flex:0 0 52px; font-size:10px; color:var(--p-muted); }'
|
|
14603
|
+
+ '.aptx-panel .aptx-dlrow input[type=text] { flex:1 1 auto; min-width:0; height:24px; box-sizing:border-box; }'
|
|
14604
|
+
+ '.aptx-panel .aptx-dlrow .aptx-segmented { flex:1 1 auto; display:flex; }'
|
|
14605
|
+
+ '.aptx-panel .aptx-dlrow .aptx-seg { flex:1 1 0; padding:3px 4px; font-weight:500; white-space:nowrap; }'
|
|
14606
|
+
+ '.aptx-panel .aptx-dlrow .aptx-dlbtn { flex:1 1 0; min-width:0; margin:0; padding:0 6px;'
|
|
14607
|
+
+ ' display:flex; align-items:center; justify-content:center; white-space:nowrap; }'
|
|
14387
14608
|
+ '.aptx-panel .aptx-domrow .aptx-domlabel { flex:0 0 64px; font-size:10px; color:var(--p-muted); }'
|
|
14388
14609
|
+ '.aptx-panel .aptx-domrow input[type=button] { width:30px; padding:0; margin:0; }'
|
|
14389
14610
|
// ... except one that has a WORD on it: the rule above sizes the
|
|
@@ -14412,7 +14633,6 @@ function (root, d3, forester, phyloXml) {
|
|
|
14412
14633
|
+ ' line-height:1; color:var(--p-muted); transition:transform .15s,color .15s; }'
|
|
14413
14634
|
+ '.aptx-panel legend.aptx-legend-toggle:hover::after { color:var(--p-accent-ink); }'
|
|
14414
14635
|
+ '.aptx-panel legend.aptx-legend-toggle:hover { color:var(--p-accent-ink); }'
|
|
14415
|
-
+ '.aptx-panel #' + EXPORT_FORMAT_SELECT + ' { margin-left:6px; }'
|
|
14416
14636
|
+ '.aptx-panel fieldset.aptx-collapsed > legend.aptx-legend-toggle::after { transform:rotate(-90deg); }'
|
|
14417
14637
|
+ '.aptx-panel fieldset.aptx-collapsed > legend { margin-bottom:0; }'
|
|
14418
14638
|
+ '.aptx-panel fieldset.aptx-collapsed > .aptx-fieldset-body { display:none; }'
|
|
@@ -15618,14 +15838,6 @@ function (root, d3, forester, phyloXml) {
|
|
|
15618
15838
|
// the stylesheet's job now: the search row sizes its R button, and the
|
|
15619
15839
|
// zoom grid stretches Y+/Y- to the exact width of the button row.)
|
|
15620
15840
|
|
|
15621
|
-
const downloadButton = byId(DOWNLOAD_BUTTON);
|
|
15622
|
-
|
|
15623
|
-
if (downloadButton) {
|
|
15624
|
-
setStyles(downloadButton, {
|
|
15625
|
-
'width': '60px', 'margin-bottom': '3px'
|
|
15626
|
-
});
|
|
15627
|
-
}
|
|
15628
|
-
|
|
15629
15841
|
|
|
15630
15842
|
on(SEARCH_FIELD_0, 'keyup', searchValueKeyup);
|
|
15631
15843
|
|
|
@@ -15842,12 +16054,19 @@ function (root, d3, forester, phyloXml) {
|
|
|
15842
16054
|
|
|
15843
16055
|
// ----------------
|
|
15844
16056
|
|
|
15845
|
-
|
|
15846
|
-
|
|
15847
|
-
|
|
15848
|
-
|
|
15849
|
-
|
|
15850
|
-
|
|
16057
|
+
on(DOWNLOAD_BUTTON, 'click', downloadButtonPressed);
|
|
16058
|
+
on(COPY_NEWICK_BUTTON, 'click', copyNewickPressed);
|
|
16059
|
+
on(COPY_PNG_BUTTON, 'click', copyPngPressed);
|
|
16060
|
+
on(DOWNLOAD_AS_SHOWN_BUTTON, 'change', function () {
|
|
16061
|
+
_downloadFullSize = false;
|
|
16062
|
+
});
|
|
16063
|
+
on(DOWNLOAD_FULL_SIZE_BUTTON, 'change', function () {
|
|
16064
|
+
_downloadFullSize = true;
|
|
16065
|
+
});
|
|
16066
|
+
DOWNLOAD_PNG_SCALES.forEach(function (k) {
|
|
16067
|
+
on('dl_png' + k + '_b', 'change', function () {
|
|
16068
|
+
_pngScale = k;
|
|
16069
|
+
});
|
|
15851
16070
|
});
|
|
15852
16071
|
|
|
15853
16072
|
// ---------------
|
|
@@ -16298,34 +16517,49 @@ function (root, d3, forester, phyloXml) {
|
|
|
16298
16517
|
return h;
|
|
16299
16518
|
}
|
|
16300
16519
|
|
|
16520
|
+
// A titled section like the others, so it folds. The format is picked
|
|
16521
|
+
// from a menu the Download button opens (showDownloadMenu), where each
|
|
16522
|
+
// format says what it keeps; the rows above it are the choices every
|
|
16523
|
+
// graphic, or every file, shares.
|
|
16301
16524
|
function makeDownloadSection() {
|
|
16302
|
-
let h = "";
|
|
16303
|
-
h = h.concat('<
|
|
16304
|
-
h = h.concat('<
|
|
16305
|
-
|
|
16306
|
-
|
|
16307
|
-
|
|
16308
|
-
+ '
|
|
16525
|
+
let h = '<fieldset class="aptx-download">';
|
|
16526
|
+
h = h.concat('<legend>Download</legend>');
|
|
16527
|
+
h = h.concat('<div class="aptx-dlrow"><label class="aptx-dllabel" for="' + DOWNLOAD_NAME_FIELD
|
|
16528
|
+
+ '" title="the file name: each format adds its own extension">Name</label>');
|
|
16529
|
+
h = h.concat('<input type="text" id="' + DOWNLOAD_NAME_FIELD + '" name="' + DOWNLOAD_NAME_FIELD
|
|
16530
|
+
+ '" spellcheck="false" autocomplete="off"'
|
|
16531
|
+
+ ' title="the file name: each format adds its own extension"></div>');
|
|
16532
|
+
h = h.concat('<div class="aptx-dlrow"><span class="aptx-dllabel">Graphics</span>');
|
|
16533
|
+
h = h.concat('<div class="aptx-segmented">');
|
|
16534
|
+
h = h.concat(makeSegment('As shown', DOWNLOAD_AS_SHOWN_BUTTON, 'dl_extent_radio',
|
|
16535
|
+
'SVG, PDF and PNG show what is on screen: the part of the tree in view, at the current zoom'));
|
|
16536
|
+
h = h.concat(makeSegment('Full size', DOWNLOAD_FULL_SIZE_BUTTON, 'dl_extent_radio',
|
|
16537
|
+
'SVG, PDF and PNG show the whole tree, drawn large enough for every name'));
|
|
16538
|
+
h = h.concat('</div></div>');
|
|
16309
16539
|
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>');
|
|
16540
|
+
h = h.concat('<div class="aptx-dlrow"><span class="aptx-dllabel">PNG</span>');
|
|
16541
|
+
h = h.concat('<div class="aptx-segmented">');
|
|
16542
|
+
DOWNLOAD_PNG_SCALES.forEach(function (k) {
|
|
16543
|
+
h = h.concat(makeSegment(k + '×', 'dl_png' + k + '_b', 'dl_png_radio',
|
|
16544
|
+
'PNG at ' + k + ' times the resolution of the screen'));
|
|
16545
|
+
});
|
|
16546
|
+
h = h.concat('</div></div>');
|
|
16325
16547
|
}
|
|
16326
|
-
h = h.concat('
|
|
16548
|
+
h = h.concat('<div class="aptx-dlrow">');
|
|
16549
|
+
h = h.concat('<button type="button" class="aptx-gbtn aptx-dlbtn" id="' + DOWNLOAD_BUTTON + '" name="'
|
|
16550
|
+
+ DOWNLOAD_BUTTON + '" aria-haspopup="menu"'
|
|
16551
|
+
+ ' title="download the tree: choose the format from the menu">Download…</button>');
|
|
16552
|
+
h = h.concat('</div>');
|
|
16553
|
+
h = h.concat('<div class="aptx-dlrow">');
|
|
16554
|
+
h = h.concat('<button type="button" class="aptx-gbtn aptx-dlbtn" id="' + COPY_NEWICK_BUTTON + '" name="'
|
|
16555
|
+
+ COPY_NEWICK_BUTTON + '" title="copy the tree to the clipboard in Newick format">Copy Newick</button>');
|
|
16556
|
+
if (pngCopyAvailable()) {
|
|
16557
|
+
h = h.concat('<button type="button" class="aptx-gbtn aptx-dlbtn" id="' + COPY_PNG_BUTTON + '" name="'
|
|
16558
|
+
+ COPY_PNG_BUTTON + '" title="copy the tree to the clipboard as a PNG image, to paste into'
|
|
16559
|
+
+ ' a document or a slide: drawn as the Graphics and PNG choices above say">Copy PNG</button>');
|
|
16560
|
+
}
|
|
16561
|
+
h = h.concat('</div>');
|
|
16327
16562
|
h = h.concat('</fieldset>');
|
|
16328
|
-
h = h.concat('</form>');
|
|
16329
16563
|
return h;
|
|
16330
16564
|
}
|
|
16331
16565
|
|
|
@@ -16578,6 +16812,27 @@ function (root, d3, forester, phyloXml) {
|
|
|
16578
16812
|
setCheckboxValue(VISUAL_STYLES_CB, _state.useVisualStyles);
|
|
16579
16813
|
setCheckboxValue(VIS_CB, _state.showVisualizations);
|
|
16580
16814
|
setCheckboxValue(DYNAHIDE_CB, _state.dynahide);
|
|
16815
|
+
{
|
|
16816
|
+
let nameField = byId(DOWNLOAD_NAME_FIELD);
|
|
16817
|
+
if (nameField) {
|
|
16818
|
+
nameField.value = _state.downloadStem;
|
|
16819
|
+
}
|
|
16820
|
+
// once per launch, not in initializeSettings: zoom-to-fit re-runs
|
|
16821
|
+
// that, and would throw the user's pick away
|
|
16822
|
+
_pngScale = DOWNLOAD_PNG_SCALES.reduce(function (best, k) {
|
|
16823
|
+
let want = _settings.pngExportScale;
|
|
16824
|
+
return Math.abs(Math.log(k / want)) < Math.abs(Math.log(best / want)) ? k : best;
|
|
16825
|
+
});
|
|
16826
|
+
// any positive number used to be taken as it was; the panel offers
|
|
16827
|
+
// 2x, 4x and 8x, and the setting is now the one it starts on
|
|
16828
|
+
// (Christian, 2026-09-28) -- said, not done silently
|
|
16829
|
+
if (_pngScale !== _settings.pngExportScale) {
|
|
16830
|
+
console.warn(WARNING + ': pngExportScale ' + _settings.pngExportScale + ' is not one of '
|
|
16831
|
+
+ DOWNLOAD_PNG_SCALES.join(', ') + ': the PNG starts at ' + _pngScale + 'x');
|
|
16832
|
+
}
|
|
16833
|
+
setRadioButtonValue(_downloadFullSize ? DOWNLOAD_FULL_SIZE_BUTTON : DOWNLOAD_AS_SHOWN_BUTTON, true);
|
|
16834
|
+
setRadioButtonValue('dl_png' + _pngScale + '_b', true);
|
|
16835
|
+
}
|
|
16581
16836
|
setCheckboxValue(MSA_CB, _state.showMsa);
|
|
16582
16837
|
setCheckboxValue(MSA_LOGO_CB, _state.showMsaLogo === true);
|
|
16583
16838
|
setCheckboxValue(HEATMAP_CB, _state.showHeatmap);
|
|
@@ -16846,70 +17101,348 @@ function (root, d3, forester, phyloXml) {
|
|
|
16846
17101
|
return svgText;
|
|
16847
17102
|
}
|
|
16848
17103
|
|
|
16849
|
-
|
|
16850
|
-
|
|
16851
|
-
|
|
17104
|
+
// The live tree's <svg>, copied and cleaned for a file: the overview, the
|
|
17105
|
+
// search halos and the hover glow are on-screen aids, not the tree.
|
|
17106
|
+
function exportCopy(svg) {
|
|
17107
|
+
return cleanExportCopy(svg.cloneNode(true));
|
|
17108
|
+
}
|
|
17109
|
+
|
|
17110
|
+
function cleanExportCopy(copy) {
|
|
17111
|
+
// Serialize a COPY with the overview taken out of it: the overview is
|
|
17112
|
+
// an on-screen navigation aid, not part of the tree, and working on a
|
|
17113
|
+
// copy leaves the live display untouched.
|
|
17114
|
+
let overview = copy.querySelector('.aptx-overview');
|
|
17115
|
+
if (overview) {
|
|
17116
|
+
overview.remove();
|
|
17117
|
+
}
|
|
17118
|
+
// the halo discs' translucency and pulse live in page CSS the
|
|
17119
|
+
// export cannot carry -- serialized as-is they become solid blobs
|
|
17120
|
+
copy.querySelectorAll('circle.foundHalo').forEach(function (h) {
|
|
17121
|
+
h.remove();
|
|
17122
|
+
});
|
|
17123
|
+
let glow = copy.querySelector('.aptx-hoverglow');
|
|
17124
|
+
if (glow) {
|
|
17125
|
+
glow.remove();
|
|
17126
|
+
}
|
|
17127
|
+
return copy;
|
|
17128
|
+
}
|
|
17129
|
+
|
|
17130
|
+
// The same element of the copy as `live` is of the svg: children are
|
|
17131
|
+
// cloned in order, so the index finds it -- in a copy nothing has been
|
|
17132
|
+
// taken out of yet (cleaning it removes the overview, and every child
|
|
17133
|
+
// after that would be off by one).
|
|
17134
|
+
function copyCounterpart(svg, copy, live) {
|
|
17135
|
+
let i = Array.prototype.indexOf.call(svg.children, live);
|
|
17136
|
+
return i >= 0 ? copy.children[i] : null;
|
|
17137
|
+
}
|
|
17138
|
+
|
|
17139
|
+
// The graphic an SVG, PDF or PNG download draws, as {text, w, h}: the
|
|
17140
|
+
// serialized light-theme svg and its size in px. Which one follows the
|
|
17141
|
+
// Download section's choice.
|
|
17142
|
+
function exportGraphic() {
|
|
17143
|
+
if (!treeSvgElement() || typeof window.XMLSerializer === 'undefined') {
|
|
16852
17144
|
return null;
|
|
16853
17145
|
}
|
|
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
|
-
|
|
17146
|
+
return _downloadFullSize ? graphicFullSize() : graphicAsShown();
|
|
17147
|
+
}
|
|
17148
|
+
|
|
17149
|
+
// "As shown": the viewport, exactly as it is on screen.
|
|
17150
|
+
function graphicAsShown() {
|
|
17151
|
+
let svg = treeSvgElement();
|
|
17152
|
+
let copy = exportCopy(svg);
|
|
17153
|
+
// The floating strips re-anchor to the TREE for export: on screen
|
|
17154
|
+
// a strip may be stuck at the viewport bottom, but a figure must
|
|
17155
|
+
// not carry an artefact of where the user happened to be scrolled
|
|
17156
|
+
// -- the axis belongs at the bottom of the tree. (As the desktop
|
|
17157
|
+
// does for PDF and graphics export.)
|
|
17158
|
+
let t = d3.zoomTransform(svg);
|
|
17159
|
+
copy.querySelectorAll('.aptx-float > g').forEach(function (strip) {
|
|
17160
|
+
strip.setAttribute('transform', 'translate(' + t.x + ',' + t.y + ') scale(' + t.k + ')');
|
|
17161
|
+
});
|
|
17162
|
+
return {
|
|
17163
|
+
text: toLightExport((new XMLSerializer()).serializeToString(copy)),
|
|
17164
|
+
w: svg.width.baseVal.value || _displayWidth,
|
|
17165
|
+
h: svg.height.baseVal.value || _displayHeight
|
|
17166
|
+
};
|
|
17167
|
+
}
|
|
17168
|
+
|
|
17169
|
+
// How far apart a full-size drawing puts its tips, in label heights, and
|
|
17170
|
+
// how large it may grow at the very most -- a bound on the file, not a
|
|
17171
|
+
// size anyone should meet short of tens of thousands of tips.
|
|
17172
|
+
const FULL_SIZE_ROW_PITCH = 1.3;
|
|
17173
|
+
const FULL_SIZE_MAX_EXTENT = 200000;
|
|
17174
|
+
const FULL_SIZE_MARGIN = 20;
|
|
17175
|
+
const FULL_SIZE_LEGEND_GAP = 24;
|
|
17176
|
+
|
|
17177
|
+
// "Full size": the whole tree, whatever part of it is on screen and
|
|
17178
|
+
// however far it is zoomed out, drawn large enough for every tip name.
|
|
17179
|
+
//
|
|
17180
|
+
// The tree is re-laid-out at that size -- not scaled: scaling the picture
|
|
17181
|
+
// would enlarge the names with it and leave them exactly as crowded --
|
|
17182
|
+
// serialized, and put back. All of it runs in one task, so the screen
|
|
17183
|
+
// never paints the enlarged tree, and the zoom transform is never
|
|
17184
|
+
// touched: the copy simply leaves it out.
|
|
17185
|
+
function graphicFullSize() {
|
|
17186
|
+
let svg = treeSvgElement();
|
|
17187
|
+
let keepW = _displayWidth;
|
|
17188
|
+
let keepH = _displayHeight;
|
|
17189
|
+
let keepHide = _state.dynahide;
|
|
17190
|
+
try {
|
|
17191
|
+
growToFullSize(keepHide);
|
|
17192
|
+
return serializeFullSize(svg);
|
|
17193
|
+
} finally {
|
|
17194
|
+
_state.dynahide = keepHide;
|
|
17195
|
+
_displayWidth = keepW;
|
|
17196
|
+
_displayHeight = keepH;
|
|
17197
|
+
update(null, 0);
|
|
17198
|
+
}
|
|
17199
|
+
}
|
|
17200
|
+
|
|
17201
|
+
// Enlarge the layout until no tip name has to be dropped. Rectangular: the
|
|
17202
|
+
// row pitch is set outright. Circular: the ring is sized from its tips
|
|
17203
|
+
// first. Then every layout is ASKED -- the auto-hide rules, switched on
|
|
17204
|
+
// for the question, are what decide whether a name fits -- and grown
|
|
17205
|
+
// again until none is refused. The width never shrinks below the window,
|
|
17206
|
+
// so a tree zoomed out to a sliver comes out at a readable width.
|
|
17207
|
+
function growToFullSize(userHides) {
|
|
17208
|
+
let pitch = Math.max(4, _state.externalNodeFontSize || 0) * FULL_SIZE_ROW_PITCH;
|
|
17209
|
+
let fit = _settings.enableDynamicSizing ? displaySizeFromContainer() : null;
|
|
17210
|
+
let fitW = (fit && fit.w > 0) ? fit.w : _settings.displayWidth;
|
|
17211
|
+
let fitH = (fit && fit.h > 0) ? fit.h : _settings.displayHeight;
|
|
17212
|
+
let cap = function (v) {
|
|
17213
|
+
return Math.min(FULL_SIZE_MAX_EXTENT, v);
|
|
17214
|
+
};
|
|
17215
|
+
_state.dynahide = true;
|
|
17216
|
+
if (radialDisplay()) {
|
|
17217
|
+
let side = cap(Math.max(Math.min(_displayWidth, _displayHeight), Math.min(fitW, fitH)));
|
|
17218
|
+
_displayWidth = side;
|
|
17219
|
+
_displayHeight = side;
|
|
17220
|
+
update(null, 0);
|
|
17221
|
+
// Sized first so every tip has a label's height of arc: on the
|
|
17222
|
+
// ring in circular, where the names stand; in unrooted, where
|
|
17223
|
+
// there is no ring, as if the tips were spread evenly around the
|
|
17224
|
+
// fan's rim -- the most a fan's size can promise.
|
|
17225
|
+
let rows = 0;
|
|
17226
|
+
forEachDisplayed(function (n) {
|
|
17227
|
+
if (!n.children || isCollapsed(n)) {
|
|
17228
|
+
++rows;
|
|
17229
|
+
}
|
|
16881
17230
|
});
|
|
16882
|
-
|
|
16883
|
-
|
|
16884
|
-
|
|
17231
|
+
let span = (_state.circularDisplay && _radial) ? Math.max(0.1, _radial.angleSpan) : 2 * Math.PI;
|
|
17232
|
+
let radius = (_state.circularDisplay && _radial) ? _radial.maxRad : _displayWidth * 0.42;
|
|
17233
|
+
let need = (rows * pitch) / span;
|
|
17234
|
+
if (radius > 0 && need > radius) {
|
|
17235
|
+
_displayWidth = cap(_displayWidth * need / radius);
|
|
17236
|
+
_displayHeight = _displayWidth;
|
|
17237
|
+
update(null, 0);
|
|
17238
|
+
}
|
|
17239
|
+
// Then grown while growing still frees names, and no further. In
|
|
17240
|
+
// an unrooted fan names collide that no size will part: flu_h5's
|
|
17241
|
+
// clusters sit on branches a thousand times shorter than the tree,
|
|
17242
|
+
// and at 67,000 px across 176 of its 354 names were still refused
|
|
17243
|
+
// (332 at 850 px, 322 at 2,300) -- chasing them only made a file
|
|
17244
|
+
// too large to paint as a PNG. The drawing kept is the smallest
|
|
17245
|
+
// that showed the fewest refusals.
|
|
17246
|
+
let best = {hidden: _radialNamesHidden, side: _displayWidth};
|
|
17247
|
+
for (let i = 0; i < 12 && _radialNamesHidden > 0 && _displayWidth < FULL_SIZE_MAX_EXTENT; ++i) {
|
|
17248
|
+
_displayWidth = cap(_displayWidth * 1.4);
|
|
17249
|
+
_displayHeight = _displayWidth;
|
|
17250
|
+
update(null, 0);
|
|
17251
|
+
if (_radialNamesHidden < best.hidden * 0.95) {
|
|
17252
|
+
best = {hidden: _radialNamesHidden, side: _displayWidth};
|
|
17253
|
+
} else {
|
|
17254
|
+
break;
|
|
17255
|
+
}
|
|
17256
|
+
}
|
|
17257
|
+
if (_displayWidth !== best.side) {
|
|
17258
|
+
_displayWidth = best.side;
|
|
17259
|
+
_displayHeight = best.side;
|
|
17260
|
+
update(null, 0);
|
|
17261
|
+
}
|
|
17262
|
+
} else {
|
|
17263
|
+
_displayWidth = cap(Math.max(_displayWidth, fitW));
|
|
17264
|
+
_displayHeight = cap(Math.max(_displayHeight, fitH));
|
|
17265
|
+
update(null, 0);
|
|
17266
|
+
for (let i = 0; i < 4 && (_rowUnit < pitch * 0.999 || _dynahide_factor >= 2)
|
|
17267
|
+
&& _displayHeight < FULL_SIZE_MAX_EXTENT; ++i) {
|
|
17268
|
+
// the vertical span less the fixed reserves is what the rows
|
|
17269
|
+
// share, so that is the part that scales
|
|
17270
|
+
let span = layoutSpans().vertical;
|
|
17271
|
+
let reserve = _displayHeight - span;
|
|
17272
|
+
let f = Math.max(pitch / Math.max(_rowUnit, 1e-6), 1.05);
|
|
17273
|
+
_displayHeight = cap(reserve + (span * f));
|
|
17274
|
+
update(null, 0);
|
|
17275
|
+
}
|
|
17276
|
+
}
|
|
17277
|
+
// drawn once more under the user's own auto-hide choice: with it off,
|
|
17278
|
+
// what it would have hidden is drawn, as it is on screen
|
|
17279
|
+
if (!userHides) {
|
|
17280
|
+
_state.dynahide = false;
|
|
17281
|
+
update(null, 0);
|
|
16885
17282
|
}
|
|
16886
|
-
|
|
17283
|
+
}
|
|
17284
|
+
|
|
17285
|
+
// The smallest box around every non-empty one of `boxes` (getBBox
|
|
17286
|
+
// results), as {x0, y0, x1, y1}; null when none has any size.
|
|
17287
|
+
function unionBox(boxes) {
|
|
17288
|
+
let u = null;
|
|
17289
|
+
boxes.forEach(function (b) {
|
|
17290
|
+
if (!b || !(b.width > 0 || b.height > 0)) {
|
|
17291
|
+
return;
|
|
17292
|
+
}
|
|
17293
|
+
if (!u) {
|
|
17294
|
+
u = {x0: b.x, y0: b.y, x1: b.x + b.width, y1: b.y + b.height};
|
|
17295
|
+
} else {
|
|
17296
|
+
u.x0 = Math.min(u.x0, b.x);
|
|
17297
|
+
u.y0 = Math.min(u.y0, b.y);
|
|
17298
|
+
u.x1 = Math.max(u.x1, b.x + b.width);
|
|
17299
|
+
u.y1 = Math.max(u.y1, b.y + b.height);
|
|
17300
|
+
}
|
|
17301
|
+
});
|
|
17302
|
+
return u;
|
|
17303
|
+
}
|
|
17304
|
+
|
|
17305
|
+
// Every card the viewer pins to the WINDOW rather than to the tree: the
|
|
17306
|
+
// colour and shape legends, the domain legend, the heat-map ring's scale.
|
|
17307
|
+
// On screen they sit where the window has room; in a full-size figure the
|
|
17308
|
+
// window is gone, so they are stacked beside the tree instead.
|
|
17309
|
+
const WINDOW_LEGEND_CLASSES = [LEGEND_LABEL_COLOR, LEGEND_NODE_SHAPE, LEGEND_DOMAINS, 'aptx-heatmap-ringlegend'];
|
|
17310
|
+
const FULL_SIZE_LEGEND_STACK_GAP = 12;
|
|
17311
|
+
|
|
17312
|
+
// The enlarged drawing, cropped to what is actually drawn: the tree
|
|
17313
|
+
// (with its floating strips, anchored to it) and, in a column to its left
|
|
17314
|
+
// rather than over it, the legend cards.
|
|
17315
|
+
function serializeFullSize(svg) {
|
|
17316
|
+
let M = FULL_SIZE_MARGIN;
|
|
17317
|
+
// a strip's content is drawn in tree coordinates; its own transform
|
|
17318
|
+
// only places it on screen, and getBBox leaves that out
|
|
17319
|
+
let strips = _floatGroup ? Array.prototype.slice.call(_floatGroup.node().children) : [];
|
|
17320
|
+
let box = unionBox([_svgGroup.node().getBBox()].concat(strips.map(function (g) {
|
|
17321
|
+
return g.getBBox();
|
|
17322
|
+
})));
|
|
17323
|
+
if (!box) {
|
|
17324
|
+
return graphicAsShown();
|
|
17325
|
+
}
|
|
17326
|
+
let cards = [];
|
|
17327
|
+
WINDOW_LEGEND_CLASSES.forEach(function (cls) {
|
|
17328
|
+
let g = svg.querySelector(':scope > g.' + cls);
|
|
17329
|
+
let b = g ? g.getBBox() : null;
|
|
17330
|
+
if (b && (b.width > 0 || b.height > 0)) {
|
|
17331
|
+
cards.push({g: g, b: b});
|
|
17332
|
+
}
|
|
17333
|
+
});
|
|
17334
|
+
let columnW = 0;
|
|
17335
|
+
let columnH = 0;
|
|
17336
|
+
cards.forEach(function (c, i) {
|
|
17337
|
+
columnW = Math.max(columnW, c.b.width);
|
|
17338
|
+
columnH += c.b.height + (i > 0 ? FULL_SIZE_LEGEND_STACK_GAP : 0);
|
|
17339
|
+
});
|
|
17340
|
+
let legendW = cards.length > 0 ? columnW + FULL_SIZE_LEGEND_GAP : 0;
|
|
17341
|
+
let treeW = box.x1 - box.x0;
|
|
17342
|
+
let treeH = box.y1 - box.y0;
|
|
17343
|
+
let W = Math.ceil(M + legendW + treeW + M);
|
|
17344
|
+
let H = Math.ceil(M + Math.max(treeH, columnH) + M);
|
|
17345
|
+
|
|
17346
|
+
let copy = svg.cloneNode(true);
|
|
17347
|
+
let place = 'translate(' + (M + legendW - box.x0) + ',' + (M - box.y0) + ')';
|
|
17348
|
+
let tree = copyCounterpart(svg, copy, _svgGroup.node());
|
|
17349
|
+
if (tree) {
|
|
17350
|
+
tree.setAttribute('transform', place);
|
|
17351
|
+
}
|
|
17352
|
+
copy.querySelectorAll('.aptx-float > g').forEach(function (strip) {
|
|
17353
|
+
strip.setAttribute('transform', place);
|
|
17354
|
+
});
|
|
17355
|
+
// A card's own transform, where it has one, only placed it in the
|
|
17356
|
+
// window (getBBox leaves it out), so it is replaced, not added to.
|
|
17357
|
+
let y = M;
|
|
17358
|
+
cards.forEach(function (c) {
|
|
17359
|
+
let cc = copyCounterpart(svg, copy, c.g);
|
|
17360
|
+
if (cc) {
|
|
17361
|
+
cc.setAttribute('transform', 'translate(' + (M - c.b.x) + ',' + (y - c.b.y) + ')');
|
|
17362
|
+
}
|
|
17363
|
+
y += c.b.height + FULL_SIZE_LEGEND_STACK_GAP;
|
|
17364
|
+
});
|
|
17365
|
+
cleanExportCopy(copy);
|
|
17366
|
+
copy.setAttribute('width', W);
|
|
17367
|
+
copy.setAttribute('height', H);
|
|
17368
|
+
copy.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
|
|
17369
|
+
return {text: toLightExport((new XMLSerializer()).serializeToString(copy)), w: W, h: H};
|
|
16887
17370
|
}
|
|
16888
17371
|
|
|
16889
17372
|
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) {
|
|
17373
|
+
if (format === NH_EXPORT_FORMAT) {
|
|
16899
17374
|
downloadAsNH();
|
|
16900
17375
|
} else if (format === NEXUS_EXPORT_FORMAT) {
|
|
16901
17376
|
downloadAsNexus();
|
|
16902
17377
|
} else if (format === PHYLOXML_EXPORT_FORMAT) {
|
|
16903
17378
|
downloadAsPhyloXml();
|
|
16904
|
-
} else if (format === PDF_EXPORT_FORMAT) {
|
|
16905
|
-
changeBaseBackgoundColor(_state.backgroundColorForPrintExportDefault);
|
|
16906
|
-
downloadAsPdf();
|
|
16907
|
-
changeBaseBackgoundColor(_state.backgroundColorDefault);
|
|
16908
17379
|
} else if (format === FASTA_EXPORT_FORMAT) {
|
|
16909
17380
|
downloadAsFastaAll();
|
|
17381
|
+
} else if (format === TSV_EXPORT_FORMAT) {
|
|
17382
|
+
downloadAsTsvAll();
|
|
17383
|
+
} else if (format === SVG_EXPORT_FORMAT || format === PDF_EXPORT_FORMAT || format === PNG_EXPORT_FORMAT) {
|
|
17384
|
+
withGraphic(function (graphic) {
|
|
17385
|
+
if (format === SVG_EXPORT_FORMAT) {
|
|
17386
|
+
downloadAsSVG(graphic);
|
|
17387
|
+
} else if (format === PDF_EXPORT_FORMAT) {
|
|
17388
|
+
downloadAsPdf(graphic);
|
|
17389
|
+
} else {
|
|
17390
|
+
downloadAsPng(graphic);
|
|
17391
|
+
}
|
|
17392
|
+
});
|
|
16910
17393
|
}
|
|
16911
17394
|
}
|
|
16912
17395
|
|
|
17396
|
+
// Hands fn the graphic the Download section's choices describe, drawn in
|
|
17397
|
+
// the light export colours; a failure -- no graphic, or a throw anywhere
|
|
17398
|
+
// on the way -- goes to fail instead, so a caller waiting on the result
|
|
17399
|
+
// (Copy PNG's clipboard write) always hears back. A full-size drawing
|
|
17400
|
+
// re-lays-out the tree several times over, each pass larger than the
|
|
17401
|
+
// redraw last timed, so the card goes up when that redraw was even a
|
|
17402
|
+
// fraction of the card's threshold.
|
|
17403
|
+
function withGraphic(fn, fail) {
|
|
17404
|
+
// without a handler the error is thrown on, as it was before there
|
|
17405
|
+
// was one -- to the page's error handling and the harnesses -- rather
|
|
17406
|
+
// than turned into a console line and a click that did nothing
|
|
17407
|
+
let failed = fail || function (err) {
|
|
17408
|
+
throw err;
|
|
17409
|
+
};
|
|
17410
|
+
let draw = function () {
|
|
17411
|
+
let graphic;
|
|
17412
|
+
try {
|
|
17413
|
+
changeBaseBackgoundColor(_state.backgroundColorForPrintExportDefault);
|
|
17414
|
+
try {
|
|
17415
|
+
graphic = exportGraphic();
|
|
17416
|
+
} finally {
|
|
17417
|
+
changeBaseBackgoundColor(_state.backgroundColorDefault);
|
|
17418
|
+
}
|
|
17419
|
+
if (!graphic) {
|
|
17420
|
+
throw new Error('this browser cannot serialize the tree');
|
|
17421
|
+
}
|
|
17422
|
+
fn(graphic);
|
|
17423
|
+
} catch (err) {
|
|
17424
|
+
failed(err);
|
|
17425
|
+
}
|
|
17426
|
+
};
|
|
17427
|
+
if (_downloadFullSize && _redrawMs * FULL_SIZE_REDRAWS_EXPECTED >= REDRAW_CARD_MS && _container) {
|
|
17428
|
+
showBusy(_container, 'Drawing the whole tree');
|
|
17429
|
+
afterPaint(function () {
|
|
17430
|
+
try {
|
|
17431
|
+
draw();
|
|
17432
|
+
} finally {
|
|
17433
|
+
hideBusy();
|
|
17434
|
+
}
|
|
17435
|
+
});
|
|
17436
|
+
} else {
|
|
17437
|
+
draw();
|
|
17438
|
+
}
|
|
17439
|
+
}
|
|
17440
|
+
|
|
17441
|
+
// How many redraws a full-size drawing typically costs: the drawing at the
|
|
17442
|
+
// window's size, one or two to reach the size, and the one that puts the
|
|
17443
|
+
// window back -- more on a crowded fan.
|
|
17444
|
+
const FULL_SIZE_REDRAWS_EXPECTED = 4;
|
|
17445
|
+
|
|
16913
17446
|
/**
|
|
16914
17447
|
* Saves a Blob to a file using native browser APIs (an <a download> link
|
|
16915
17448
|
* pointing at an object URL). This replaces the former FileSaver.js
|
|
@@ -16937,9 +17470,34 @@ function (root, d3, forester, phyloXml) {
|
|
|
16937
17470
|
}, 1000);
|
|
16938
17471
|
}
|
|
16939
17472
|
|
|
17473
|
+
// What a tree file is written from. The whole tree as it is; a subtree
|
|
17474
|
+
// view's root is a bare wrapper made when the subtree was entered, and
|
|
17475
|
+
// written as it stands it gave the file no name at all (phyloXML wrote
|
|
17476
|
+
// none, Nexus "tree1"). So it goes out under the tree's own name, marked
|
|
17477
|
+
// as a subtree. A shallow copy: the drawing's wrapper is left alone, and
|
|
17478
|
+
// the whole-tree files are byte for byte what they were.
|
|
17479
|
+
function treeForExport() {
|
|
17480
|
+
if (_root === _root_const || !_root_const) {
|
|
17481
|
+
return _root;
|
|
17482
|
+
}
|
|
17483
|
+
let name = _root_const.name ? String(_root_const.name).trim() : '';
|
|
17484
|
+
return Object.assign({}, _root, {name: name ? name + ' (subtree)' : undefined});
|
|
17485
|
+
}
|
|
17486
|
+
|
|
17487
|
+
// "subtree, 19 of 50 tips" while a subtree is shown, else null: said in
|
|
17488
|
+
// the Download menu, so nobody writes a part of the tree thinking it the
|
|
17489
|
+
// whole.
|
|
17490
|
+
function subtreeExtentNote() {
|
|
17491
|
+
if (_root === _root_const || !_root_const) {
|
|
17492
|
+
return null;
|
|
17493
|
+
}
|
|
17494
|
+
return 'subtree, ' + forester.getAllExternalNodes(_root).length.toLocaleString() + ' of '
|
|
17495
|
+
+ forester.getAllExternalNodes(_root_const).length.toLocaleString() + ' tips';
|
|
17496
|
+
}
|
|
17497
|
+
|
|
16940
17498
|
function downloadAsPhyloXml() {
|
|
16941
|
-
let x = phyloXml.toPhyloXML(
|
|
16942
|
-
saveAs(new Blob([x], {type: "application/xml"}),
|
|
17499
|
+
let x = phyloXml.toPhyloXML(treeForExport(), 9);
|
|
17500
|
+
saveAs(new Blob([x], {type: "application/xml"}), downloadFileName(XML_SUFFIX));
|
|
16943
17501
|
}
|
|
16944
17502
|
|
|
16945
17503
|
function downloadAsNH() {
|
|
@@ -16947,25 +17505,43 @@ function (root, d3, forester, phyloXml) {
|
|
|
16947
17505
|
// not a preference: writing them out produces a file that will not parse
|
|
16948
17506
|
// back in.
|
|
16949
17507
|
let nh = forester.toNewHampshire(_root, 9, true, _settings.nhExportWriteConfidences);
|
|
16950
|
-
saveAs(new Blob([nh], {type: "application/txt"}),
|
|
17508
|
+
saveAs(new Blob([nh], {type: "application/txt"}), downloadFileName(NH_SUFFIX));
|
|
16951
17509
|
}
|
|
16952
17510
|
|
|
16953
17511
|
function downloadAsNexus() {
|
|
16954
17512
|
// taxa and trees blocks as on the desktop, plus a characters block
|
|
16955
17513
|
// when the tips carry an alignment -- carrying tree and alignment
|
|
16956
17514
|
// in one file is what Nexus is for
|
|
16957
|
-
let
|
|
16958
|
-
|
|
17515
|
+
let phy = treeForExport();
|
|
17516
|
+
let nex = forester.toNexus(phy, 9, _settings.nhExportWriteConfidences);
|
|
17517
|
+
saveAs(new Blob([nex], {type: "application/txt"}), downloadFileName(NEXUS_SUFFIX));
|
|
16959
17518
|
}
|
|
16960
17519
|
|
|
16961
|
-
function downloadAsSVG() {
|
|
16962
|
-
|
|
16963
|
-
|
|
17520
|
+
function downloadAsSVG(graphic) {
|
|
17521
|
+
saveAs(new Blob([graphic.text], {type: "image/svg+xml"}), downloadFileName(SVG_SUFFIX));
|
|
17522
|
+
}
|
|
17523
|
+
|
|
17524
|
+
// Every tip's data, one row per tip top to bottom as drawn: the node
|
|
17525
|
+
// menu's "Download Ext. Node Data" for the whole tree -- the desktop's
|
|
17526
|
+
// columns, and a table that joins back onto the tree as metadata.
|
|
17527
|
+
function downloadAsTsvAll() {
|
|
17528
|
+
saveAs(new Blob([tipTableTsv(forester.getAllExternalNodes(_root).reverse())],
|
|
17529
|
+
{type: 'text/tab-separated-values'}), downloadFileName(TSV_SUFFIX));
|
|
17530
|
+
}
|
|
17531
|
+
|
|
17532
|
+
// The table of `tips` (top to bottom as drawn: getAllExternalNodes
|
|
17533
|
+
// reversed) -- one writer for the Download menu's TSV and the node
|
|
17534
|
+
// menu's Download Ext. Node Data, so their columns and ids cannot drift
|
|
17535
|
+
// apart. The caller walks the tips, once, and may count them too.
|
|
17536
|
+
function tipTableTsv(tips) {
|
|
17537
|
+
return forester.externalNodeDataTsv(tips, function (n, i) {
|
|
17538
|
+
return (n.viewId !== undefined) ? n.viewId : i + 1;
|
|
17539
|
+
});
|
|
16964
17540
|
}
|
|
16965
17541
|
|
|
16966
17542
|
function downloadAsFastaAll() {
|
|
16967
17543
|
let fasta_text = forester.getMolecularSequencesAsFasta(_root, '\n');
|
|
16968
|
-
saveAs(new Blob([fasta_text], {type: "application/txt"}),
|
|
17544
|
+
saveAs(new Blob([fasta_text], {type: "application/txt"}), downloadFileName(FASTA_SUFFIX));
|
|
16969
17545
|
}
|
|
16970
17546
|
|
|
16971
17547
|
// Vector PDF via the OPTIONAL page-level libraries jsPDF and svg2pdf.js
|
|
@@ -16979,13 +17555,18 @@ function (root, d3, forester, phyloXml) {
|
|
|
16979
17555
|
&& window.jspdf.jsPDF.API.svg);
|
|
16980
17556
|
}
|
|
16981
17557
|
|
|
16982
|
-
|
|
17558
|
+
// The largest page a PDF may have: 200 inches a side (ISO 32000, Annex C).
|
|
17559
|
+
// Readers refuse, or quietly clip, anything past it -- and a full-size
|
|
17560
|
+
// tree of a few thousand tips is past it -- so a larger drawing is scaled
|
|
17561
|
+
// onto the largest page instead. It is vector: nothing is lost by that.
|
|
17562
|
+
const PDF_MAX_PAGE_PT = 14400;
|
|
17563
|
+
|
|
17564
|
+
function downloadAsPdf(graphic) {
|
|
16983
17565
|
if (!pdfExportAvailable()) {
|
|
16984
17566
|
console.error(ERROR + 'PDF export needs the optional jspdf and svg2pdf.js libraries on the page');
|
|
16985
17567
|
return;
|
|
16986
17568
|
}
|
|
16987
|
-
let
|
|
16988
|
-
let el = new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement;
|
|
17569
|
+
let el = new DOMParser().parseFromString(graphic.text, 'image/svg+xml').documentElement;
|
|
16989
17570
|
// the full-canvas background rect is sized in percentages, which
|
|
16990
17571
|
// svg2pdf renders literally (a dark 100pt square at the origin); the
|
|
16991
17572
|
// PDF page is white anyway, so the rect is simply dropped
|
|
@@ -17017,9 +17598,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
17017
17598
|
}
|
|
17018
17599
|
});
|
|
17019
17600
|
});
|
|
17020
|
-
let
|
|
17021
|
-
let w =
|
|
17022
|
-
let h =
|
|
17601
|
+
let fit = Math.min(1, PDF_MAX_PAGE_PT / Math.max(graphic.w, graphic.h));
|
|
17602
|
+
let w = graphic.w * fit;
|
|
17603
|
+
let h = graphic.h * fit;
|
|
17023
17604
|
let pdf = new window.jspdf.jsPDF({
|
|
17024
17605
|
orientation: w >= h ? 'landscape' : 'portrait',
|
|
17025
17606
|
unit: 'pt',
|
|
@@ -17027,7 +17608,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
17027
17608
|
compress: true
|
|
17028
17609
|
});
|
|
17029
17610
|
pdf.svg(el, {x: 0, y: 0, width: w, height: h}).then(function () {
|
|
17030
|
-
pdf.save(
|
|
17611
|
+
pdf.save(downloadFileName(PDF_SUFFIX));
|
|
17031
17612
|
holder.remove();
|
|
17032
17613
|
}, function (err) {
|
|
17033
17614
|
holder.remove();
|
|
@@ -17044,23 +17625,62 @@ function (root, d3, forester, phyloXml) {
|
|
|
17044
17625
|
return !!(window.Canvg && window.Canvg.fromString);
|
|
17045
17626
|
}
|
|
17046
17627
|
|
|
17047
|
-
|
|
17628
|
+
// The biggest canvas this browser will actually paint, at no more than
|
|
17629
|
+
// the scale asked for. Browsers cap a canvas by side and by area, and
|
|
17630
|
+
// differently (Safari's area cap is a sixteenth of Chrome's); past the
|
|
17631
|
+
// cap a canvas is not an error, it is just blank -- which is what a
|
|
17632
|
+
// full-size PNG of a large tree would silently have been. So the canvas
|
|
17633
|
+
// is TRIED: filled, and its far corner read back.
|
|
17634
|
+
function paintableCanvas(w, h, scale) {
|
|
17635
|
+
for (let k = scale; k > 0.05; k *= 0.8) {
|
|
17636
|
+
let canvas = document.createElement('canvas');
|
|
17637
|
+
canvas.width = Math.max(1, Math.round(w * k));
|
|
17638
|
+
canvas.height = Math.max(1, Math.round(h * k));
|
|
17639
|
+
let ctx = canvas.getContext('2d');
|
|
17640
|
+
if (ctx) {
|
|
17641
|
+
ctx.fillStyle = '#ffffff';
|
|
17642
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
17643
|
+
let px;
|
|
17644
|
+
try {
|
|
17645
|
+
px = ctx.getImageData(canvas.width - 1, canvas.height - 1, 1, 1).data;
|
|
17646
|
+
} catch {
|
|
17647
|
+
px = null;
|
|
17648
|
+
}
|
|
17649
|
+
if (px && px[3] === 255) {
|
|
17650
|
+
return {canvas: canvas, ctx: ctx, scale: k};
|
|
17651
|
+
}
|
|
17652
|
+
}
|
|
17653
|
+
}
|
|
17654
|
+
return null;
|
|
17655
|
+
}
|
|
17656
|
+
|
|
17657
|
+
function downloadAsPng(graphic) {
|
|
17658
|
+
renderPng(graphic).then(function (blob) {
|
|
17659
|
+
saveAs(blob, downloadFileName(PNG_SUFFIX));
|
|
17660
|
+
}, function (err) {
|
|
17661
|
+
console.error(ERROR + 'PNG export failed: ' + err);
|
|
17662
|
+
});
|
|
17663
|
+
}
|
|
17664
|
+
|
|
17665
|
+
// The graphic as a PNG Blob, at the Download section's 2x/4x/8x -- less
|
|
17666
|
+
// where the browser cannot paint a canvas that large (paintableCanvas).
|
|
17667
|
+
function renderPng(graphic) {
|
|
17668
|
+
if (!graphic) {
|
|
17669
|
+
return Promise.reject(new Error('nothing to draw'));
|
|
17670
|
+
}
|
|
17048
17671
|
if (!pngExportAvailable()) {
|
|
17049
|
-
|
|
17050
|
-
|
|
17672
|
+
return Promise.reject(new Error('PNG export needs the optional canvg library on the page'));
|
|
17673
|
+
}
|
|
17674
|
+
let target = paintableCanvas(graphic.w, graphic.h, _pngScale);
|
|
17675
|
+
if (!target) {
|
|
17676
|
+
return Promise.reject(new Error('this browser cannot paint a canvas of that size'));
|
|
17051
17677
|
}
|
|
17052
|
-
|
|
17053
|
-
|
|
17054
|
-
|
|
17055
|
-
|
|
17056
|
-
let
|
|
17057
|
-
let
|
|
17058
|
-
let w = (svgEl && svgEl.width.baseVal.value) || _displayWidth;
|
|
17059
|
-
let h = (svgEl && svgEl.height.baseVal.value) || _displayHeight;
|
|
17060
|
-
let canvas = document.createElement('canvas');
|
|
17061
|
-
canvas.width = Math.round(w * scale);
|
|
17062
|
-
canvas.height = Math.round(h * scale);
|
|
17063
|
-
let ctx = canvas.getContext('2d');
|
|
17678
|
+
if (target.scale < _pngScale) {
|
|
17679
|
+
console.warn(WARNING + ': PNG exported at ' + target.scale.toFixed(2) + 'x rather than ' + _pngScale
|
|
17680
|
+
+ 'x: the largest image this browser can paint');
|
|
17681
|
+
}
|
|
17682
|
+
let canvas = target.canvas;
|
|
17683
|
+
let ctx = target.ctx;
|
|
17064
17684
|
// The up-scaling is done on the CONTEXT, not through canvg's own
|
|
17065
17685
|
// scaleWidth/scaleHeight: canvg 4's scaling pipeline mis-scales (a
|
|
17066
17686
|
// requested 4x came out at 2.5x, leaving unpainted margins -- and any
|
|
@@ -17069,20 +17689,56 @@ function (root, d3, forester, phyloXml) {
|
|
|
17069
17689
|
// background before canvg draws a thing. ignoreDimensions keeps canvg
|
|
17070
17690
|
// from resizing the canvas to the SVG's size (which would erase both
|
|
17071
17691
|
// 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()
|
|
17692
|
+
// fill back to transparent. v4's render() is async (v1's canvg()
|
|
17075
17693
|
// call drew synchronously, so toBlob could follow on the next line).
|
|
17076
17694
|
ctx.fillStyle = _state.backgroundColorForPrintExportDefault;
|
|
17077
17695
|
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
|
-
|
|
17696
|
+
ctx.scale(target.scale, target.scale);
|
|
17697
|
+
let v = window.Canvg.fromString(ctx, graphic.text);
|
|
17698
|
+
return v.render({ignoreDimensions: true, ignoreClear: true}).then(function () {
|
|
17699
|
+
return new Promise(function (resolve, reject) {
|
|
17700
|
+
canvas.toBlob(function (blob) {
|
|
17701
|
+
if (blob) {
|
|
17702
|
+
resolve(blob);
|
|
17703
|
+
} else {
|
|
17704
|
+
reject(new Error('the canvas gave no image'));
|
|
17705
|
+
}
|
|
17706
|
+
}, 'image/png');
|
|
17083
17707
|
});
|
|
17708
|
+
});
|
|
17709
|
+
}
|
|
17710
|
+
|
|
17711
|
+
// Copying an image needs the async clipboard's write() and ClipboardItem
|
|
17712
|
+
// (a secure page: https, or localhost) as well as canvg; the button is
|
|
17713
|
+
// only offered where all three are there.
|
|
17714
|
+
function pngCopyAvailable() {
|
|
17715
|
+
return pngExportAvailable() && typeof window.ClipboardItem === 'function'
|
|
17716
|
+
&& !!(navigator.clipboard && navigator.clipboard.write);
|
|
17717
|
+
}
|
|
17718
|
+
|
|
17719
|
+
// The desktop's "copy image": the picture straight onto the clipboard,
|
|
17720
|
+
// for a Word document or a slide. The ClipboardItem is made at once, with
|
|
17721
|
+
// the image as a PROMISE, inside the click: Safari allows a clipboard
|
|
17722
|
+
// write only during the gesture, and drawing the PNG takes longer than
|
|
17723
|
+
// that -- a promise-valued item is how it waits (Chrome takes it too).
|
|
17724
|
+
function copyPngPressed(event) {
|
|
17725
|
+
let btn = event.currentTarget;
|
|
17726
|
+
let png = new Promise(function (resolve, reject) {
|
|
17727
|
+
withGraphic(function (graphic) {
|
|
17728
|
+
renderPng(graphic).then(resolve, reject);
|
|
17729
|
+
}, reject);
|
|
17730
|
+
});
|
|
17731
|
+
let written;
|
|
17732
|
+
try {
|
|
17733
|
+
written = navigator.clipboard.write([new window.ClipboardItem({'image/png': png})]);
|
|
17734
|
+
} catch (e) {
|
|
17735
|
+
written = Promise.reject(e);
|
|
17736
|
+
}
|
|
17737
|
+
written.then(function () {
|
|
17738
|
+
flashButton(btn, 'Copied ✓');
|
|
17084
17739
|
}, function (err) {
|
|
17085
|
-
console.error(ERROR + 'PNG
|
|
17740
|
+
console.error(ERROR + 'copying the PNG failed: ' + err);
|
|
17741
|
+
flashButton(btn, 'Copy failed');
|
|
17086
17742
|
});
|
|
17087
17743
|
}
|
|
17088
17744
|
|