archaeopteryx 3.15.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 +178 -24
- package/archaeopteryx.d.ts +2 -0
- package/archaeopteryx.js +1661 -285
- package/forester.js +191 -29
- package/package.json +4 -2
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';
|
|
@@ -408,6 +409,20 @@ function (root, d3, forester, phyloXml) {
|
|
|
408
409
|
const VIS_CB = 'vis_cb';
|
|
409
410
|
const LADDERIZE_BUTTON = 'ladderize_b';
|
|
410
411
|
const PHYLOGRAM_ALIGNED_BUTTON = 'phya_b';
|
|
412
|
+
// What the three display types DO, in one place: the tooltip, the reason a
|
|
413
|
+
// type is greyed, and the cheat-sheet row are all this sentence. A control
|
|
414
|
+
// named only after itself ("phylogram display (uses branch length
|
|
415
|
+
// values)") tells a reader who already knows what it is.
|
|
416
|
+
// Each says WHEN it is greyed, whether or not it is greyed now: that is a
|
|
417
|
+
// property of the control, not of the view, and a reader of the sheet in
|
|
418
|
+
// one layout is owed what happens in the others.
|
|
419
|
+
const PHYLOGRAM_WHAT = 'phylogram: branch lengths drawn to scale, so the tips end ragged.'
|
|
420
|
+
+ ' Greyed in the circular layout, which always carries its labels to the outer ring,'
|
|
421
|
+
+ ' so there the aligned phylogram is the one drawn';
|
|
422
|
+
const PHYLOGRAM_ALIGNED_WHAT = 'aligned phylogram: branch lengths to scale, with every tip'
|
|
423
|
+
+ ' carried on to a common column, or in the circular layout to the outer ring.'
|
|
424
|
+
+ ' Greyed in the unrooted layout, which has no column or ring to carry them to';
|
|
425
|
+
const CLADOGRAM_WHAT = 'cladogram: topology only, branch lengths ignored, every tip flush';
|
|
411
426
|
const PHYLOGRAM_BUTTON = 'phy_b';
|
|
412
427
|
const PHYLOGRAM_CLADOGRAM_CONTROLGROUP = 'phy_cla_g';
|
|
413
428
|
const BRANCH_SCALE_CONTROLGROUP = 'branch_scale_g';
|
|
@@ -484,6 +499,12 @@ function (root, d3, forester, phyloXml) {
|
|
|
484
499
|
// "Instance variables"
|
|
485
500
|
// ---------------------------
|
|
486
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;
|
|
487
508
|
let _basicTreeProperties = null;
|
|
488
509
|
let _displayHeight = 0;
|
|
489
510
|
let _displayWidth = 0;
|
|
@@ -1553,8 +1574,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
1553
1574
|
}
|
|
1554
1575
|
}
|
|
1555
1576
|
|
|
1556
|
-
// items: [{label, action, danger}]; anchored at the click position
|
|
1557
|
-
|
|
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) {
|
|
1558
1582
|
removeNodeMenu();
|
|
1559
1583
|
if (!items || items.length < 1) {
|
|
1560
1584
|
return;
|
|
@@ -1582,9 +1606,22 @@ function (root, d3, forester, phyloXml) {
|
|
|
1582
1606
|
menu.appendChild(note);
|
|
1583
1607
|
return;
|
|
1584
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
|
+
}
|
|
1585
1616
|
let b = document.createElement('button');
|
|
1586
1617
|
b.type = 'button';
|
|
1587
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
|
+
}
|
|
1588
1625
|
if (item.title) {
|
|
1589
1626
|
b.title = item.title;
|
|
1590
1627
|
}
|
|
@@ -1604,10 +1641,19 @@ function (root, d3, forester, phyloXml) {
|
|
|
1604
1641
|
document.body.appendChild(menu);
|
|
1605
1642
|
|
|
1606
1643
|
// Place it at the pointer, nudged back inside the window if it would
|
|
1607
|
-
// 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.
|
|
1608
1647
|
let x = (event && event.pageX !== undefined) ? event.pageX : 0;
|
|
1609
1648
|
let y = (event && event.pageY !== undefined) ? event.pageY : 0;
|
|
1610
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
|
+
}
|
|
1611
1657
|
let maxX = window.scrollX + document.documentElement.clientWidth - r.width - 8;
|
|
1612
1658
|
let maxY = window.scrollY + document.documentElement.clientHeight - r.height - 8;
|
|
1613
1659
|
menu.style.left = Math.max(window.scrollX + 8, Math.min(x, maxX)) + 'px';
|
|
@@ -1629,6 +1675,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
1629
1675
|
if (_nodeMenu && _nodeMenu.contains(e.target)) {
|
|
1630
1676
|
return;
|
|
1631
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
|
+
}
|
|
1632
1686
|
removeNodeMenu();
|
|
1633
1687
|
};
|
|
1634
1688
|
let onKey = function (e) {
|
|
@@ -2966,7 +3020,6 @@ function (root, d3, forester, phyloXml) {
|
|
|
2966
3020
|
return {source: link.source.data, target: link.target.data};
|
|
2967
3021
|
});
|
|
2968
3022
|
_overviewLinks = links; // the overview is built from these, not from the DOM
|
|
2969
|
-
let gap = _state.nodeLabelGap;
|
|
2970
3023
|
|
|
2971
3024
|
if (_state.phylogram === true) {
|
|
2972
3025
|
_yScale = branchLengthScaling(forester.getAllExternalNodes(_root), _w);
|
|
@@ -3048,9 +3101,20 @@ function (root, d3, forester, phyloXml) {
|
|
|
3048
3101
|
}
|
|
3049
3102
|
|
|
3050
3103
|
if (_state.dynahide) {
|
|
3051
|
-
if (
|
|
3052
|
-
//
|
|
3053
|
-
//
|
|
3104
|
+
if (radialDisplay()) {
|
|
3105
|
+
// The every-k-th DECIMATION does not run in either radial
|
|
3106
|
+
// layout: it thins by index against even ROW spacing, which a
|
|
3107
|
+
// fan does not have. An unrooted fan has no rows at all; a
|
|
3108
|
+
// circular one has a ring, whose room is set by its
|
|
3109
|
+
// circumference and not by the display's height. Measured on
|
|
3110
|
+
// Caliciviridae_100 at 1100x850, the index rule kept 48 of 97
|
|
3111
|
+
// names in circular while all 97 fitted the ring with NOT ONE
|
|
3112
|
+
// overlapping pair, and on flu_h5 it kept 59 of 354 where 118
|
|
3113
|
+
// are readable. (118 is the shipped rule measured; 100 was a
|
|
3114
|
+
// greedy estimate in DOM order made before it was built, and
|
|
3115
|
+
// is what this comment said until a review caught it against
|
|
3116
|
+
// README and CHANGELOG.) Both are now thinned by hideCrowdedTipLabels
|
|
3117
|
+
// instead, on whether the names actually overprint.
|
|
3054
3118
|
forester.preOrderTraversal(_root, function (n) {
|
|
3055
3119
|
n.hide = false;
|
|
3056
3120
|
});
|
|
@@ -3062,8 +3126,6 @@ function (root, d3, forester, phyloXml) {
|
|
|
3062
3126
|
});
|
|
3063
3127
|
}
|
|
3064
3128
|
}
|
|
3065
|
-
syncDynahideIndicator(_state.dynahide && !_state.unrootedDisplay && _dynahide_factor >= 2);
|
|
3066
|
-
|
|
3067
3129
|
updateButtonEnabledState();
|
|
3068
3130
|
|
|
3069
3131
|
// Only the nodes with something drawn for them individually get a
|
|
@@ -3130,11 +3192,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
3130
3192
|
return makeFoundOutlineColor(d) ? '0.9px' : null;
|
|
3131
3193
|
})
|
|
3132
3194
|
.style('paint-order', 'stroke')
|
|
3195
|
+
// anchor, dy and x all come from nodeLabelPlacement, which the
|
|
3196
|
+
// occupancy box is built from too
|
|
3133
3197
|
.attr('text-anchor', function (d) {
|
|
3134
|
-
|
|
3135
|
-
return labelFlip(d) ? 'end' : 'start';
|
|
3136
|
-
}
|
|
3137
|
-
return d.children ? 'end' : 'start';
|
|
3198
|
+
return nodeLabelPlacement(d).anchor;
|
|
3138
3199
|
})
|
|
3139
3200
|
.attr('transform', function (d) {
|
|
3140
3201
|
if (_state.unrootedDisplay) {
|
|
@@ -3162,42 +3223,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
3162
3223
|
return 'rotate(' + labelAngleDeg(d) + ') translate(' + off + ',0)' + (labelFlip(d) ? ' rotate(180)' : '');
|
|
3163
3224
|
})
|
|
3164
3225
|
.attr('dy', function (d) {
|
|
3165
|
-
|
|
3166
|
-
return '0.32em';
|
|
3167
|
-
}
|
|
3168
|
-
if (d.children) {
|
|
3169
|
-
// the desktop's above-branch internal label: the baseline
|
|
3170
|
-
// is lifted so the glyph bottoms sit just clear of the
|
|
3171
|
-
// horizontal branch line, instead of straddling it
|
|
3172
|
-
return -(Math.round(0.2 * _state.internalNodeFontSize) + 1) + 'px';
|
|
3173
|
-
}
|
|
3174
|
-
return 0.3 * _state.externalNodeFontSize + 'px';
|
|
3226
|
+
return nodeLabelPlacement(d).dy + 'px';
|
|
3175
3227
|
})
|
|
3176
3228
|
.attr('x', function (d) {
|
|
3177
|
-
|
|
3178
|
-
return labelFlip(d) ? -gap : gap;
|
|
3179
|
-
}
|
|
3180
|
-
if (!(d.children)) {
|
|
3181
|
-
if (_state.phylogram && _state.alignPhylogram) {
|
|
3182
|
-
return (-_yScale(d.distToRoot) + _w + gap);
|
|
3183
|
-
} else {
|
|
3184
|
-
return gap;
|
|
3185
|
-
}
|
|
3186
|
-
} else {
|
|
3187
|
-
// right-aligned so the label ends just left of the node
|
|
3188
|
-
// (the desktop's publication-style placement); a long
|
|
3189
|
-
// label on a node near the root shifts right so its
|
|
3190
|
-
// leading characters stay on the canvas rather than
|
|
3191
|
-
// clipping off the left edge
|
|
3192
|
-
let label = makeNodeLabel(d) || '';
|
|
3193
|
-
let est = label.length * _state.internalNodeFontSize * 0.55;
|
|
3194
|
-
let leftmost = d.y - gap - est;
|
|
3195
|
-
let minX = 2 - _settings.rootOffset;
|
|
3196
|
-
if (leftmost < minX) {
|
|
3197
|
-
return -gap + (minX - leftmost);
|
|
3198
|
-
}
|
|
3199
|
-
return -gap;
|
|
3200
|
-
}
|
|
3229
|
+
return nodeLabelPlacement(d).x;
|
|
3201
3230
|
});
|
|
3202
3231
|
|
|
3203
3232
|
node.select('text.bllabel')
|
|
@@ -3218,8 +3247,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
3218
3247
|
if (radialDisplay()) {
|
|
3219
3248
|
return 0;
|
|
3220
3249
|
}
|
|
3221
|
-
|
|
3222
|
-
|
|
3250
|
+
let p = drawnParent(d);
|
|
3251
|
+
if (p) {
|
|
3252
|
+
return (p.y - d.y + 1);
|
|
3223
3253
|
} else {
|
|
3224
3254
|
return 0;
|
|
3225
3255
|
}
|
|
@@ -3239,8 +3269,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
3239
3269
|
if (radialDisplay()) {
|
|
3240
3270
|
return 0;
|
|
3241
3271
|
}
|
|
3242
|
-
|
|
3243
|
-
|
|
3272
|
+
let p = drawnParent(d);
|
|
3273
|
+
if (p) {
|
|
3274
|
+
return (0.5 * (p.y - d.y));
|
|
3244
3275
|
} else {
|
|
3245
3276
|
return 0;
|
|
3246
3277
|
}
|
|
@@ -3261,7 +3292,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
3261
3292
|
if (radialDisplay()) {
|
|
3262
3293
|
return 0;
|
|
3263
3294
|
}
|
|
3264
|
-
|
|
3295
|
+
let p = drawnParent(d);
|
|
3296
|
+
return p ? (0.5 * (p.y - d.y)) : 0;
|
|
3265
3297
|
});
|
|
3266
3298
|
|
|
3267
3299
|
node.select('text.brancheventlabel')
|
|
@@ -3275,8 +3307,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
3275
3307
|
if (radialDisplay()) {
|
|
3276
3308
|
return 0;
|
|
3277
3309
|
}
|
|
3278
|
-
|
|
3279
|
-
|
|
3310
|
+
let p = drawnParent(d);
|
|
3311
|
+
if (p) {
|
|
3312
|
+
return (0.5 * (p.y - d.y));
|
|
3280
3313
|
}
|
|
3281
3314
|
});
|
|
3282
3315
|
|
|
@@ -3361,7 +3394,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
3361
3394
|
// a collapsed clade's label always shows, so its guide does too: from
|
|
3362
3395
|
// past the wedge's farthest tip to the label column
|
|
3363
3396
|
let collapsedGuides = alignedRect ? nodes.filter(function (d) {
|
|
3364
|
-
return isCollapsed(d) &&
|
|
3397
|
+
return isCollapsed(d) && nameDrawn(d);
|
|
3365
3398
|
}) : [];
|
|
3366
3399
|
if (tipGuides || collapsedGuides.length > 0) {
|
|
3367
3400
|
let ext = _svgGroup.insert('g', 'g').attr('class', 'aptx-align-ext');
|
|
@@ -3374,7 +3407,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
3374
3407
|
if (tipGuides) {
|
|
3375
3408
|
guideStyle(ext.selectAll('path.aptx-tip-guide')
|
|
3376
3409
|
.data(links.filter(function (d) {
|
|
3377
|
-
return (!d.target.children &&
|
|
3410
|
+
return (!d.target.children && nameDrawn(d.target));
|
|
3378
3411
|
}))
|
|
3379
3412
|
.enter().append('path').attr('class', 'aptx-tip-guide'))
|
|
3380
3413
|
.attr('d', function (d) {
|
|
@@ -3397,7 +3430,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
3397
3430
|
// tips while their labels show; a collapsed clade always (its label
|
|
3398
3431
|
// always shows), from its wedge's farthest reach out to the ring
|
|
3399
3432
|
let connected = nodes.filter(function (d) {
|
|
3400
|
-
if (
|
|
3433
|
+
if (!nameDrawn(d)) {
|
|
3401
3434
|
return false;
|
|
3402
3435
|
}
|
|
3403
3436
|
if (isCollapsed(d)) {
|
|
@@ -3870,8 +3903,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
3870
3903
|
// in y as much as in x, and branch length only sees x. Measured here
|
|
3871
3904
|
// before adopting it: with Auto-hide Labels already ON, 240 of 267 drawn
|
|
3872
3905
|
// support numbers overlapped another on flu_h5.xml (90%), 149 of 262 on
|
|
3873
|
-
// Adenoviridae, 13 of 119 on bcl2 -- our auto-hide
|
|
3874
|
-
// and had never touched branch data.
|
|
3906
|
+
// Adenoviridae, 13 of 119 on bcl2 -- our auto-hide only thinned TIP labels
|
|
3907
|
+
// then, and had never touched branch data.
|
|
3875
3908
|
//
|
|
3876
3909
|
// WHAT MUST MATCH, or the two viewers drop different marks:
|
|
3877
3910
|
// - the tie-break: PREORDER, first claim wins, so the mark nearer the
|
|
@@ -3903,11 +3936,29 @@ function (root, d3, forester, phyloXml) {
|
|
|
3903
3936
|
// Measured from the font rather than assumed. The fallback is what this
|
|
3904
3937
|
// font actually reports at 9px -- ascent 9, descent 2, line 11 -- read off
|
|
3905
3938
|
// a rendered label's getBBox rather than guessed.
|
|
3906
|
-
let
|
|
3939
|
+
let _fontMetricsCache = new Map();
|
|
3940
|
+
|
|
3941
|
+
// how many names the radial overlap rule took on the last render, for the
|
|
3942
|
+
// Auto-hide indicator: those layouts thin by overlap, not by index, so
|
|
3943
|
+
// _dynahide_factor says nothing about them
|
|
3944
|
+
let _radialNamesHidden = 0;
|
|
3945
|
+
|
|
3946
|
+
// and how many branch marks the crowding rule refused on the last render.
|
|
3947
|
+
// The switch governs three things -- the every-k-th thinning, the radial
|
|
3948
|
+
// overlap thinning and the branch-data rule -- and the indicator used to
|
|
3949
|
+
// ask only about names, so a view where every name fitted but 201 numbers
|
|
3950
|
+
// were being refused showed a dark toggle over a tree it was thinning.
|
|
3951
|
+
// (Caliciviridae circular; apaf RECTANGULAR did it too, so this half was
|
|
3952
|
+
// never right in any layout.)
|
|
3953
|
+
let _branchMarksHidden = 0;
|
|
3907
3954
|
|
|
3908
3955
|
function branchFontMetrics(fontPx) {
|
|
3909
|
-
|
|
3910
|
-
|
|
3956
|
+
// One cache for every size asked for, not one slot: labels and branch
|
|
3957
|
+
// marks run at different sizes in the same pass, and a single slot
|
|
3958
|
+
// would re-measure on every alternation.
|
|
3959
|
+
let hit = _fontMetricsCache.get(fontPx);
|
|
3960
|
+
if (hit) {
|
|
3961
|
+
return hit;
|
|
3911
3962
|
}
|
|
3912
3963
|
let ctx = measureCtx();
|
|
3913
3964
|
ctx.font = fontPx + 'px ' + FONT_DEFAULTS;
|
|
@@ -3924,9 +3975,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
3924
3975
|
if (!(descent >= 0)) {
|
|
3925
3976
|
descent = fontPx * (2 / 9);
|
|
3926
3977
|
}
|
|
3927
|
-
|
|
3978
|
+
let metrics = {px: fontPx, ascent: ascent, descent: descent,
|
|
3928
3979
|
height: ascent + descent};
|
|
3929
|
-
|
|
3980
|
+
_fontMetricsCache.set(fontPx, metrics);
|
|
3981
|
+
return metrics;
|
|
3930
3982
|
}
|
|
3931
3983
|
|
|
3932
3984
|
// Where a branch mark sits on its branch, read off the rendered elements
|
|
@@ -3970,12 +4022,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
3970
4022
|
if (d.ux === undefined) {
|
|
3971
4023
|
return null;
|
|
3972
4024
|
}
|
|
3973
|
-
let p = (d
|
|
4025
|
+
let p = drawnParent(d);
|
|
4026
|
+
p = (p && p.ux !== undefined) ? p : null;
|
|
3974
4027
|
return p ? [0.5 * (d.ux + p.ux), 0.5 * (d.uy + p.uy), angle] : [d.ux, d.uy, angle];
|
|
3975
4028
|
}
|
|
3976
4029
|
let r = radialRadius(d.y);
|
|
3977
|
-
|
|
3978
|
-
|
|
4030
|
+
let p = drawnParent(d);
|
|
4031
|
+
if (p) {
|
|
4032
|
+
r = 0.5 * (r + radialRadius(p.y));
|
|
3979
4033
|
}
|
|
3980
4034
|
let pt = polarXY(radialAngle(d.x), r);
|
|
3981
4035
|
return [pt[0], pt[1], angle];
|
|
@@ -4011,13 +4065,246 @@ function (root, d3, forester, phyloXml) {
|
|
|
4011
4065
|
// used to claim nothing -- so the first child's number, landing in
|
|
4012
4066
|
// almost the same place, was granted and painted over them. The one
|
|
4013
4067
|
// place the pass could not keep its promise.
|
|
4014
|
-
let
|
|
4068
|
+
let p = drawnParent(d);
|
|
4069
|
+
let span = p ? (p.y - d.y) : 0;
|
|
4015
4070
|
let x = at.mid
|
|
4016
4071
|
? (d.y + (0.5 * span) - (w / 2)) // middle-anchored
|
|
4017
4072
|
: (d.y + span + 1); // start-anchored
|
|
4018
4073
|
return [x, (d.x + at.dy) - fm.ascent, w, h];
|
|
4019
4074
|
}
|
|
4020
4075
|
|
|
4076
|
+
// ---- the node's own label: one source for drawing it and for reserving
|
|
4077
|
+
// its space ----
|
|
4078
|
+
//
|
|
4079
|
+
// The three attributes below (text-anchor, x, dy) and the occupancy box
|
|
4080
|
+
// that keeps branch numbers off the name are the same decision, so they
|
|
4081
|
+
// are made once here. Modelled after branchMarkPlacement, and for the same
|
|
4082
|
+
// reason: a box that is not where the text is reserves the wrong space,
|
|
4083
|
+
// and two copies of the arithmetic drift the first time one is edited.
|
|
4084
|
+
function nodeLabelPlacement(d) {
|
|
4085
|
+
let gap = _state.nodeLabelGap;
|
|
4086
|
+
if (radialDisplay()) {
|
|
4087
|
+
let flip = labelFlip(d);
|
|
4088
|
+
return {anchor: flip ? 'end' : 'start', x: flip ? -gap : gap,
|
|
4089
|
+
dy: 0.32 * nodeLabelFontSize(d)};
|
|
4090
|
+
}
|
|
4091
|
+
if (!d.children) {
|
|
4092
|
+
let x = (_state.phylogram && _state.alignPhylogram)
|
|
4093
|
+
? (-_yScale(d.distToRoot) + _w + gap)
|
|
4094
|
+
: gap;
|
|
4095
|
+
return {anchor: 'start', x: x, dy: 0.3 * _state.externalNodeFontSize};
|
|
4096
|
+
}
|
|
4097
|
+
// the desktop's above-branch internal label: right-aligned so it ends
|
|
4098
|
+
// just left of the node, and shifted right when a long label on a node
|
|
4099
|
+
// near the root would otherwise clip off the left edge
|
|
4100
|
+
let label = makeNodeLabel(d) || '';
|
|
4101
|
+
let est = label.length * _state.internalNodeFontSize * 0.55;
|
|
4102
|
+
let leftmost = d.y - gap - est;
|
|
4103
|
+
let minX = 2 - _settings.rootOffset;
|
|
4104
|
+
let x = (leftmost < minX) ? (-gap + (minX - leftmost)) : -gap;
|
|
4105
|
+
return {anchor: 'end', x: x,
|
|
4106
|
+
dy: -(Math.round(0.2 * _state.internalNodeFontSize) + 1)};
|
|
4107
|
+
}
|
|
4108
|
+
|
|
4109
|
+
// The size the label is actually drawn at: a per-node style overrides the
|
|
4110
|
+
// internal/external default, and the box has to follow it or a styled
|
|
4111
|
+
// tree reserves the wrong space.
|
|
4112
|
+
function nodeLabelFontSize(d) {
|
|
4113
|
+
let style = nodeStyle(d);
|
|
4114
|
+
if (style && style.fontSize) {
|
|
4115
|
+
return style.fontSize;
|
|
4116
|
+
}
|
|
4117
|
+
return d.children ? _state.internalNodeFontSize : _state.externalNodeFontSize;
|
|
4118
|
+
}
|
|
4119
|
+
|
|
4120
|
+
// The label's rectangle in the tree group's coordinates, as a start point
|
|
4121
|
+
// on the baseline, a direction the text runs in, and its extents.
|
|
4122
|
+
// Returns null when there is no label drawn.
|
|
4123
|
+
function nodeLabelGeometry(d) {
|
|
4124
|
+
let text = d._extLabelText;
|
|
4125
|
+
if (!text) {
|
|
4126
|
+
return null;
|
|
4127
|
+
}
|
|
4128
|
+
let fontPx = nodeLabelFontSize(d);
|
|
4129
|
+
let style = nodeStyle(d);
|
|
4130
|
+
let bold = getFoundColor(d)
|
|
4131
|
+
|| (style && (style.fontStyle === 'bold' || style.fontStyle === 'bold_italic'));
|
|
4132
|
+
let w = legendTextWidth(text, (bold ? 'bold ' : '') + fontPx + 'px ' + FONT_DEFAULTS);
|
|
4133
|
+
let fm = branchFontMetrics(fontPx);
|
|
4134
|
+
let p = nodeLabelPlacement(d);
|
|
4135
|
+
let at = layoutPointXY(d);
|
|
4136
|
+
if (at[0] === undefined || at[1] === undefined) {
|
|
4137
|
+
return null; // not laid out (an unrooted node the layout never reached)
|
|
4138
|
+
}
|
|
4139
|
+
if (!radialDisplay() || _radialLabelsHorizontal) {
|
|
4140
|
+
// upright text, whatever the layout: one axis-aligned rectangle
|
|
4141
|
+
let base = at;
|
|
4142
|
+
if (_state.circularDisplay && _radialLabelsHorizontal && !d.children && _radial) {
|
|
4143
|
+
// the label rides out to its point on the ring, in screen terms
|
|
4144
|
+
base = polarXY(radialAngle(d.x), _radial.maxRad);
|
|
4145
|
+
}
|
|
4146
|
+
let x0 = base[0] + p.x - (p.anchor === 'end' ? w : 0);
|
|
4147
|
+
return {x: x0, y: (base[1] + p.dy) - fm.ascent, w: w, h: fm.height, angle: 0};
|
|
4148
|
+
}
|
|
4149
|
+
// Radial: the text starts a gap beyond the node (or beyond the ring,
|
|
4150
|
+
// for a circular tip) and runs OUTWARD along the spoke -- on both
|
|
4151
|
+
// halves of the fan, because the 180-degree flip that keeps the far
|
|
4152
|
+
// half upright also reverses which end the anchor is.
|
|
4153
|
+
//
|
|
4154
|
+
// Built as an offset FROM THE NODE, not as a distance from the origin.
|
|
4155
|
+
// A circular node lies on the ray its label runs along, so radius
|
|
4156
|
+
// arithmetic happens to work there; an unrooted one does not -- its
|
|
4157
|
+
// spoke angle is the direction its own branch came in at, which has
|
|
4158
|
+
// nothing to do with its bearing from the centre. Measured with the
|
|
4159
|
+
// radius version: unrooted still had 7 of 12 numbers printing through
|
|
4160
|
+
// a name on flu_h5, because the boxes were being reserved in places
|
|
4161
|
+
// no label was.
|
|
4162
|
+
let theta = spokeAngle(d);
|
|
4163
|
+
let along = _state.nodeLabelGap;
|
|
4164
|
+
if (_state.circularDisplay && !d.children && _radial) {
|
|
4165
|
+
along += _radial.maxRad - radialRadius(d.y); // the pull out to the ring
|
|
4166
|
+
}
|
|
4167
|
+
let flip = labelFlip(d);
|
|
4168
|
+
// the baseline offset is perpendicular to the text, and the flip
|
|
4169
|
+
// carries it across with everything else
|
|
4170
|
+
let off = flip ? -p.dy : p.dy;
|
|
4171
|
+
let top = flip ? (off - fm.descent) : (off - fm.ascent);
|
|
4172
|
+
let cos = Math.cos(theta), sin = Math.sin(theta);
|
|
4173
|
+
return {x: at[0] + (cos * along) - (sin * top),
|
|
4174
|
+
y: at[1] + (sin * along) + (cos * top),
|
|
4175
|
+
w: w, h: fm.height, angle: theta};
|
|
4176
|
+
}
|
|
4177
|
+
|
|
4178
|
+
// The label's own outline, as four corners in the tree group's
|
|
4179
|
+
// coordinates: what the turned-box occupancy tests against.
|
|
4180
|
+
function nodeLabelQuad(g) {
|
|
4181
|
+
if (!g || !(g.w > 0) || !(g.h > 0)) {
|
|
4182
|
+
return null;
|
|
4183
|
+
}
|
|
4184
|
+
let cos = Math.cos(g.angle), sin = Math.sin(g.angle);
|
|
4185
|
+
let corners = [[0, 0], [g.w, 0], [g.w, g.h], [0, g.h]];
|
|
4186
|
+
let quad = [];
|
|
4187
|
+
for (let i = 0; i < corners.length; ++i) {
|
|
4188
|
+
quad.push([g.x + (cos * corners[i][0]) - (sin * corners[i][1]),
|
|
4189
|
+
g.y + (sin * corners[i][0]) + (cos * corners[i][1])]);
|
|
4190
|
+
}
|
|
4191
|
+
return quad;
|
|
4192
|
+
}
|
|
4193
|
+
|
|
4194
|
+
// ---- crowded TIP LABELS, in both radial layouts ----
|
|
4195
|
+
//
|
|
4196
|
+
// The desktop's rule: a tip label is drawn only if its own outline overlaps
|
|
4197
|
+
// no label already drawn this pass. Both fans use it; the rectangular
|
|
4198
|
+
// layout keeps the every-k-th thinning, whose row pitch is a real quantity
|
|
4199
|
+
// there.
|
|
4200
|
+
//
|
|
4201
|
+
// Unrooted had nothing at all before this -- its labels sit wherever the
|
|
4202
|
+
// equal-angle layout put the tip. Circular had the rectangular rule, which
|
|
4203
|
+
// reads the display's HEIGHT against the node count and says nothing about
|
|
4204
|
+
// a ring: it kept 48 of 97 names on Caliciviridae where all 97 fit with no
|
|
4205
|
+
// overlap, and 59 of 354 on flu_h5 where 118 are readable.
|
|
4206
|
+
//
|
|
4207
|
+
// A SEARCH HIT is placed without being asked, so it can be drawn across a
|
|
4208
|
+
// name already down -- intended (the hit is what is being looked for), and
|
|
4209
|
+
// measured: a 3-hit search on confidences unrooted puts 3 hits across
|
|
4210
|
+
// non-hits, 8.1 px deep. It is the one case where "drawn only where it
|
|
4211
|
+
// overlaps nothing" does not hold, and the README says so.
|
|
4212
|
+
// Measured at 1100x850 before this, overlapping pairs among the labels
|
|
4213
|
+
// drawn: 172 of 97 labels on Caliciviridae_100, 71 of 42 on confidences,
|
|
4214
|
+
// 10 of 31 on apaf -- clumps of a dozen names printed through each other.
|
|
4215
|
+
//
|
|
4216
|
+
// OUTLINES, not their axis-aligned bounds: a name lying near its spoke has
|
|
4217
|
+
// bounds several times its own area, and on a 48-label ring those bounds
|
|
4218
|
+
// called 28 pairs crowded where a reader sees none.
|
|
4219
|
+
//
|
|
4220
|
+
// Order is the tree's own, root first (forester.preorderOf), which is also
|
|
4221
|
+
// what the desktop's unrooted painter does -- it reaches an internal node
|
|
4222
|
+
// before its subtree -- so both programs keep the same label when two
|
|
4223
|
+
// contend. A search HIT is placed without being asked, so the name being
|
|
4224
|
+
// looked for is always on the screen and everything after it keeps clear
|
|
4225
|
+
// of it (Christian, 2026-09-26: hits are exempt for tip labels, and for
|
|
4226
|
+
// nothing else).
|
|
4227
|
+
// Is this node's name on the screen? Asked by everything drawn to a label
|
|
4228
|
+
// -- the aligned guides, the circular ring connectors -- and it must be
|
|
4229
|
+
// ONE question. It used to be spelled `_state.dynahide && d.hide`, which
|
|
4230
|
+
// is the every-k-th rule and nothing else: when the radial layouts started
|
|
4231
|
+
// hiding by overlap they left `hide` false for everyone, so on flu_h5 in
|
|
4232
|
+
// circular 354 dashed connectors ran out to the ring for 118 names, 236 of
|
|
4233
|
+
// them ending at nothing. `_labelDropped` is set by BOTH rules.
|
|
4234
|
+
function nameDrawn(d) {
|
|
4235
|
+
return !d._labelDropped;
|
|
4236
|
+
}
|
|
4237
|
+
|
|
4238
|
+
function hideCrowdedTipLabels(nodes) {
|
|
4239
|
+
_radialNamesHidden = 0;
|
|
4240
|
+
if (!_state.dynahide || !radialDisplay()) {
|
|
4241
|
+
return;
|
|
4242
|
+
}
|
|
4243
|
+
// no layout, no honest outlines; bailing draws everything
|
|
4244
|
+
if (_state.unrootedDisplay ? !_unroot : !_radial) {
|
|
4245
|
+
return;
|
|
4246
|
+
}
|
|
4247
|
+
let labels = forester.orientedOccupancy(Math.max(4, _state.externalNodeFontSize));
|
|
4248
|
+
let ordered = forester.preorderOf(nodes);
|
|
4249
|
+
for (let i = 0, len = ordered.length; i !== len; ++i) {
|
|
4250
|
+
let d = ordered[i];
|
|
4251
|
+
if (d.children || d._extLabelText === '') {
|
|
4252
|
+
continue;
|
|
4253
|
+
}
|
|
4254
|
+
let quad = nodeLabelQuad(nodeLabelGeometry(d));
|
|
4255
|
+
if (!quad) {
|
|
4256
|
+
continue;
|
|
4257
|
+
}
|
|
4258
|
+
if (getFoundColor(d)) {
|
|
4259
|
+
labels.occupy(quad);
|
|
4260
|
+
} else if (!labels.claim(quad)) {
|
|
4261
|
+
d._extLabelText = '';
|
|
4262
|
+
d._labelDropped = true;
|
|
4263
|
+
++_radialNamesHidden;
|
|
4264
|
+
}
|
|
4265
|
+
}
|
|
4266
|
+
}
|
|
4267
|
+
|
|
4268
|
+
// The boxes to reserve for a label. An upright label is one rectangle. A
|
|
4269
|
+
// rotated one is reserved as a RUN of short boxes along its baseline,
|
|
4270
|
+
// never as one axis-aligned box around the whole thing: the bounds of a
|
|
4271
|
+
// rotated rectangle grow with its LENGTH, so a 120 px name lying at 30
|
|
4272
|
+
// degrees would reserve several times its own area. Stepping by the line
|
|
4273
|
+
// height bounds the error at one step whatever the name is.
|
|
4274
|
+
//
|
|
4275
|
+
// Honestly measured: the run and the single box draw the SAME numbers on
|
|
4276
|
+
// every tree tried (apaf, Caliciviridae, confidences) bar one -- flu_h5
|
|
4277
|
+
// unrooted, 4 against 3 -- and a purpose-built sparse fixture with 28-
|
|
4278
|
+
// character names could not separate them at all
|
|
4279
|
+
// (test_trees/label_reservation.html). The views that would show the
|
|
4280
|
+
// difference are already saturated, and the ones with room refuse nothing
|
|
4281
|
+
// either way. It is kept for the bound, not for the count: the error of
|
|
4282
|
+
// the single box grows without limit as names get longer, and this is
|
|
4283
|
+
// ten lines.
|
|
4284
|
+
function nodeLabelClaimBoxes(g) {
|
|
4285
|
+
if (!g || !(g.w > 0) || !(g.h > 0)) {
|
|
4286
|
+
return [];
|
|
4287
|
+
}
|
|
4288
|
+
if (g.angle === 0) {
|
|
4289
|
+
return [[g.x, g.y, g.w, g.h]];
|
|
4290
|
+
}
|
|
4291
|
+
let cos = Math.cos(g.angle), sin = Math.sin(g.angle);
|
|
4292
|
+
let step = Math.max(4, g.h);
|
|
4293
|
+
let boxes = [];
|
|
4294
|
+
for (let t = 0; t < g.w; t += step) {
|
|
4295
|
+
let len = Math.min(step, g.w - t);
|
|
4296
|
+
// the four corners of this slice, in tree coordinates
|
|
4297
|
+
let xs = [], ys = [];
|
|
4298
|
+
for (const [a, b] of [[t, 0], [t + len, 0], [t + len, g.h], [t, g.h]]) {
|
|
4299
|
+
xs.push(g.x + (cos * a) - (sin * b));
|
|
4300
|
+
ys.push(g.y + (sin * a) + (cos * b));
|
|
4301
|
+
}
|
|
4302
|
+
let x0 = Math.min(...xs), y0 = Math.min(...ys);
|
|
4303
|
+
boxes.push([x0, y0, Math.max(...xs) - x0, Math.max(...ys) - y0]);
|
|
4304
|
+
}
|
|
4305
|
+
return boxes;
|
|
4306
|
+
}
|
|
4307
|
+
|
|
4021
4308
|
function prepareNodeDrawing(nodes) {
|
|
4022
4309
|
let wantBl = _state.showBranchLengthValues === true;
|
|
4023
4310
|
let wantConf = _state.showConfidenceValues === true || _state.showMadValues === true;
|
|
@@ -4034,7 +4321,12 @@ function (root, d3, forester, phyloXml) {
|
|
|
4034
4321
|
}
|
|
4035
4322
|
// Empty is the common case on a big tree: Auto-hide Labels blanks
|
|
4036
4323
|
// them exactly when there are most nodes to blank.
|
|
4037
|
-
|
|
4324
|
+
// WHY the text is empty matters downstream: a label the crowding
|
|
4325
|
+
// rule took away is a different thing from a label the user
|
|
4326
|
+
// switched off, and marks that accompany a name must tell them
|
|
4327
|
+
// apart (drawDomainArchitectures).
|
|
4328
|
+
d._labelDropped = _state.dynahide === true && d.hide === true;
|
|
4329
|
+
d._extLabelText = d._labelDropped ? '' : (makeNodeLabel(d) || '');
|
|
4038
4330
|
d._blText = wantBl ? asText(makeBranchLengthLabel(d)) : '';
|
|
4039
4331
|
d._confText = wantConf ? asText(makeConfidenceValuesLabel(d)) : '';
|
|
4040
4332
|
d._eventText = wantEvent ? asText(makeBranchEventsLabel(d)) : '';
|
|
@@ -4044,7 +4336,13 @@ function (root, d3, forester, phyloXml) {
|
|
|
4044
4336
|
// childless <g class="node"> behind, and those are transformed on
|
|
4045
4337
|
// every animation frame. On the big tree that was ~18,000 empty groups
|
|
4046
4338
|
// per redraw, defeating the very thing the `drawn` filter is for.
|
|
4339
|
+
// names first: the ones that survive then reserve their space inside
|
|
4340
|
+
// hideCrowdedBranchData, and the marks are placed against what is left
|
|
4341
|
+
hideCrowdedTipLabels(nodes);
|
|
4047
4342
|
hideCrowdedBranchData(nodes);
|
|
4343
|
+
// told after the rules have run, not before: what the unrooted rule
|
|
4344
|
+
// hid is only known once it has looked at the labels
|
|
4345
|
+
syncDynahideIndicator();
|
|
4048
4346
|
for (let i = 0, len = nodes.length; i !== len; ++i) {
|
|
4049
4347
|
let d = nodes[i];
|
|
4050
4348
|
if (d._extLabelText !== '' || d._blText !== '' || d._confText !== ''
|
|
@@ -4070,8 +4368,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
4070
4368
|
// built in the tree group's own coordinates, which is where every mark is
|
|
4071
4369
|
// actually drawn, so the circular and unrooted views are measured in their
|
|
4072
4370
|
// own space and not in the cluster's. (The tip-label decimation that shares
|
|
4073
|
-
// the toggle
|
|
4074
|
-
// to decimate against, and
|
|
4371
|
+
// the toggle runs in the rectangular layout alone; both fans have no rows
|
|
4372
|
+
// to decimate against, and hideCrowdedTipLabels thins their names by
|
|
4373
|
+
// overlap instead, running before this does.)
|
|
4075
4374
|
function hideCrowdedBranchData(nodes) {
|
|
4076
4375
|
if (!_state.dynahide) {
|
|
4077
4376
|
return;
|
|
@@ -4082,6 +4381,24 @@ function (root, d3, forester, phyloXml) {
|
|
|
4082
4381
|
if ((_state.circularDisplay && !_radial) || (_state.unrootedDisplay && !_unroot)) {
|
|
4083
4382
|
return;
|
|
4084
4383
|
}
|
|
4384
|
+
_branchMarksHidden = 0;
|
|
4385
|
+
// Each granted mark's claimed box is kept on the node, as a label's is
|
|
4386
|
+
// (_labelBoxes). Names have had an ink-coverage check since the
|
|
4387
|
+
// reservation went in -- every point of a drawn name must lie inside
|
|
4388
|
+
// the space it claimed -- and branch MARKS never did, which is the
|
|
4389
|
+
// half that failed on a Linux CI runner while the label half passed.
|
|
4390
|
+
for (let i = 0; i < nodes.length; ++i) {
|
|
4391
|
+
nodes[i]._markBoxes = null;
|
|
4392
|
+
}
|
|
4393
|
+
let stash = function (d, kind, box) {
|
|
4394
|
+
if (!box) {
|
|
4395
|
+
return;
|
|
4396
|
+
}
|
|
4397
|
+
if (!d._markBoxes) {
|
|
4398
|
+
d._markBoxes = {};
|
|
4399
|
+
}
|
|
4400
|
+
d._markBoxes[kind] = box;
|
|
4401
|
+
};
|
|
4085
4402
|
let fontPx = _state.branchDataFontSize;
|
|
4086
4403
|
let dotCell = 0;
|
|
4087
4404
|
for (let i = 0; i < nodes.length; ++i) {
|
|
@@ -4093,6 +4410,33 @@ function (root, d3, forester, phyloXml) {
|
|
|
4093
4410
|
let numbers = forester.labelOccupancy(cell);
|
|
4094
4411
|
let symbols = forester.labelOccupancy(cell);
|
|
4095
4412
|
let ordered = forester.preorderOf(nodes);
|
|
4413
|
+
|
|
4414
|
+
// THE NAMES GO DOWN FIRST, and they are not asked: a node label is
|
|
4415
|
+
// drawn whatever else is there, so it reserves its space with occupy
|
|
4416
|
+
// rather than claim. Everything below is then refused against it, and
|
|
4417
|
+
// names outrank numbers -- the desktop's order, and the one a reader
|
|
4418
|
+
// expects, since a name is what the tree is for and a value that has
|
|
4419
|
+
// printed through one is worse than a value not drawn at all.
|
|
4420
|
+
//
|
|
4421
|
+
// Measured before this existed, at 1100x850 with both numbers on:
|
|
4422
|
+
// 44 of 93 numbers printed through a name on flu_h5 in the
|
|
4423
|
+
// rectangular layout, 30 of 55 on confidences, 10 of 15 unrooted.
|
|
4424
|
+
// The 3.15.0 pass kept numbers off each OTHER and off nothing else.
|
|
4425
|
+
for (let i = 0, len = ordered.length; i !== len; ++i) {
|
|
4426
|
+
let boxes = nodeLabelClaimBoxes(nodeLabelGeometry(ordered[i]));
|
|
4427
|
+
// kept on the node, beside the other per-render drawing state, so
|
|
4428
|
+
// the reservation can be compared with the ink it is supposed to
|
|
4429
|
+
// cover: a box that is merely SMALL -- short by a descent, or a
|
|
4430
|
+
// few per cent narrow -- still hides nothing wrongly and still
|
|
4431
|
+
// shows no overlap anywhere a number does not happen to sit. The
|
|
4432
|
+
// desktop pins the same property pixel by pixel.
|
|
4433
|
+
ordered[i]._labelBoxes = boxes.length ? boxes : null;
|
|
4434
|
+
for (let b = 0; b < boxes.length; ++b) {
|
|
4435
|
+
numbers.occupy(boxes[b][0], boxes[b][1], boxes[b][2], boxes[b][3]);
|
|
4436
|
+
symbols.occupy(boxes[b][0], boxes[b][1], boxes[b][2], boxes[b][3]);
|
|
4437
|
+
}
|
|
4438
|
+
}
|
|
4439
|
+
|
|
4096
4440
|
for (let i = 0, len = ordered.length; i !== len; ++i) {
|
|
4097
4441
|
let d = ordered[i];
|
|
4098
4442
|
// Branch length first, then confidence: they sit on opposite sides
|
|
@@ -4102,12 +4446,18 @@ function (root, d3, forester, phyloXml) {
|
|
|
4102
4446
|
let b = branchMarkBox(d, 'bl', d._blText, fontPx);
|
|
4103
4447
|
if (b && !numbers.claim(b[0], b[1], b[2], b[3])) {
|
|
4104
4448
|
d._blText = '';
|
|
4449
|
+
++_branchMarksHidden;
|
|
4450
|
+
} else {
|
|
4451
|
+
stash(d, 'bl', b);
|
|
4105
4452
|
}
|
|
4106
4453
|
}
|
|
4107
4454
|
if (d._confText !== '') {
|
|
4108
4455
|
let b = branchMarkBox(d, 'conf', d._confText, fontPx);
|
|
4109
4456
|
if (b && !numbers.claim(b[0], b[1], b[2], b[3])) {
|
|
4110
4457
|
d._confText = '';
|
|
4458
|
+
++_branchMarksHidden;
|
|
4459
|
+
} else {
|
|
4460
|
+
stash(d, 'conf', b);
|
|
4111
4461
|
}
|
|
4112
4462
|
}
|
|
4113
4463
|
// Branch EVENTS are branch-anchored too, half a branch from the
|
|
@@ -4119,6 +4469,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
4119
4469
|
let b = branchMarkBox(d, 'event', d._eventText, fontPx);
|
|
4120
4470
|
if (b && !numbers.claim(b[0], b[1], b[2], b[3])) {
|
|
4121
4471
|
d._eventText = '';
|
|
4472
|
+
++_branchMarksHidden;
|
|
4473
|
+
} else {
|
|
4474
|
+
stash(d, 'event', b);
|
|
4122
4475
|
}
|
|
4123
4476
|
}
|
|
4124
4477
|
if (d._suppDot) {
|
|
@@ -4129,9 +4482,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
4129
4482
|
// do not rotate, so the radial case needs only the midpoint
|
|
4130
4483
|
let at = radialDisplay()
|
|
4131
4484
|
? radialBranchMid(d)
|
|
4132
|
-
: [d.y + (0.5 * (d.
|
|
4485
|
+
: [d.y + (0.5 * (drawnParent(d).y - d.y)), d.x]; // one use
|
|
4133
4486
|
if (at && !symbols.claim(at[0] - r, at[1] - r, 2 * r, 2 * r)) {
|
|
4134
4487
|
d._suppDot = false;
|
|
4488
|
+
++_branchMarksHidden;
|
|
4135
4489
|
}
|
|
4136
4490
|
}
|
|
4137
4491
|
}
|
|
@@ -4440,24 +4794,39 @@ function (root, d3, forester, phyloXml) {
|
|
|
4440
4794
|
}
|
|
4441
4795
|
};
|
|
4442
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
|
+
|
|
4443
4811
|
// How long node d's branch is DRAWN, in layout units -- not its
|
|
4444
4812
|
// branch_length: the gate for decorations that need room on the branch.
|
|
4445
4813
|
function drawnBranchSpan(d) {
|
|
4446
|
-
|
|
4814
|
+
let p = drawnParent(d);
|
|
4815
|
+
if (!p) {
|
|
4447
4816
|
return 0;
|
|
4448
4817
|
}
|
|
4449
4818
|
if (_state.unrootedDisplay) {
|
|
4450
|
-
if (d.ux === undefined ||
|
|
4819
|
+
if (d.ux === undefined || p.ux === undefined) {
|
|
4451
4820
|
return 0;
|
|
4452
4821
|
}
|
|
4453
|
-
let dx = d.ux -
|
|
4454
|
-
let dy = d.uy -
|
|
4822
|
+
let dx = d.ux - p.ux;
|
|
4823
|
+
let dy = d.uy - p.uy;
|
|
4455
4824
|
return Math.sqrt((dx * dx) + (dy * dy));
|
|
4456
4825
|
}
|
|
4457
4826
|
if (_state.circularDisplay) {
|
|
4458
|
-
return Math.abs(radialRadius(
|
|
4827
|
+
return Math.abs(radialRadius(p.y) - radialRadius(d.y));
|
|
4459
4828
|
}
|
|
4460
|
-
return Math.abs(
|
|
4829
|
+
return Math.abs(p.y - d.y);
|
|
4461
4830
|
}
|
|
4462
4831
|
|
|
4463
4832
|
// The Support Dots threshold on the tree's own scale: the setting is a
|
|
@@ -4491,7 +4860,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
4491
4860
|
}
|
|
4492
4861
|
|
|
4493
4862
|
function showSupportDot(d) {
|
|
4494
|
-
if (!_state.showSupportDots || !d
|
|
4863
|
+
if (!_state.showSupportDots || !drawnParent(d)
|
|
4495
4864
|
|| !d.confidences || d.confidences.length === 0) {
|
|
4496
4865
|
return false;
|
|
4497
4866
|
}
|
|
@@ -4565,17 +4934,18 @@ function (root, d3, forester, phyloXml) {
|
|
|
4565
4934
|
// Transform for a branch-data label (confidence / branch length / events):
|
|
4566
4935
|
// rotate to the node's angle and sit at the midpoint of the branch (radially).
|
|
4567
4936
|
function branchLabelTransform(d) {
|
|
4568
|
-
|
|
4937
|
+
let p = drawnParent(d);
|
|
4938
|
+
if (!p || (_state.unrootedDisplay && p.ux === undefined)) {
|
|
4569
4939
|
// no laid-out parent (the displayed subtree's root): angle only
|
|
4570
4940
|
return 'rotate(' + labelAngleDeg(d) + ')';
|
|
4571
4941
|
}
|
|
4572
4942
|
let mid;
|
|
4573
4943
|
if (_state.unrootedDisplay) {
|
|
4574
|
-
let dx = d.ux -
|
|
4575
|
-
let dy = d.uy -
|
|
4944
|
+
let dx = d.ux - p.ux;
|
|
4945
|
+
let dy = d.uy - p.uy;
|
|
4576
4946
|
mid = -Math.sqrt((dx * dx) + (dy * dy)) / 2;
|
|
4577
4947
|
} else {
|
|
4578
|
-
mid = (radialRadius(
|
|
4948
|
+
mid = (radialRadius(p.y) - radialRadius(d.y)) / 2;
|
|
4579
4949
|
}
|
|
4580
4950
|
return 'rotate(' + labelAngleDeg(d) + ') translate(' + mid + ',0)' + (labelFlip(d) ? ' rotate(180)' : '');
|
|
4581
4951
|
}
|
|
@@ -5089,11 +5459,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
5089
5459
|
internalNodeFontSize: 'font size is fixed at launch (11px) and changed only via the in-panel Font slider -- not a launch config key',
|
|
5090
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',
|
|
5091
5461
|
// download filenames follow the tree's name
|
|
5092
|
-
nameForNhDownload: 'download names follow "treeName"',
|
|
5093
|
-
nameForPhyloXmlDownload: 'download names follow "treeName"',
|
|
5094
|
-
nameForPngDownload: 'download names follow "treeName"',
|
|
5095
|
-
nameForSvgDownload: 'download names follow "treeName"',
|
|
5096
|
-
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',
|
|
5097
5467
|
// which taxonomy / sequence field to label with is read off the tree
|
|
5098
5468
|
showTaxonomyCode: 'taxonomy labelling follows what the tree contains',
|
|
5099
5469
|
showTaxonomyScientificName: 'taxonomy labelling follows what the tree contains',
|
|
@@ -5165,8 +5535,12 @@ function (root, d3, forester, phyloXml) {
|
|
|
5165
5535
|
showTaxonomyButton: 'shown automatically when the tree has taxonomies',
|
|
5166
5536
|
showSequenceButton: 'shown automatically when the tree has sequences',
|
|
5167
5537
|
showBranchColorsButton: 'the Visual Styles checkbox appears when the tree has branch colours or style properties',
|
|
5168
|
-
|
|
5169
|
-
|
|
5538
|
+
// These two are ALWAYS offered, deliberately: they do nothing on a tree
|
|
5539
|
+
// that needs neither, and a control that comes and goes between trees
|
|
5540
|
+
// is worse than one that is simply there. (Both messages used to
|
|
5541
|
+
// promise a conditional appearance the code had already rejected.)
|
|
5542
|
+
showDynahideButton: 'the Auto-hide Labels checkbox is always shown',
|
|
5543
|
+
showShortenNodeNamesButton: 'the Short Names checkbox is always shown; it starts on when the tree has long node names',
|
|
5170
5544
|
showExternalLabelsButton: 'always shown',
|
|
5171
5545
|
showInternalLabelsButton: 'shown automatically when the tree has internal node data',
|
|
5172
5546
|
// the collapse-by-depth / rank / feature feature is gone
|
|
@@ -5693,20 +6067,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
5693
6067
|
// The tree names itself; a caller-supplied name only ever disagreed with
|
|
5694
6068
|
// the file. It is the stem of every download filename.
|
|
5695
6069
|
_state.treeName = _treeData.name ? _treeData.name.trim().replace(/\W+/g, '_') : null;
|
|
5696
|
-
_state.
|
|
5697
|
-
? (_state.treeName + NH_SUFFIX) : NAME_FOR_NH_DOWNLOAD_DEFAULT;
|
|
5698
|
-
_state.nameForPhyloXmlDownload = _state.treeName
|
|
5699
|
-
? (_state.treeName + XML_SUFFIX) : NAME_FOR_PHYLOXML_DOWNLOAD_DEFAULT;
|
|
5700
|
-
_state.nameForPngDownload = _state.treeName
|
|
5701
|
-
? (_state.treeName + PNG_SUFFIX) : NAME_FOR_PNG_DOWNLOAD_DEFAULT;
|
|
5702
|
-
_state.nameForPdfDownload = _state.treeName
|
|
5703
|
-
? (_state.treeName + PDF_SUFFIX) : NAME_FOR_PDF_DOWNLOAD_DEFAULT;
|
|
5704
|
-
_state.nameForSvgDownload = _state.treeName
|
|
5705
|
-
? (_state.treeName + SVG_SUFFIX) : NAME_FOR_SVG_DOWNLOAD_DEFAULT;
|
|
5706
|
-
_state.nameForFastaDownload = _state.treeName
|
|
5707
|
-
? (_state.treeName + FASTA_SUFFIX) : NAME_FOR_FASTA_DOWNLOAD_DEFAULT;
|
|
5708
|
-
_state.nameForNexusDownload = _state.treeName
|
|
5709
|
-
? (_state.treeName + NEXUS_SUFFIX) : NAME_FOR_NEXUS_DOWNLOAD_DEFAULT;
|
|
6070
|
+
_state.downloadStem = _state.treeName || DOWNLOAD_STEM_DEFAULT;
|
|
5710
6071
|
|
|
5711
6072
|
// === undefined, not a truthy check: 0 is a legitimate explicit
|
|
5712
6073
|
// position (the legend's top-left corner) and must survive, not be
|
|
@@ -6573,10 +6934,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
6573
6934
|
} else {
|
|
6574
6935
|
filename = 'External_Node_Data_for_' + ext_nodes.length + '_Nodes.tsv';
|
|
6575
6936
|
}
|
|
6576
|
-
|
|
6577
|
-
return (n.viewId !== undefined) ? n.viewId : i + 1;
|
|
6578
|
-
});
|
|
6579
|
-
saveAs(new Blob([tsv], {type: 'text/tab-separated-values'}), filename);
|
|
6937
|
+
saveAs(new Blob([tipTableTsv(ext_nodes)], {type: 'text/tab-separated-values'}), filename);
|
|
6580
6938
|
update();
|
|
6581
6939
|
}
|
|
6582
6940
|
|
|
@@ -9913,7 +10271,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
9913
10271
|
}
|
|
9914
10272
|
if (_state.phylogram && !_state.alignPhylogram) {
|
|
9915
10273
|
if (_state.unrootedDisplay) {
|
|
9916
|
-
toCladegram();
|
|
10274
|
+
toCladegram(); // unrooted cannot align
|
|
10275
|
+
} else if (_state.circularDisplay) {
|
|
10276
|
+
toCladegram(); // circular is already showing the aligned type
|
|
9917
10277
|
} else {
|
|
9918
10278
|
toAlignedPhylogram();
|
|
9919
10279
|
}
|
|
@@ -10017,6 +10377,274 @@ function (root, d3, forester, phyloXml) {
|
|
|
10017
10377
|
}
|
|
10018
10378
|
|
|
10019
10379
|
// The cheat sheet: every combo above, then the plain keys.
|
|
10380
|
+
// ===================== The control-panel cheat sheet =====================
|
|
10381
|
+
//
|
|
10382
|
+
// Every control the panel is SHOWING, in the order it shows them, each
|
|
10383
|
+
// beside the sentence that already explains it. The desktop's
|
|
10384
|
+
// ControlPanelCheatSheet, and built the same way on one principle: nothing
|
|
10385
|
+
// is written twice. A row's picture is the control's own glyph, cloned;
|
|
10386
|
+
// a row's words are the tooltip the control already carries. So a new
|
|
10387
|
+
// control needs no edit here -- it needs a tooltip, and
|
|
10388
|
+
// test_trees/panel_descriptions.html fails by name for any visible control
|
|
10389
|
+
// without one.
|
|
10390
|
+
//
|
|
10391
|
+
// Two rules of Christian's, 2026-09-25, which the desktop follows too:
|
|
10392
|
+
// name and description are separated by a COLON, never a dash; and no live
|
|
10393
|
+
// VALUES appear -- a label reading "Font size: 6" is cut at the colon, so
|
|
10394
|
+
// the sheet says what the control is and never what it is currently set
|
|
10395
|
+
// to. "A printed 40% is wrong for every reader but the one who printed
|
|
10396
|
+
// it."
|
|
10397
|
+
//
|
|
10398
|
+
// Modeless, and placed beside the panel rather than over it: a sheet you
|
|
10399
|
+
// must close to touch the control it describes is half a sheet.
|
|
10400
|
+
const CHEAT_SHEET_DIALOG = 'aptx_cheatsheet';
|
|
10401
|
+
let _cheatSheetBody = null;
|
|
10402
|
+
|
|
10403
|
+
// this viewer's own panel, not another viewer's on the same page
|
|
10404
|
+
function cheatSheetPanel() {
|
|
10405
|
+
return (_container && _container.querySelector(':scope > .aptx-panel'))
|
|
10406
|
+
|| document.querySelector('.aptx-panel');
|
|
10407
|
+
}
|
|
10408
|
+
|
|
10409
|
+
// The words for one control: its own title, or the title of the <label>
|
|
10410
|
+
// that names it -- which is a SIBLING, not an ancestor, for the select
|
|
10411
|
+
// menus (makeSelectMenu puts it there).
|
|
10412
|
+
function controlDescription(el) {
|
|
10413
|
+
let n = el;
|
|
10414
|
+
for (let i = 0; i < 4 && n; ++i) {
|
|
10415
|
+
let t = (n.getAttribute && (n.getAttribute('title') || n.getAttribute('aria-label'))) || '';
|
|
10416
|
+
if (t.trim() !== '') {
|
|
10417
|
+
return t.trim();
|
|
10418
|
+
}
|
|
10419
|
+
n = n.parentElement;
|
|
10420
|
+
}
|
|
10421
|
+
if (el.id) {
|
|
10422
|
+
let lab = document.querySelector('label[for="' + el.id + '"]');
|
|
10423
|
+
let t = lab ? (lab.getAttribute('title') || lab.getAttribute('aria-label') || '') : '';
|
|
10424
|
+
if (t.trim() !== '') {
|
|
10425
|
+
return t.trim();
|
|
10426
|
+
}
|
|
10427
|
+
}
|
|
10428
|
+
return '';
|
|
10429
|
+
}
|
|
10430
|
+
|
|
10431
|
+
// What the control is called, with any live value cut away. A checkbox is
|
|
10432
|
+
// named by its own text, a menu or slider by the label beside it, a glyph
|
|
10433
|
+
// button by nothing -- its picture is its name, and the row shows that.
|
|
10434
|
+
function controlName(el) {
|
|
10435
|
+
// ONE cut, at the end, over whatever source the name came from.
|
|
10436
|
+
//
|
|
10437
|
+
// It used to be applied only to the two sources that could carry a
|
|
10438
|
+
// value -- a <label for> and an aria-label -- and not to a checkbox's
|
|
10439
|
+
// own text or a button's value. That left "no sheet name contains a
|
|
10440
|
+
// colon" meaning two different things on the two programs: on the
|
|
10441
|
+
// desktop every name IS a cut label, so the rule asserts the cut ran
|
|
10442
|
+
// and can never fire on correct output; here it would ALSO have
|
|
10443
|
+
// constrained what a control may be called, and a checkbox legitimately
|
|
10444
|
+
// labelled "Ratio 1:1" would have failed it. Cutting everywhere makes
|
|
10445
|
+
// the invariant mean the same thing on both sides.
|
|
10446
|
+
//
|
|
10447
|
+
// What the cut is doing here, exactly: our labels read "Color by:" and
|
|
10448
|
+
// "Shape:", so it removes a TRAILING colon, and removing the cut fails
|
|
10449
|
+
// the sheet's value rule by name ("Color by:"). What it is NOT doing
|
|
10450
|
+
// here is removing a value -- nothing follows a colon on this panel,
|
|
10451
|
+
// unlike the desktop's "Font size: 6". So it is exercised but for the
|
|
10452
|
+
// lesser of its two reasons. (Said precisely because the looser
|
|
10453
|
+
// version of this note -- "a no-op, removing it passes every check" --
|
|
10454
|
+
// was true for about an hour and then was not, once the desktop's
|
|
10455
|
+
// sharper value rule was adopted here.)
|
|
10456
|
+
return cutAtValue(rawControlName(el));
|
|
10457
|
+
}
|
|
10458
|
+
|
|
10459
|
+
// "Font size: 6" -> "Font size". The colon is where a value starts, and a
|
|
10460
|
+
// value on a sheet is wrong for everyone but whoever was looking when it
|
|
10461
|
+
// was made.
|
|
10462
|
+
function cutAtValue(name) {
|
|
10463
|
+
return name ? name.split(':')[0].trim() : '';
|
|
10464
|
+
}
|
|
10465
|
+
|
|
10466
|
+
// What the control is called, before the cut. A checkbox is named by its
|
|
10467
|
+
// own text, a menu or slider by the label beside it, a glyph button by
|
|
10468
|
+
// nothing -- its picture is its name, and the row shows that.
|
|
10469
|
+
function rawControlName(el) {
|
|
10470
|
+
if (el.type === 'checkbox' || el.type === 'radio') {
|
|
10471
|
+
let span = el.parentElement ? el.parentElement.querySelector('span') : null;
|
|
10472
|
+
if (span && span.textContent.trim() !== '') {
|
|
10473
|
+
return span.textContent.trim();
|
|
10474
|
+
}
|
|
10475
|
+
}
|
|
10476
|
+
if (el.tagName === 'INPUT' && el.value && (el.type === 'button' || el.type === 'submit')) {
|
|
10477
|
+
return el.value.trim();
|
|
10478
|
+
}
|
|
10479
|
+
if (el.id) {
|
|
10480
|
+
let lab = document.querySelector('label[for="' + el.id + '"]');
|
|
10481
|
+
if (lab && lab.textContent.trim() !== '') {
|
|
10482
|
+
return lab.textContent.trim();
|
|
10483
|
+
}
|
|
10484
|
+
}
|
|
10485
|
+
// a menu with no visible label of its own says what it is here
|
|
10486
|
+
let aria = el.getAttribute('aria-label');
|
|
10487
|
+
if (aria && aria.trim() !== '') {
|
|
10488
|
+
return aria.trim();
|
|
10489
|
+
}
|
|
10490
|
+
// and a text button -- the hide toggle, the reset chips -- is its text
|
|
10491
|
+
let own = (el.textContent || '').trim();
|
|
10492
|
+
if (own !== '' && el.tagName === 'BUTTON' && !el.querySelector('svg')) {
|
|
10493
|
+
return own;
|
|
10494
|
+
}
|
|
10495
|
+
// a segmented button drawn as TEXT rather than a glyph (Time / Div) is
|
|
10496
|
+
// named by its own label, which is the only thing on it
|
|
10497
|
+
let seg = el.closest ? el.closest('label.aptx-seg') : null;
|
|
10498
|
+
if (seg && !seg.querySelector('svg')) {
|
|
10499
|
+
let segText = (seg.textContent || '').trim();
|
|
10500
|
+
if (segText !== '') {
|
|
10501
|
+
return segText;
|
|
10502
|
+
}
|
|
10503
|
+
}
|
|
10504
|
+
return '';
|
|
10505
|
+
}
|
|
10506
|
+
|
|
10507
|
+
// What the user actually sees and clicks. A segmented button's <input> is
|
|
10508
|
+
// 0x0 and transparent -- the visible thing is the <label> around it, which
|
|
10509
|
+
// carries the glyph and the tooltip -- so asking the input whether it is
|
|
10510
|
+
// on the screen answered no for all eight of them: the three layouts, the
|
|
10511
|
+
// three display types and the time/divergence pair were absent from the
|
|
10512
|
+
// sheet entirely (Christian, 2026-09-28).
|
|
10513
|
+
function controlSurface(el) {
|
|
10514
|
+
let box = el.getBoundingClientRect();
|
|
10515
|
+
if (box.width > 0 || box.height > 0) {
|
|
10516
|
+
return el;
|
|
10517
|
+
}
|
|
10518
|
+
let label = el.closest ? el.closest('label') : null;
|
|
10519
|
+
if (label) {
|
|
10520
|
+
let lb = label.getBoundingClientRect();
|
|
10521
|
+
if (lb.width > 0 || lb.height > 0) {
|
|
10522
|
+
return label;
|
|
10523
|
+
}
|
|
10524
|
+
}
|
|
10525
|
+
return null;
|
|
10526
|
+
}
|
|
10527
|
+
|
|
10528
|
+
function cheatSheetRows() {
|
|
10529
|
+
let panel = cheatSheetPanel();
|
|
10530
|
+
if (!panel) {
|
|
10531
|
+
return [];
|
|
10532
|
+
}
|
|
10533
|
+
let rows = [];
|
|
10534
|
+
let seen = new Set();
|
|
10535
|
+
let all = panel.querySelectorAll('input, button, select');
|
|
10536
|
+
for (let i = 0; i < all.length; ++i) {
|
|
10537
|
+
let el = all[i];
|
|
10538
|
+
// what the panel is SHOWING: a control the tree gives no data for
|
|
10539
|
+
// is not on the screen and does not belong on the sheet. A
|
|
10540
|
+
// DISABLED one is on the screen, and saying what it is is how a
|
|
10541
|
+
// reader learns why it is greyed.
|
|
10542
|
+
let surface = el.disabled ? (controlSurface(el) || el) : controlSurface(el);
|
|
10543
|
+
if (!surface) {
|
|
10544
|
+
continue;
|
|
10545
|
+
}
|
|
10546
|
+
let words = controlDescription(el);
|
|
10547
|
+
if (words === '' || seen.has(el)) {
|
|
10548
|
+
continue;
|
|
10549
|
+
}
|
|
10550
|
+
seen.add(el);
|
|
10551
|
+
// the glyph belongs to the surface, not to a 0x0 input inside it
|
|
10552
|
+
let svg = surface.querySelector ? surface.querySelector('svg.aptx-glyph') : null;
|
|
10553
|
+
rows.push({name: controlName(el), words: words, glyph: svg});
|
|
10554
|
+
}
|
|
10555
|
+
return rows;
|
|
10556
|
+
}
|
|
10557
|
+
|
|
10558
|
+
function renderCheatSheet(body) {
|
|
10559
|
+
// everything but the standing lead paragraph
|
|
10560
|
+
let keep = body.querySelector('.aptx-cheat-lead');
|
|
10561
|
+
while (body.firstChild) {
|
|
10562
|
+
body.removeChild(body.firstChild);
|
|
10563
|
+
}
|
|
10564
|
+
if (keep) {
|
|
10565
|
+
body.appendChild(keep);
|
|
10566
|
+
}
|
|
10567
|
+
let rows = cheatSheetRows();
|
|
10568
|
+
rows.forEach(function (row) {
|
|
10569
|
+
let line = document.createElement('div');
|
|
10570
|
+
line.className = 'aptx-dialog-line aptx-cheat-line';
|
|
10571
|
+
let key = document.createElement('span');
|
|
10572
|
+
key.className = 'aptx-dialog-key aptx-cheat-key';
|
|
10573
|
+
if (row.glyph) {
|
|
10574
|
+
let g = row.glyph.cloneNode(true);
|
|
10575
|
+
g.removeAttribute('class');
|
|
10576
|
+
g.setAttribute('class', 'aptx-glyph aptx-cheat-glyph');
|
|
10577
|
+
key.appendChild(g);
|
|
10578
|
+
}
|
|
10579
|
+
if (row.name) {
|
|
10580
|
+
let n = document.createElement('span');
|
|
10581
|
+
n.className = 'aptx-cheat-name';
|
|
10582
|
+
n.textContent = row.name;
|
|
10583
|
+
key.appendChild(n);
|
|
10584
|
+
}
|
|
10585
|
+
let val = document.createElement('span');
|
|
10586
|
+
val.className = 'aptx-dialog-val';
|
|
10587
|
+
val.textContent = row.words;
|
|
10588
|
+
line.appendChild(key);
|
|
10589
|
+
line.appendChild(val);
|
|
10590
|
+
body.appendChild(line);
|
|
10591
|
+
});
|
|
10592
|
+
if (rows.length === 0) {
|
|
10593
|
+
let empty = document.createElement('p');
|
|
10594
|
+
empty.className = 'aptx-shortcuts-note';
|
|
10595
|
+
empty.textContent = 'No controls are showing.';
|
|
10596
|
+
body.appendChild(empty);
|
|
10597
|
+
}
|
|
10598
|
+
}
|
|
10599
|
+
|
|
10600
|
+
// The panel is rebuilt on every render, so an open sheet is re-read from
|
|
10601
|
+
// it rather than left describing a panel that has changed underneath.
|
|
10602
|
+
function refreshCheatSheet() {
|
|
10603
|
+
if (_cheatSheetBody && _cheatSheetBody.isConnected) {
|
|
10604
|
+
renderCheatSheet(_cheatSheetBody);
|
|
10605
|
+
}
|
|
10606
|
+
}
|
|
10607
|
+
|
|
10608
|
+
function showCheatSheetDialog() {
|
|
10609
|
+
let shell = makeDialogShell(CHEAT_SHEET_DIALOG, 'Control panel', 460);
|
|
10610
|
+
shell.body.classList.add('aptx-shortcuts');
|
|
10611
|
+
shell.body.classList.add('aptx-cheat');
|
|
10612
|
+
// says what the reader is looking at, as the desktop's sheet does
|
|
10613
|
+
let lead = document.createElement('p');
|
|
10614
|
+
lead.className = 'aptx-cheat-lead';
|
|
10615
|
+
lead.textContent = 'Every control the panel is showing, with the icon it draws and what it does.';
|
|
10616
|
+
shell.body.appendChild(lead);
|
|
10617
|
+
_cheatSheetBody = shell.body;
|
|
10618
|
+
renderCheatSheet(shell.body);
|
|
10619
|
+
shell.dialog.addEventListener('close', function () {
|
|
10620
|
+
if (_cheatSheetBody === shell.body) {
|
|
10621
|
+
_cheatSheetBody = null;
|
|
10622
|
+
}
|
|
10623
|
+
});
|
|
10624
|
+
// modeless, as the colour picker is: the point is to read a row and
|
|
10625
|
+
// click the control it names without closing anything
|
|
10626
|
+
shell.dialog.show();
|
|
10627
|
+
placeBesidePanel(shell.dialog);
|
|
10628
|
+
}
|
|
10629
|
+
|
|
10630
|
+
// Beside the panel, never over it, and inside the window.
|
|
10631
|
+
function placeBesidePanel(dialog) {
|
|
10632
|
+
let panel = cheatSheetPanel();
|
|
10633
|
+
let box = panel ? panel.getBoundingClientRect() : null;
|
|
10634
|
+
let w = dialog.offsetWidth;
|
|
10635
|
+
let h = dialog.offsetHeight;
|
|
10636
|
+
let left = box ? (box.right + 10) : 10;
|
|
10637
|
+
if (left + w > window.innerWidth - 6) {
|
|
10638
|
+
left = box ? (box.left - w - 10) : 6;
|
|
10639
|
+
}
|
|
10640
|
+
setStyles(dialog, {
|
|
10641
|
+
'position': 'fixed',
|
|
10642
|
+
'margin': '0',
|
|
10643
|
+
'left': Math.max(6, Math.min(window.innerWidth - w - 6, left)) + 'px',
|
|
10644
|
+
'top': Math.max(6, Math.min(window.innerHeight - h - 6, box ? box.top : 10)) + 'px'
|
|
10645
|
+
});
|
|
10646
|
+
}
|
|
10647
|
+
|
|
10020
10648
|
function showShortcutsDialog() {
|
|
10021
10649
|
let shell = makeDialogShell(SHORTCUTS_DIALOG, 'Keyboard shortcuts', 400);
|
|
10022
10650
|
shell.body.classList.add('aptx-shortcuts');
|
|
@@ -10353,7 +10981,27 @@ function (root, d3, forester, phyloXml) {
|
|
|
10353
10981
|
return;
|
|
10354
10982
|
}
|
|
10355
10983
|
let existing = _svgGroup.select('g.aptx-domains');
|
|
10984
|
+
// An architecture goes WITH ITS NAME, and is left out when the name
|
|
10985
|
+
// is. domainsShown() already ties the whole track to external labels
|
|
10986
|
+
// being on; this is the same rule per tip, and it became necessary the
|
|
10987
|
+
// day unrooted started hiding crowded names -- before that every tip
|
|
10988
|
+
// in every layout kept its label, so a nameless architecture could not
|
|
10989
|
+
// happen. Measured on apaf when it could: 7 of 31 tips drew an
|
|
10990
|
+
// architecture with no name beside it, identifiable only by which
|
|
10991
|
+
// spoke it sat on. (The desktop reached the same rule from the other
|
|
10992
|
+
// end: a tip IMAGE painted before its label's claim was left orphaned
|
|
10993
|
+
// under a neighbour's name.)
|
|
10356
10994
|
let tips = (domainsShown() && _root) ? displayedTips().filter(function (d) {
|
|
10995
|
+
// Keyed on "the RULE hid this name", not on "there is no name".
|
|
10996
|
+
// The two part company the moment the user switches the name
|
|
10997
|
+
// fields off: nothing is hidden by the rule then, every label is
|
|
10998
|
+
// empty, and every architecture should still be drawn. Keying on
|
|
10999
|
+
// the empty text took all 31 of apaf's architectures away with the
|
|
11000
|
+
// Node Name checkbox -- shipped, and caught by the desktop
|
|
11001
|
+
// warning us they had met the same reading of their own fix.
|
|
11002
|
+
if (d._labelDropped) {
|
|
11003
|
+
return false;
|
|
11004
|
+
}
|
|
10357
11005
|
return _state.unrootedDisplay ? (d.ux !== undefined) : (d.x !== undefined);
|
|
10358
11006
|
}) : [];
|
|
10359
11007
|
let f = tips.length > 0 ? domainScale() : 0;
|
|
@@ -11519,7 +12167,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
11519
12167
|
|
|
11520
12168
|
// what the viewer writes onto a tree's nodes, left off a tree cut out of it
|
|
11521
12169
|
const VIEW_NODE_FIELDS = ['viewId', 'collapsed', 'x', 'y', 'x0', 'y0', 'id', 'hide', 'hasVis', 'distToRoot',
|
|
11522
|
-
'ux', 'uy', 'uangle', '_style', '_suppDot', '_extLabelText', '_eventText', '_confText', '_blText'
|
|
12170
|
+
'ux', 'uy', 'uangle', '_style', '_suppDot', '_extLabelText', '_eventText', '_confText', '_blText',
|
|
12171
|
+
'_labelBoxes', '_labelDropped', '_markBoxes'];
|
|
11523
12172
|
|
|
11524
12173
|
// the whole tree's tip count, counted again only when the tree changes
|
|
11525
12174
|
// (every change makes new _basicTreeProperties)
|
|
@@ -12315,16 +12964,27 @@ function (root, d3, forester, phyloXml) {
|
|
|
12315
12964
|
scheduleUpdate(null, 0);
|
|
12316
12965
|
}
|
|
12317
12966
|
|
|
12967
|
+
// In CIRCULAR these two leave the aligned/unaligned flag alone. That
|
|
12968
|
+
// layout aligns whatever the flag says, so the button shown as chosen
|
|
12969
|
+
// there is "aligned" regardless -- and writing the flag from it would
|
|
12970
|
+
// silently change the user's choice for the layouts that CAN express it.
|
|
12971
|
+
// A user who likes the plain phylogram in rectangular, steps into
|
|
12972
|
+
// circular, flips to the cladogram and back, and returns, would have
|
|
12973
|
+
// found the aligned one waiting.
|
|
12318
12974
|
function toAlignedPhylogram() {
|
|
12319
12975
|
_state.phylogram = true;
|
|
12320
|
-
_state.
|
|
12976
|
+
if (!_state.circularDisplay) {
|
|
12977
|
+
_state.alignPhylogram = true;
|
|
12978
|
+
}
|
|
12321
12979
|
setDisplayTypeButtons();
|
|
12322
12980
|
scheduleUpdate(null, 0);
|
|
12323
12981
|
}
|
|
12324
12982
|
|
|
12325
12983
|
function toCladegram() {
|
|
12326
12984
|
_state.phylogram = false;
|
|
12327
|
-
_state.
|
|
12985
|
+
if (!_state.circularDisplay) {
|
|
12986
|
+
_state.alignPhylogram = false;
|
|
12987
|
+
}
|
|
12328
12988
|
setDisplayTypeButtons();
|
|
12329
12989
|
scheduleUpdate(null, 0);
|
|
12330
12990
|
}
|
|
@@ -12356,19 +13016,40 @@ function (root, d3, forester, phyloXml) {
|
|
|
12356
13016
|
// against; an alignment / time axis is inherently horizontal. Synced
|
|
12357
13017
|
// BEFORE the zoom-row early return, and (via updateButtonEnabledState)
|
|
12358
13018
|
// on every render, so subtree switches keep them honest.
|
|
13019
|
+
// WHICH of the two phylogram buttons is dead depends on the layout,
|
|
13020
|
+
// and it is the one the layout CANNOT do -- not the one it always
|
|
13021
|
+
// does. Circular always carries its external labels out to a common
|
|
13022
|
+
// ring on dashed connectors, whatever is chosen, so the circular
|
|
13023
|
+
// phylogram IS the aligned one: the unavailable option there is the
|
|
13024
|
+
// UNALIGNED phylogram. Unrooted is the other way round -- it has no
|
|
13025
|
+
// common edge to align to, so what it cannot do is align.
|
|
13026
|
+
//
|
|
13027
|
+
// (Greying the aligned button in circular was the first attempt, on
|
|
13028
|
+
// the grounds that picking it changed nothing. True, but backwards:
|
|
13029
|
+
// it disabled the button that describes what the layout does and left
|
|
13030
|
+
// live the one it cannot do, and the panel then showed "phylogram"
|
|
13031
|
+
// selected over a picture whose labels are plainly aligned. Christian
|
|
13032
|
+
// spotted the inconsistency, 2026-09-27.)
|
|
13033
|
+
let measured = _basicTreeProperties.branchLengths === true;
|
|
12359
13034
|
let phyBtn = byId(PHYLOGRAM_BUTTON);
|
|
12360
13035
|
if (phyBtn) {
|
|
12361
|
-
phyBtn.disabled =
|
|
13036
|
+
phyBtn.disabled = !measured || _state.circularDisplay;
|
|
13037
|
+
phyBtn.title = PHYLOGRAM_WHAT;
|
|
12362
13038
|
}
|
|
12363
13039
|
let alignBtn = byId(PHYLOGRAM_ALIGNED_BUTTON);
|
|
12364
13040
|
if (alignBtn) {
|
|
12365
|
-
alignBtn.disabled =
|
|
13041
|
+
alignBtn.disabled = !measured || _state.unrootedDisplay;
|
|
13042
|
+
alignBtn.title = PHYLOGRAM_ALIGNED_WHAT;
|
|
12366
13043
|
}
|
|
13044
|
+
// Which button is SHOWN as chosen is layout-dependent too, and this
|
|
13045
|
+
// was only re-synced when the display type changed -- so switching
|
|
13046
|
+
// layout left the previous layout's answer on the screen.
|
|
13047
|
+
setDisplayTypeButtons();
|
|
12367
13048
|
// Auto-hide Labels stays live in every layout. It used to be greyed out
|
|
12368
|
-
// in unrooted, where the tip-label decimation
|
|
12369
|
-
// but it also governs the crowded branch-data rule,
|
|
12370
|
-
//
|
|
12371
|
-
//
|
|
13049
|
+
// in unrooted, where the every-k-th tip-label decimation does not run --
|
|
13050
|
+
// but it also governs the crowded branch-data rule, and now the
|
|
13051
|
+
// unrooted tip-label rule, both of which do run there, so greying it
|
|
13052
|
+
// out left the user unable to switch off what they could see.
|
|
12372
13053
|
let msaCb = byId(MSA_CB);
|
|
12373
13054
|
if (msaCb) {
|
|
12374
13055
|
msaCb.disabled = radialDisplay();
|
|
@@ -12585,7 +13266,15 @@ function (root, d3, forester, phyloXml) {
|
|
|
12585
13266
|
// panel's accent: the checkbox item gets a box and its tooltip says how
|
|
12586
13267
|
// many labels are shown. Called on every redraw, since the density
|
|
12587
13268
|
// changes with the vertical zoom, the font size and the view.
|
|
12588
|
-
|
|
13269
|
+
// Lights the toggle while it is actually taking something away, and says
|
|
13270
|
+
// what. The switch governs three rules, and the light has to ask all three
|
|
13271
|
+
// rather than whichever one was written first: the every-k-th thinning
|
|
13272
|
+
// (rectangular, by index against the row pitch), the overlap thinning
|
|
13273
|
+
// (both fans, by whether names would overprint) and the crowded
|
|
13274
|
+
// branch-data rule (every layout). Asking only the first two left it dark
|
|
13275
|
+
// over a circular tree with 201 numbers refused and every name drawn --
|
|
13276
|
+
// and over a RECTANGULAR one with 18 refused, which was never right.
|
|
13277
|
+
function syncDynahideIndicator() {
|
|
12589
13278
|
let cb = byId(DYNAHIDE_CB);
|
|
12590
13279
|
let item = cb ? cb.closest('.aptx-check') : null;
|
|
12591
13280
|
if (!item) {
|
|
@@ -12594,10 +13283,23 @@ function (root, d3, forester, phyloXml) {
|
|
|
12594
13283
|
if (item.dataset.baseTitle === undefined) {
|
|
12595
13284
|
item.dataset.baseTitle = item.title;
|
|
12596
13285
|
}
|
|
12597
|
-
|
|
13286
|
+
let parts = [];
|
|
13287
|
+
if (!radialDisplay() && _dynahide_factor >= 2) {
|
|
13288
|
+
parts.push('1 in ' + _dynahide_factor + ' labels shown');
|
|
13289
|
+
} else if (radialDisplay() && _radialNamesHidden > 0) {
|
|
13290
|
+
parts.push(_radialNamesHidden + ' name' + (_radialNamesHidden === 1 ? '' : 's')
|
|
13291
|
+
+ ' that would overprint');
|
|
13292
|
+
}
|
|
13293
|
+
if (_branchMarksHidden > 0) {
|
|
13294
|
+
// said only when it is happening: the clause used to be printed
|
|
13295
|
+
// unconditionally, which made a promise of every render
|
|
13296
|
+
parts.push(_branchMarksHidden + ' branch value' + (_branchMarksHidden === 1 ? '' : 's')
|
|
13297
|
+
+ ' that would overlap');
|
|
13298
|
+
}
|
|
13299
|
+
let active = _state.dynahide === true && parts.length > 0;
|
|
13300
|
+
item.classList.toggle('aptx-check-active', active);
|
|
12598
13301
|
item.title = active
|
|
12599
|
-
? item.dataset.baseTitle + ', hiding now:
|
|
12600
|
-
+ ' labels shown, and branch data that would overlap'
|
|
13302
|
+
? item.dataset.baseTitle + ', hiding now: ' + parts.join(', and ')
|
|
12601
13303
|
: item.dataset.baseTitle;
|
|
12602
13304
|
}
|
|
12603
13305
|
|
|
@@ -12617,12 +13319,163 @@ function (root, d3, forester, phyloXml) {
|
|
|
12617
13319
|
scheduleUpdate(null, 0);
|
|
12618
13320
|
}
|
|
12619
13321
|
|
|
12620
|
-
|
|
12621
|
-
|
|
12622
|
-
|
|
12623
|
-
|
|
12624
|
-
|
|
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;
|
|
12625
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;
|
|
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);
|
|
12626
13479
|
}
|
|
12627
13480
|
|
|
12628
13481
|
function changeBaseBackgoundColor(color) {
|
|
@@ -12888,7 +13741,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
12888
13741
|
// of open sections: three open sections measure anywhere from 215px to
|
|
12889
13742
|
// 562px, so a count would not have bounded the height at all.
|
|
12890
13743
|
const PANEL_SECTIONS_KEY = 'aptx-panel-sections';
|
|
12891
|
-
|
|
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'];
|
|
12892
13747
|
|
|
12893
13748
|
function loadPanelSections() {
|
|
12894
13749
|
if (_panelSections) {
|
|
@@ -13236,6 +14091,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
13236
14091
|
+ glyphDot(50, 29, 5.5);
|
|
13237
14092
|
}
|
|
13238
14093
|
|
|
14094
|
+
// a reference card: the outline, and the rows on it
|
|
14095
|
+
function glyphCheatSheet() {
|
|
14096
|
+
return '<rect x="18" y="12" width="64" height="76" rx="8"/>'
|
|
14097
|
+
+ glyphLine(32, 34, 68, 34)
|
|
14098
|
+
+ glyphLine(32, 50, 68, 50)
|
|
14099
|
+
+ glyphLine(32, 66, 56, 66);
|
|
14100
|
+
}
|
|
14101
|
+
|
|
13239
14102
|
function glyphSun() {
|
|
13240
14103
|
let s = glyphDot(50, 50, 20);
|
|
13241
14104
|
for (let i = 0; i < 8; ++i) {
|
|
@@ -13304,6 +14167,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
13304
14167
|
case 'tree_prev': sw = 9; cap = 'round'; body = glyphChevron(false); break;
|
|
13305
14168
|
case 'tree_next': sw = 9; cap = 'round'; body = glyphChevron(true); break;
|
|
13306
14169
|
case 'info': sw = 7; cap = 'round'; body = glyphInfo(); break;
|
|
14170
|
+
case 'cheat_sheet': sw = 7; cap = 'round'; body = glyphCheatSheet(); break;
|
|
13307
14171
|
case 'sun': body = glyphSun(); break;
|
|
13308
14172
|
case 'moon': body = glyphMoon(); break;
|
|
13309
14173
|
default: throw new Error('unknown control-panel glyph: ' + kind);
|
|
@@ -13334,13 +14198,13 @@ function (root, d3, forester, phyloXml) {
|
|
|
13334
14198
|
}
|
|
13335
14199
|
// Dark palette tokens, shared by the system-preference default and the
|
|
13336
14200
|
// explicit "dark" choice from the header light/dark switch.
|
|
13337
|
-
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;'
|
|
13338
14202
|
+ ' --p-line:#27343f; --p-line-strong:#35434f; --p-surface2:#202d38;'
|
|
13339
14203
|
+ ' --p-accent:#57a6ff; --p-accent-ink:#9cc7ff; --p-accent-weak:rgba(87,166,255,0.18);'
|
|
13340
14204
|
+ ' --p-shadow-sm:0 1px 2px rgba(0,0,0,0.4);';
|
|
13341
14205
|
let css = ''
|
|
13342
14206
|
+ '.aptx-panel {'
|
|
13343
|
-
+ ' --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;'
|
|
13344
14208
|
+ ' --p-line:#e3e9f0; --p-line-strong:#cad6e1; --p-surface2:#f3f6fa;'
|
|
13345
14209
|
+ ' --p-accent:#2f83f2; --p-accent-ink:#1c5fbf; --p-accent-weak:rgba(47,131,242,0.12);'
|
|
13346
14210
|
+ ' --p-shadow-sm:0 1px 2px rgba(23,34,46,0.12);'
|
|
@@ -13367,7 +14231,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
13367
14231
|
// (they cannot live inside the panel), so they carry their own
|
|
13368
14232
|
// copy of the palette and follow the same light / dark rules.
|
|
13369
14233
|
+ '.aptx-node-menu, .aptx-suggest {'
|
|
13370
|
-
+ ' --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;'
|
|
13371
14235
|
+ ' --p-line:#e3e9f0; --p-line-strong:#cad6e1; --p-surface2:#f3f6fa;'
|
|
13372
14236
|
+ ' --p-accent:#2f83f2; --p-accent-ink:#1c5fbf; --p-accent-weak:rgba(47,131,242,0.12);'
|
|
13373
14237
|
+ '}'
|
|
@@ -13397,6 +14261,17 @@ function (root, d3, forester, phyloXml) {
|
|
|
13397
14261
|
+ ' background:#e5484d; color:#fff; }'
|
|
13398
14262
|
+ '.aptx-node-menu hr { border:0; border-top:1px solid var(--p-line); margin:3px 4px; }'
|
|
13399
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; }'
|
|
13400
14275
|
// The search suggestions: the node menu's chrome under the value box.
|
|
13401
14276
|
+ '.aptx-suggest { position:absolute; z-index:1000; max-width:320px; padding:4px; box-sizing:border-box;'
|
|
13402
14277
|
+ ' border:1px solid var(--p-line-strong); border-radius:10px; background:var(--p-bg); color:var(--p-ink);'
|
|
@@ -13415,7 +14290,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
13415
14290
|
+ ' font-size:10px; color:var(--p-faint); }'
|
|
13416
14291
|
// The node-data dialog, on the same palette as the panel and the menu.
|
|
13417
14292
|
+ '.aptx-dialog, .aptx-busy, .aptx-msa-nav {'
|
|
13418
|
-
+ ' --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;'
|
|
13419
14294
|
+ ' --p-line:#e3e9f0; --p-line-strong:#cad6e1; --p-surface2:#f3f6fa;'
|
|
13420
14295
|
+ ' --p-accent:#2f83f2; --p-accent-ink:#1c5fbf; --p-accent-weak:rgba(47,131,242,0.12);'
|
|
13421
14296
|
+ '}'
|
|
@@ -13502,6 +14377,18 @@ function (root, d3, forester, phyloXml) {
|
|
|
13502
14377
|
+ '.aptx-keys { display:inline-flex; gap:3px; flex-wrap:wrap; }'
|
|
13503
14378
|
+ '.aptx-kbd { font-family:inherit; font-size:10.5px; line-height:1; padding:3px 5px; min-width:18px; text-align:center; color:var(--p-ink); background:var(--p-surface2); border:1px solid var(--p-line-strong); border-bottom-width:2px; border-radius:4px; }'
|
|
13504
14379
|
+ '.aptx-shortcuts-note { margin:8px 0 0; font-size:10.5px; line-height:1.4; color:var(--p-muted); }'
|
|
14380
|
+
// the cheat sheet: a scrolling card of glyph + name : description
|
|
14381
|
+
// 31 rows is taller than a short window; the card scrolls rather
|
|
14382
|
+
// than running off the bottom, and the title bar stays put
|
|
14383
|
+
+ '#' + CHEAT_SHEET_DIALOG + ' { max-height:min(78vh, 760px); display:flex; flex-direction:column; }'
|
|
14384
|
+
+ '#' + CHEAT_SHEET_DIALOG + ' .aptx-dialog-body { min-height:0; }'
|
|
14385
|
+
+ '.aptx-cheat-lead { margin:0 0 9px; font-size:10.5px; line-height:1.4; color:var(--p-muted); }'
|
|
14386
|
+
+ '.aptx-cheat .aptx-dialog-line { padding:3px 0; align-items:flex-start; }'
|
|
14387
|
+
+ '.aptx-cheat .aptx-cheat-key { flex:0 0 38%; display:flex; align-items:center; gap:6px; color:var(--p-ink); }'
|
|
14388
|
+
+ '.aptx-cheat .aptx-cheat-glyph { width:14px; height:14px; flex:none; color:var(--p-muted); }'
|
|
14389
|
+
+ '.aptx-cheat .aptx-cheat-name { overflow-wrap:anywhere; }'
|
|
14390
|
+
+ '.aptx-cheat .aptx-dialog-val { color:var(--p-muted); line-height:1.35; }'
|
|
14391
|
+
+ '.aptx-cheat .aptx-cheat-key:not(:empty)::after { content:":"; color:var(--p-faint); }'
|
|
13505
14392
|
// representative tips: the input dialog and its result
|
|
13506
14393
|
+ '.aptx-reps { line-height:1.35; }'
|
|
13507
14394
|
+ '.aptx-reps-lead { margin:0 0 4px; }'
|
|
@@ -13553,7 +14440,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
13553
14440
|
+ ' font-size:11px; white-space:pre-wrap; overflow-wrap:anywhere; }'
|
|
13554
14441
|
// The hover tooltip, on the same palette.
|
|
13555
14442
|
+ '.aptx-tip {'
|
|
13556
|
-
+ ' --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;'
|
|
13557
14444
|
+ ' --p-line:#e3e9f0; --p-line-strong:#cad6e1;'
|
|
13558
14445
|
+ '}'
|
|
13559
14446
|
+ '@media (prefers-color-scheme:dark){ .aptx-tip:not(.aptx-light):not(.aptx-dark) {' + dark + '} }'
|
|
@@ -13573,7 +14460,15 @@ function (root, d3, forester, phyloXml) {
|
|
|
13573
14460
|
+ '.aptx-tip .aptx-dialog-line { padding:1px 0; }'
|
|
13574
14461
|
+ '.aptx-tip .aptx-dialog-line + .aptx-dialog-line { border-top:0; }'
|
|
13575
14462
|
+ '.aptx-tip .aptx-dialog-key { flex:0 0 auto; min-width:96px; }'
|
|
13576
|
-
|
|
14463
|
+
// WRAPS. The title's longest word cannot break, so on a narrow
|
|
14464
|
+
// panel the buttons had nowhere to go and overflowed the padding:
|
|
14465
|
+
// with four of them they ended 2.4 px past the panel's own right
|
|
14466
|
+
// edge, and squeezed the title into two ragged centred lines on
|
|
14467
|
+
// the way. Wrapping puts them on their own right-aligned row
|
|
14468
|
+
// instead, which also gives the title back its full width -- so
|
|
14469
|
+
// "Archaeopteryx.js 3.15.0" sits on ONE line where it used to
|
|
14470
|
+
// break in two.
|
|
14471
|
+
+ '.aptx-panel .' + PROG_NAME + ' { display:flex; align-items:center; flex-wrap:wrap; gap:6px 8px; padding:9px 12px; border-bottom:1px solid var(--p-line); font-weight:600; letter-spacing:-0.01em; }'
|
|
13577
14472
|
+ '.aptx-panel .' + PROGNAMELINK + ',.aptx-panel .' + PROGNAMELINK + ':link,.aptx-panel .' + PROGNAMELINK + ':visited { color:var(--p-accent-ink); text-decoration:none; font-size:12px; border:0; }'
|
|
13578
14473
|
+ '.aptx-panel .' + PROGNAMELINK + ' { background:none; padding:0; margin:0; cursor:pointer;'
|
|
13579
14474
|
+ ' font-family:inherit; font-weight:inherit; letter-spacing:inherit; }'
|
|
@@ -13700,6 +14595,16 @@ function (root, d3, forester, phyloXml) {
|
|
|
13700
14595
|
+ '.aptx-panel .aptx-zoomrow .aptx-gbtn { flex:1 1 0; padding:0; }'
|
|
13701
14596
|
+ '.aptx-panel .aptx-zoomrow .aptx-gbtn:last-child { margin-right:0; }'
|
|
13702
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; }'
|
|
13703
14608
|
+ '.aptx-panel .aptx-domrow .aptx-domlabel { flex:0 0 64px; font-size:10px; color:var(--p-muted); }'
|
|
13704
14609
|
+ '.aptx-panel .aptx-domrow input[type=button] { width:30px; padding:0; margin:0; }'
|
|
13705
14610
|
// ... except one that has a WORD on it: the rule above sizes the
|
|
@@ -13728,7 +14633,6 @@ function (root, d3, forester, phyloXml) {
|
|
|
13728
14633
|
+ ' line-height:1; color:var(--p-muted); transition:transform .15s,color .15s; }'
|
|
13729
14634
|
+ '.aptx-panel legend.aptx-legend-toggle:hover::after { color:var(--p-accent-ink); }'
|
|
13730
14635
|
+ '.aptx-panel legend.aptx-legend-toggle:hover { color:var(--p-accent-ink); }'
|
|
13731
|
-
+ '.aptx-panel #' + EXPORT_FORMAT_SELECT + ' { margin-left:6px; }'
|
|
13732
14636
|
+ '.aptx-panel fieldset.aptx-collapsed > legend.aptx-legend-toggle::after { transform:rotate(-90deg); }'
|
|
13733
14637
|
+ '.aptx-panel fieldset.aptx-collapsed > legend { margin-bottom:0; }'
|
|
13734
14638
|
+ '.aptx-panel fieldset.aptx-collapsed > .aptx-fieldset-body { display:none; }'
|
|
@@ -13824,6 +14728,18 @@ function (root, d3, forester, phyloXml) {
|
|
|
13824
14728
|
});
|
|
13825
14729
|
actions.appendChild(infoBtn);
|
|
13826
14730
|
|
|
14731
|
+
let sheetBtn = document.createElement('button');
|
|
14732
|
+
sheetBtn.type = 'button';
|
|
14733
|
+
sheetBtn.className = 'aptx-info-btn';
|
|
14734
|
+
sheetBtn.title = 'Control panel: what each control does';
|
|
14735
|
+
sheetBtn.setAttribute('aria-label', 'Control panel cheat sheet');
|
|
14736
|
+
sheetBtn.innerHTML = makeGlyph('cheat_sheet');
|
|
14737
|
+
sheetBtn.addEventListener('click', function (e) {
|
|
14738
|
+
e.stopPropagation();
|
|
14739
|
+
showCheatSheetDialog();
|
|
14740
|
+
});
|
|
14741
|
+
actions.appendChild(sheetBtn);
|
|
14742
|
+
|
|
13827
14743
|
let progName = header.querySelector('.' + PROGNAMELINK);
|
|
13828
14744
|
if (progName) {
|
|
13829
14745
|
// Clicking the title used to navigate away to the project site;
|
|
@@ -13895,6 +14811,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
13895
14811
|
fitPanelSections(panel, name);
|
|
13896
14812
|
}
|
|
13897
14813
|
savePanelSections();
|
|
14814
|
+
// a folded section's controls are off the screen, so they
|
|
14815
|
+
// leave the cheat sheet: it describes what the panel is
|
|
14816
|
+
// showing, and folding does not go through a tree render
|
|
14817
|
+
refreshCheatSheet();
|
|
13898
14818
|
});
|
|
13899
14819
|
}
|
|
13900
14820
|
// What arrived open counts as opened, oldest at the BOTTOM: a panel is
|
|
@@ -14683,7 +15603,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
14683
15603
|
['Desktop version', DESKTOP_WEBSITE, 'cmzmasek.github.io/archaeopteryx'],
|
|
14684
15604
|
['Source code', SOURCE_WEBSITE, 'github.com/cmzmasek/archaeopteryx-js'],
|
|
14685
15605
|
['License', LICENSE_WEBSITE, LICENSE_NAME],
|
|
14686
|
-
['Keyboard', null, 'shortcuts (' + (IS_MAC ? '\u2318 /' : 'Ctrl+/') + ')']
|
|
15606
|
+
['Keyboard', null, 'shortcuts (' + (IS_MAC ? '\u2318 /' : 'Ctrl+/') + ')'],
|
|
15607
|
+
['Control panel', null, 'what each control does']].forEach(function (row) {
|
|
14687
15608
|
let line = document.createElement('div');
|
|
14688
15609
|
line.className = 'aptx-dialog-line';
|
|
14689
15610
|
let key = document.createElement('span');
|
|
@@ -14697,12 +15618,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
14697
15618
|
a.target = '_blank';
|
|
14698
15619
|
a.rel = 'noopener noreferrer';
|
|
14699
15620
|
} else {
|
|
14700
|
-
// the
|
|
15621
|
+
// the two rows with no URL open another dialog in place of
|
|
15622
|
+
// this one: the key list, and the control-panel sheet
|
|
15623
|
+
let opener = row[0] === 'Control panel' ? showCheatSheetDialog : showShortcutsDialog;
|
|
14701
15624
|
a.href = '#';
|
|
14702
15625
|
a.addEventListener('click', function (e) {
|
|
14703
15626
|
e.preventDefault();
|
|
14704
15627
|
shell.dialog.close();
|
|
14705
|
-
|
|
15628
|
+
opener();
|
|
14706
15629
|
});
|
|
14707
15630
|
}
|
|
14708
15631
|
a.textContent = row[2];
|
|
@@ -14915,14 +15838,6 @@ function (root, d3, forester, phyloXml) {
|
|
|
14915
15838
|
// the stylesheet's job now: the search row sizes its R button, and the
|
|
14916
15839
|
// zoom grid stretches Y+/Y- to the exact width of the button row.)
|
|
14917
15840
|
|
|
14918
|
-
const downloadButton = byId(DOWNLOAD_BUTTON);
|
|
14919
|
-
|
|
14920
|
-
if (downloadButton) {
|
|
14921
|
-
setStyles(downloadButton, {
|
|
14922
|
-
'width': '60px', 'margin-bottom': '3px'
|
|
14923
|
-
});
|
|
14924
|
-
}
|
|
14925
|
-
|
|
14926
15841
|
|
|
14927
15842
|
on(SEARCH_FIELD_0, 'keyup', searchValueKeyup);
|
|
14928
15843
|
|
|
@@ -15139,12 +16054,19 @@ function (root, d3, forester, phyloXml) {
|
|
|
15139
16054
|
|
|
15140
16055
|
// ----------------
|
|
15141
16056
|
|
|
15142
|
-
|
|
15143
|
-
|
|
15144
|
-
|
|
15145
|
-
|
|
15146
|
-
|
|
15147
|
-
|
|
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
|
+
});
|
|
15148
16070
|
});
|
|
15149
16071
|
|
|
15150
16072
|
// ---------------
|
|
@@ -15273,7 +16195,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
15273
16195
|
function makeProgramDesc() {
|
|
15274
16196
|
let h = "";
|
|
15275
16197
|
h = h.concat('<div class=' + PROG_NAME + '>');
|
|
15276
|
-
|
|
16198
|
+
// aria-label without the version: the cheat sheet names a control
|
|
16199
|
+
// by this, and a version is a value -- it belongs in the About box
|
|
16200
|
+
// it opens, not in a row that says what the button is
|
|
16201
|
+
h = h.concat('<button type="button" class="' + PROGNAMELINK + '" aria-label="' + NAME
|
|
16202
|
+
+ '" title="About ' + NAME + '">' + NAME + ' ' + VERSION + '</button>');
|
|
15277
16203
|
h = h.concat('</div>');
|
|
15278
16204
|
return h;
|
|
15279
16205
|
}
|
|
@@ -15424,14 +16350,17 @@ function (root, d3, forester, phyloXml) {
|
|
|
15424
16350
|
// desktop
|
|
15425
16351
|
let layoutGroup = 'layout_control_radio';
|
|
15426
16352
|
h = h.concat('<div class="aptx-segmented">');
|
|
15427
|
-
h = h.concat(makeSegment(makeGlyph('rectangular'), LAYOUT_RECT_BUTTON, layoutGroup,
|
|
15428
|
-
|
|
15429
|
-
h = h.concat(makeSegment(makeGlyph('
|
|
16353
|
+
h = h.concat(makeSegment(makeGlyph('rectangular'), LAYOUT_RECT_BUTTON, layoutGroup,
|
|
16354
|
+
'rectangular layout: the root at the left, the tips in even rows down the page'));
|
|
16355
|
+
h = h.concat(makeSegment(makeGlyph('circular'), LAYOUT_CIRC_BUTTON, layoutGroup,
|
|
16356
|
+
'circular layout: the tree as a fan, every tip label carried out to a common ring'));
|
|
16357
|
+
h = h.concat(makeSegment(makeGlyph('unrooted'), LAYOUT_UNROOTED_BUTTON, layoutGroup,
|
|
16358
|
+
'unrooted layout: equal-angle, drawing no root branch at all'));
|
|
15430
16359
|
h = h.concat('</div>');
|
|
15431
16360
|
h = h.concat('<div class="' + PHYLOGRAM_CLADOGRAM_CONTROLGROUP + ' aptx-segmented">');
|
|
15432
|
-
h = h.concat(makeSegment(makeGlyph('phylogram'), PHYLOGRAM_BUTTON, radioGroup,
|
|
15433
|
-
h = h.concat(makeSegment(makeGlyph('aligned_phylogram'), PHYLOGRAM_ALIGNED_BUTTON, radioGroup,
|
|
15434
|
-
h = h.concat(makeSegment(makeGlyph('cladogram'), CLADOGRAM_BUTTON, radioGroup,
|
|
16361
|
+
h = h.concat(makeSegment(makeGlyph('phylogram'), PHYLOGRAM_BUTTON, radioGroup, PHYLOGRAM_WHAT));
|
|
16362
|
+
h = h.concat(makeSegment(makeGlyph('aligned_phylogram'), PHYLOGRAM_ALIGNED_BUTTON, radioGroup, PHYLOGRAM_ALIGNED_WHAT));
|
|
16363
|
+
h = h.concat(makeSegment(makeGlyph('cladogram'), CLADOGRAM_BUTTON, radioGroup, CLADOGRAM_WHAT));
|
|
15435
16364
|
h = h.concat('</div>');
|
|
15436
16365
|
// The branch SCALE, when the tree states two different things: its
|
|
15437
16366
|
// nodes' dates, and its branches' divergence. Hidden otherwise,
|
|
@@ -15514,7 +16443,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
15514
16443
|
// "Auto-hide Labels" matches the desktop, which renamed the historical
|
|
15515
16444
|
// "Dyna Hide" because that named the mechanism rather than what the user
|
|
15516
16445
|
// sees. The _state.dynahide field keeps its name internally.
|
|
15517
|
-
opts.push(makeCheckboxItem('Auto-hide Labels', DYNAHIDE_CB, 'automatically hide external labels, and branch values that would overprint each other, where the tree is too dense for them to be readable', true));
|
|
16446
|
+
opts.push(makeCheckboxItem('Auto-hide Labels', DYNAHIDE_CB, 'automatically hide external labels, and branch values that would overprint a name or each other, where the tree is too dense for them to be readable', true));
|
|
15518
16447
|
opts.push(makeCheckboxItem('Short Names', SHORTEN_NODE_NAME_CB, 'to shorten long node names'));
|
|
15519
16448
|
if (_basicTreeProperties.confidences) {
|
|
15520
16449
|
opts.push(makeCheckboxItem('Support Dots', SUPPORT_DOTS_CB, 'to mark a branch with support of at least ' + _settings.supportDotMinimum + '% with a dot at its midpoint, where there is room to draw it'));
|
|
@@ -15588,33 +16517,49 @@ function (root, d3, forester, phyloXml) {
|
|
|
15588
16517
|
return h;
|
|
15589
16518
|
}
|
|
15590
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.
|
|
15591
16524
|
function makeDownloadSection() {
|
|
15592
|
-
let h = "";
|
|
15593
|
-
h = h.concat('<
|
|
15594
|
-
h = h.concat('<
|
|
15595
|
-
|
|
15596
|
-
|
|
15597
|
-
|
|
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>');
|
|
15598
16539
|
if (pngExportAvailable()) {
|
|
15599
|
-
h = h.concat('<
|
|
15600
|
-
|
|
15601
|
-
|
|
15602
|
-
|
|
15603
|
-
|
|
15604
|
-
|
|
15605
|
-
|
|
15606
|
-
h = h.concat('<option value="' + NH_EXPORT_FORMAT + '">' + NH_EXPORT_FORMAT + '</option>');
|
|
15607
|
-
h = h.concat('<option value="' + NEXUS_EXPORT_FORMAT + '">' + NEXUS_EXPORT_FORMAT + '</option>');
|
|
15608
|
-
// only when there is something to write: a tip carrying a
|
|
15609
|
-
// molecular sequence, aligned or not (same gate as the node
|
|
15610
|
-
// menu's Fasta entries) -- on any other tree the download would
|
|
15611
|
-
// just be an empty file
|
|
15612
|
-
if (_basicTreeProperties.maxMolSeqLength > 0) {
|
|
15613
|
-
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>');
|
|
15614
16547
|
}
|
|
15615
|
-
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>');
|
|
15616
16562
|
h = h.concat('</fieldset>');
|
|
15617
|
-
h = h.concat('</form>');
|
|
15618
16563
|
return h;
|
|
15619
16564
|
}
|
|
15620
16565
|
|
|
@@ -15744,8 +16689,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
15744
16689
|
let h = "";
|
|
15745
16690
|
h = h.concat('<label class="aptx-field-label" for="' + val + '">' + label + '</label>');
|
|
15746
16691
|
h = h.concat('<div class="aptx-search-menus">');
|
|
15747
|
-
h = h.concat('<select class="aptx-search-field" name="' + fsel + '" id="' + fsel
|
|
15748
|
-
|
|
16692
|
+
h = h.concat('<select class="aptx-search-field" name="' + fsel + '" id="' + fsel
|
|
16693
|
+
+ '" aria-label="' + label + ' field" title="the field to search in"></select>');
|
|
16694
|
+
h = h.concat('<select class="aptx-search-mode" name="' + msel + '" id="' + msel
|
|
16695
|
+
+ '" aria-label="' + label + ' match" title="how to match"></select>');
|
|
15749
16696
|
h = h.concat('</div>');
|
|
15750
16697
|
h = h.concat('<div class="aptx-search-row">');
|
|
15751
16698
|
h = h.concat('<input class="aptx-search-value" autocomplete="off" title="' + valTip + '" type="text" name="' + val + '" id="' + val + '">');
|
|
@@ -15865,6 +16812,27 @@ function (root, d3, forester, phyloXml) {
|
|
|
15865
16812
|
setCheckboxValue(VISUAL_STYLES_CB, _state.useVisualStyles);
|
|
15866
16813
|
setCheckboxValue(VIS_CB, _state.showVisualizations);
|
|
15867
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
|
+
}
|
|
15868
16836
|
setCheckboxValue(MSA_CB, _state.showMsa);
|
|
15869
16837
|
setCheckboxValue(MSA_LOGO_CB, _state.showMsaLogo === true);
|
|
15870
16838
|
setCheckboxValue(HEATMAP_CB, _state.showHeatmap);
|
|
@@ -15952,9 +16920,15 @@ function (root, d3, forester, phyloXml) {
|
|
|
15952
16920
|
}
|
|
15953
16921
|
|
|
15954
16922
|
function setDisplayTypeButtons() {
|
|
15955
|
-
|
|
15956
|
-
|
|
15957
|
-
|
|
16923
|
+
// In circular the labels ARE aligned, whatever alignPhylogram says, so
|
|
16924
|
+
// that is the button shown as chosen: the panel describes the picture.
|
|
16925
|
+
// The flag itself is left alone -- it is the user's choice for the
|
|
16926
|
+
// layouts that can express it, and returning to rectangular restores
|
|
16927
|
+
// what they picked there.
|
|
16928
|
+
let alignedLook = _state.phylogram && (_state.alignPhylogram || _state.circularDisplay);
|
|
16929
|
+
setRadioButtonValue(PHYLOGRAM_BUTTON, _state.phylogram && !alignedLook);
|
|
16930
|
+
setRadioButtonValue(CLADOGRAM_BUTTON, !_state.phylogram);
|
|
16931
|
+
setRadioButtonValue(PHYLOGRAM_ALIGNED_BUTTON, alignedLook);
|
|
15958
16932
|
setCheckboxValue(LAYOUT_CIRC_BUTTON, _state.circularDisplay);
|
|
15959
16933
|
setCheckboxValue(LAYOUT_UNROOTED_BUTTON, _state.unrootedDisplay);
|
|
15960
16934
|
setCheckboxValue(LAYOUT_RECT_BUTTON, !radialDisplay());
|
|
@@ -15975,6 +16949,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
15975
16949
|
|
|
15976
16950
|
function updateButtonEnabledState() {
|
|
15977
16951
|
syncZoomRowButtons(); // layout- and tree-dependent disables track every render
|
|
16952
|
+
refreshCheatSheet(); // an open sheet describes the panel as it is now
|
|
15978
16953
|
// the uncollapse-all button lives only while something is collapsed, as on the desktop
|
|
15979
16954
|
((_root && collapseAllowed() && hasCollapsedIn(_root)) ? enableButton : disableButton)(byId(UNCOLLAPSE_ALL_BUTTON));
|
|
15980
16955
|
if (_in_subtree) {
|
|
@@ -16126,70 +17101,348 @@ function (root, d3, forester, phyloXml) {
|
|
|
16126
17101
|
return svgText;
|
|
16127
17102
|
}
|
|
16128
17103
|
|
|
16129
|
-
|
|
16130
|
-
|
|
16131
|
-
|
|
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') {
|
|
16132
17144
|
return null;
|
|
16133
17145
|
}
|
|
16134
|
-
|
|
16135
|
-
|
|
16136
|
-
|
|
16137
|
-
|
|
16138
|
-
|
|
16139
|
-
|
|
16140
|
-
|
|
16141
|
-
|
|
16142
|
-
|
|
16143
|
-
|
|
16144
|
-
|
|
16145
|
-
|
|
16146
|
-
|
|
16147
|
-
|
|
16148
|
-
|
|
16149
|
-
|
|
16150
|
-
|
|
16151
|
-
|
|
16152
|
-
|
|
16153
|
-
|
|
16154
|
-
|
|
16155
|
-
|
|
16156
|
-
|
|
16157
|
-
|
|
16158
|
-
|
|
16159
|
-
|
|
16160
|
-
|
|
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
|
+
}
|
|
16161
17230
|
});
|
|
16162
|
-
|
|
16163
|
-
|
|
16164
|
-
|
|
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);
|
|
16165
17282
|
}
|
|
16166
|
-
|
|
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};
|
|
16167
17370
|
}
|
|
16168
17371
|
|
|
16169
17372
|
function downloadTree(format) {
|
|
16170
|
-
if (format ===
|
|
16171
|
-
changeBaseBackgoundColor(_state.backgroundColorForPrintExportDefault);
|
|
16172
|
-
downloadAsPng();
|
|
16173
|
-
changeBaseBackgoundColor(_state.backgroundColorDefault);
|
|
16174
|
-
} else if (format === SVG_EXPORT_FORMAT) {
|
|
16175
|
-
changeBaseBackgoundColor(_state.backgroundColorForPrintExportDefault);
|
|
16176
|
-
downloadAsSVG();
|
|
16177
|
-
changeBaseBackgoundColor(_state.backgroundColorDefault);
|
|
16178
|
-
} else if (format === NH_EXPORT_FORMAT) {
|
|
17373
|
+
if (format === NH_EXPORT_FORMAT) {
|
|
16179
17374
|
downloadAsNH();
|
|
16180
17375
|
} else if (format === NEXUS_EXPORT_FORMAT) {
|
|
16181
17376
|
downloadAsNexus();
|
|
16182
17377
|
} else if (format === PHYLOXML_EXPORT_FORMAT) {
|
|
16183
17378
|
downloadAsPhyloXml();
|
|
16184
|
-
} else if (format === PDF_EXPORT_FORMAT) {
|
|
16185
|
-
changeBaseBackgoundColor(_state.backgroundColorForPrintExportDefault);
|
|
16186
|
-
downloadAsPdf();
|
|
16187
|
-
changeBaseBackgoundColor(_state.backgroundColorDefault);
|
|
16188
17379
|
} else if (format === FASTA_EXPORT_FORMAT) {
|
|
16189
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
|
+
});
|
|
16190
17393
|
}
|
|
16191
17394
|
}
|
|
16192
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
|
+
|
|
16193
17446
|
/**
|
|
16194
17447
|
* Saves a Blob to a file using native browser APIs (an <a download> link
|
|
16195
17448
|
* pointing at an object URL). This replaces the former FileSaver.js
|
|
@@ -16217,9 +17470,34 @@ function (root, d3, forester, phyloXml) {
|
|
|
16217
17470
|
}, 1000);
|
|
16218
17471
|
}
|
|
16219
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
|
+
|
|
16220
17498
|
function downloadAsPhyloXml() {
|
|
16221
|
-
let x = phyloXml.toPhyloXML(
|
|
16222
|
-
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));
|
|
16223
17501
|
}
|
|
16224
17502
|
|
|
16225
17503
|
function downloadAsNH() {
|
|
@@ -16227,25 +17505,43 @@ function (root, d3, forester, phyloXml) {
|
|
|
16227
17505
|
// not a preference: writing them out produces a file that will not parse
|
|
16228
17506
|
// back in.
|
|
16229
17507
|
let nh = forester.toNewHampshire(_root, 9, true, _settings.nhExportWriteConfidences);
|
|
16230
|
-
saveAs(new Blob([nh], {type: "application/txt"}),
|
|
17508
|
+
saveAs(new Blob([nh], {type: "application/txt"}), downloadFileName(NH_SUFFIX));
|
|
16231
17509
|
}
|
|
16232
17510
|
|
|
16233
17511
|
function downloadAsNexus() {
|
|
16234
17512
|
// taxa and trees blocks as on the desktop, plus a characters block
|
|
16235
17513
|
// when the tips carry an alignment -- carrying tree and alignment
|
|
16236
17514
|
// in one file is what Nexus is for
|
|
16237
|
-
let
|
|
16238
|
-
|
|
17515
|
+
let phy = treeForExport();
|
|
17516
|
+
let nex = forester.toNexus(phy, 9, _settings.nhExportWriteConfidences);
|
|
17517
|
+
saveAs(new Blob([nex], {type: "application/txt"}), downloadFileName(NEXUS_SUFFIX));
|
|
17518
|
+
}
|
|
17519
|
+
|
|
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));
|
|
16239
17530
|
}
|
|
16240
17531
|
|
|
16241
|
-
|
|
16242
|
-
|
|
16243
|
-
|
|
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
|
+
});
|
|
16244
17540
|
}
|
|
16245
17541
|
|
|
16246
17542
|
function downloadAsFastaAll() {
|
|
16247
17543
|
let fasta_text = forester.getMolecularSequencesAsFasta(_root, '\n');
|
|
16248
|
-
saveAs(new Blob([fasta_text], {type: "application/txt"}),
|
|
17544
|
+
saveAs(new Blob([fasta_text], {type: "application/txt"}), downloadFileName(FASTA_SUFFIX));
|
|
16249
17545
|
}
|
|
16250
17546
|
|
|
16251
17547
|
// Vector PDF via the OPTIONAL page-level libraries jsPDF and svg2pdf.js
|
|
@@ -16259,13 +17555,18 @@ function (root, d3, forester, phyloXml) {
|
|
|
16259
17555
|
&& window.jspdf.jsPDF.API.svg);
|
|
16260
17556
|
}
|
|
16261
17557
|
|
|
16262
|
-
|
|
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) {
|
|
16263
17565
|
if (!pdfExportAvailable()) {
|
|
16264
17566
|
console.error(ERROR + 'PDF export needs the optional jspdf and svg2pdf.js libraries on the page');
|
|
16265
17567
|
return;
|
|
16266
17568
|
}
|
|
16267
|
-
let
|
|
16268
|
-
let el = new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement;
|
|
17569
|
+
let el = new DOMParser().parseFromString(graphic.text, 'image/svg+xml').documentElement;
|
|
16269
17570
|
// the full-canvas background rect is sized in percentages, which
|
|
16270
17571
|
// svg2pdf renders literally (a dark 100pt square at the origin); the
|
|
16271
17572
|
// PDF page is white anyway, so the rect is simply dropped
|
|
@@ -16297,9 +17598,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
16297
17598
|
}
|
|
16298
17599
|
});
|
|
16299
17600
|
});
|
|
16300
|
-
let
|
|
16301
|
-
let w =
|
|
16302
|
-
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;
|
|
16303
17604
|
let pdf = new window.jspdf.jsPDF({
|
|
16304
17605
|
orientation: w >= h ? 'landscape' : 'portrait',
|
|
16305
17606
|
unit: 'pt',
|
|
@@ -16307,7 +17608,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
16307
17608
|
compress: true
|
|
16308
17609
|
});
|
|
16309
17610
|
pdf.svg(el, {x: 0, y: 0, width: w, height: h}).then(function () {
|
|
16310
|
-
pdf.save(
|
|
17611
|
+
pdf.save(downloadFileName(PDF_SUFFIX));
|
|
16311
17612
|
holder.remove();
|
|
16312
17613
|
}, function (err) {
|
|
16313
17614
|
holder.remove();
|
|
@@ -16324,23 +17625,62 @@ function (root, d3, forester, phyloXml) {
|
|
|
16324
17625
|
return !!(window.Canvg && window.Canvg.fromString);
|
|
16325
17626
|
}
|
|
16326
17627
|
|
|
16327
|
-
|
|
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
|
+
}
|
|
16328
17671
|
if (!pngExportAvailable()) {
|
|
16329
|
-
|
|
16330
|
-
|
|
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'));
|
|
17677
|
+
}
|
|
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');
|
|
16331
17681
|
}
|
|
16332
|
-
let
|
|
16333
|
-
|
|
16334
|
-
// rather than 1:1 with the on-screen SVG. Scale is configurable via
|
|
16335
|
-
// _settings.pngExportScale (default 4x).
|
|
16336
|
-
let svgEl = treeSvgElement();
|
|
16337
|
-
let scale = _settings.pngExportScale > 0 ? _settings.pngExportScale : 4;
|
|
16338
|
-
let w = (svgEl && svgEl.width.baseVal.value) || _displayWidth;
|
|
16339
|
-
let h = (svgEl && svgEl.height.baseVal.value) || _displayHeight;
|
|
16340
|
-
let canvas = document.createElement('canvas');
|
|
16341
|
-
canvas.width = Math.round(w * scale);
|
|
16342
|
-
canvas.height = Math.round(h * scale);
|
|
16343
|
-
let ctx = canvas.getContext('2d');
|
|
17682
|
+
let canvas = target.canvas;
|
|
17683
|
+
let ctx = target.ctx;
|
|
16344
17684
|
// The up-scaling is done on the CONTEXT, not through canvg's own
|
|
16345
17685
|
// scaleWidth/scaleHeight: canvg 4's scaling pipeline mis-scales (a
|
|
16346
17686
|
// requested 4x came out at 2.5x, leaving unpainted margins -- and any
|
|
@@ -16349,20 +17689,56 @@ function (root, d3, forester, phyloXml) {
|
|
|
16349
17689
|
// background before canvg draws a thing. ignoreDimensions keeps canvg
|
|
16350
17690
|
// from resizing the canvas to the SVG's size (which would erase both
|
|
16351
17691
|
// the transform and the fill); ignoreClear keeps it from clearing the
|
|
16352
|
-
// fill back to transparent.
|
|
16353
|
-
// falls outside the canvas and is cropped, so the export shows
|
|
16354
|
-
// 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()
|
|
16355
17693
|
// call drew synchronously, so toBlob could follow on the next line).
|
|
16356
17694
|
ctx.fillStyle = _state.backgroundColorForPrintExportDefault;
|
|
16357
17695
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
16358
|
-
ctx.scale(scale, scale);
|
|
16359
|
-
let v = window.Canvg.fromString(ctx,
|
|
16360
|
-
v.render({ignoreDimensions: true, ignoreClear: true}).then(function () {
|
|
16361
|
-
|
|
16362
|
-
|
|
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');
|
|
16363
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 ✓');
|
|
16364
17739
|
}, function (err) {
|
|
16365
|
-
console.error(ERROR + 'PNG
|
|
17740
|
+
console.error(ERROR + 'copying the PNG failed: ' + err);
|
|
17741
|
+
flashButton(btn, 'Copy failed');
|
|
16366
17742
|
});
|
|
16367
17743
|
}
|
|
16368
17744
|
|