archaeopteryx 3.22.0 → 3.23.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 +107 -6
- package/archaeopteryx.js +912 -15
- package/forester.js +222 -1
- package/package.json +2 -2
package/archaeopteryx.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
// v 3.
|
|
23
|
+
// v 3.23.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.23.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';
|
|
@@ -436,6 +436,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
436
436
|
const BRANCH_SCALE_CONTROLGROUP = 'branch_scale_g';
|
|
437
437
|
const BRANCH_SCALE_TIME_BUTTON = 'branch_scale_time_b';
|
|
438
438
|
const BRANCH_SCALE_DIV_BUTTON = 'branch_scale_div_b';
|
|
439
|
+
const CLOCK_PLOT_BUTTON = 'clock_plot_b';
|
|
440
|
+
const CLOCK_PLOT_DIALOG = 'aptx_clock_plot';
|
|
439
441
|
const ABOUT_DIALOG = 'aptx_about';
|
|
440
442
|
const TREE_PROPERTIES_DIALOG = 'aptx_tree_props';
|
|
441
443
|
const PROG_NAME = 'progname';
|
|
@@ -681,6 +683,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
681
683
|
let _timeInfo = null; // forester.timeAxisInfo, recomputed per render
|
|
682
684
|
let _timeTree = false; // forester.isTimeTree: never re-rooted
|
|
683
685
|
let _branchScaleAvailable = false; // the tree states time AND divergence, so the switch is offered
|
|
686
|
+
let _clockPlotKind = null; // forester.clockPlotKind: which clock plot the tree has, if any
|
|
684
687
|
let _clusterH = 0; // the cluster layout's vertical extent, set per render
|
|
685
688
|
let _docListenersBound = false; // page-level key/wheel handlers bind once, not per launch
|
|
686
689
|
let _docListeners = []; // ...and destroy() can take every one of them down again
|
|
@@ -1483,7 +1486,24 @@ function (root, d3, forester, phyloXml) {
|
|
|
1483
1486
|
return hsvToHex({h: hsv.h, s: Math.max(hsv.s, HOVER_GLOW_SAT_FLOOR), v: Math.max(hsv.v, HOVER_GLOW_VAL_FLOOR)});
|
|
1484
1487
|
}
|
|
1485
1488
|
|
|
1486
|
-
|
|
1489
|
+
// The glow's three discs, in `g`, in the colour of node d.
|
|
1490
|
+
function paintHoverGlow(g, d) {
|
|
1491
|
+
let dia = Math.max(HOVER_GLOW_MIN_DIA, (_state.nodeSizeDefault || 0) * 3);
|
|
1492
|
+
let fill = hoverGlowColor(d);
|
|
1493
|
+
HOVER_GLOW_RADII.forEach(function (radius, i) {
|
|
1494
|
+
g.append('circle')
|
|
1495
|
+
.attr('fill-opacity', HOVER_GLOW_ALPHAS[i])
|
|
1496
|
+
.attr('stroke', 'none')
|
|
1497
|
+
.attr('fill', fill)
|
|
1498
|
+
.attr('r', (dia * radius) / 2);
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// Lights node d. `also` lights more nodes with it: one point of the clock
|
|
1503
|
+
// plot can stand for several tips. They are drawn INSIDE the one glow
|
|
1504
|
+
// group, placed from d, so whatever hides, moves or strips the glow
|
|
1505
|
+
// (hideHoverGlow, an export) takes all of them.
|
|
1506
|
+
function showHoverGlow(d, also) {
|
|
1487
1507
|
if (!_svgGroup || !d || d.x === undefined) {
|
|
1488
1508
|
return;
|
|
1489
1509
|
}
|
|
@@ -1491,23 +1511,21 @@ function (root, d3, forester, phyloXml) {
|
|
|
1491
1511
|
if (g.empty()) {
|
|
1492
1512
|
g = _svgGroup.append('g').attr('class', 'aptx-hoverglow')
|
|
1493
1513
|
.style('pointer-events', 'none');
|
|
1494
|
-
HOVER_GLOW_RADII.forEach(function (unused, i) {
|
|
1495
|
-
g.append('circle')
|
|
1496
|
-
.attr('fill-opacity', HOVER_GLOW_ALPHAS[i])
|
|
1497
|
-
.attr('stroke', 'none');
|
|
1498
|
-
});
|
|
1499
1514
|
}
|
|
1500
|
-
|
|
1515
|
+
g.selectAll('*').remove();
|
|
1501
1516
|
let p = layoutPointXY(d);
|
|
1502
|
-
let fill = hoverGlowColor(d);
|
|
1503
1517
|
g.attr('transform', 'translate(' + p[0] + ',' + p[1] + ')')
|
|
1504
1518
|
.style('display', null)
|
|
1505
1519
|
.raise();
|
|
1506
|
-
g
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
return
|
|
1510
|
-
}
|
|
1520
|
+
paintHoverGlow(g, d);
|
|
1521
|
+
(also || []).forEach(function (n) {
|
|
1522
|
+
if (!n || n === d || n.x === undefined) {
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
let q = layoutPointXY(n);
|
|
1526
|
+
paintHoverGlow(g.append('g').attr('class', 'aptx-hoverglow-also')
|
|
1527
|
+
.attr('transform', 'translate(' + (q[0] - p[0]) + ',' + (q[1] - p[1]) + ')'), n);
|
|
1528
|
+
});
|
|
1511
1529
|
}
|
|
1512
1530
|
|
|
1513
1531
|
function hideHoverGlow() {
|
|
@@ -1518,6 +1536,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
1518
1536
|
|
|
1519
1537
|
function mouseover(event, d) {
|
|
1520
1538
|
showHoverGlow(d);
|
|
1539
|
+
markClockPlot(d);
|
|
1521
1540
|
// Start empty so the previous node's text cannot flash while this one
|
|
1522
1541
|
// fades in; mousemove fills it in immediately after. Clearing here
|
|
1523
1542
|
// rather than on the way out is what keeps the tooltip from collapsing
|
|
@@ -1831,6 +1850,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
1831
1850
|
|
|
1832
1851
|
function mouseout() {
|
|
1833
1852
|
hideHoverGlow();
|
|
1853
|
+
markClockPlot(null);
|
|
1834
1854
|
// Fade only. Emptying the tooltip here collapsed it to nothing but its
|
|
1835
1855
|
// padding and border -- a small rounded pill -- and THAT is what sat
|
|
1836
1856
|
// there fading out afterwards. The content is cleared on the next
|
|
@@ -3514,6 +3534,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
3514
3534
|
drawTimeOverlays();
|
|
3515
3535
|
rebuildOverview(); // measured AFTER the overlays, so the bbox is this frame's
|
|
3516
3536
|
updateSearchHitNavigation();
|
|
3537
|
+
renderClockPlot(false); // an open clock plot follows the view, the colours and the selection
|
|
3517
3538
|
noteViewChange();
|
|
3518
3539
|
}
|
|
3519
3540
|
|
|
@@ -6036,6 +6057,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
6036
6057
|
// it arrived showing. An Auspice build arrives in the time view; a
|
|
6037
6058
|
// BEAST or Newick file arrives stating its own branch lengths.
|
|
6038
6059
|
_branchScaleAvailable = _treeData ? forester.hasTimeAndDivergence(_treeData) : false;
|
|
6060
|
+
_clockPlotKind = _treeData ? forester.clockPlotKind(_treeData) : null;
|
|
6039
6061
|
_state.branchScale = _treeData ? forester.branchLengthScale(_treeData) : 'divergence';
|
|
6040
6062
|
if (_state.showScaleAxis === undefined) {
|
|
6041
6063
|
// on for a tree SHOWN in time; a dated tree that opens in
|
|
@@ -6479,6 +6501,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
6479
6501
|
document.querySelectorAll('dialog.aptx-dialog').forEach(function (dlg) {
|
|
6480
6502
|
dlg.remove();
|
|
6481
6503
|
});
|
|
6504
|
+
_clockPlot = null;
|
|
6505
|
+
_clockPlotKind = null;
|
|
6482
6506
|
if (_msaNav) {
|
|
6483
6507
|
_msaNav.remove();
|
|
6484
6508
|
_msaNav = null;
|
|
@@ -10605,6 +10629,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
10605
10629
|
if (scaleGroup) {
|
|
10606
10630
|
scaleGroup.style.display = _branchScaleAvailable ? '' : 'none';
|
|
10607
10631
|
}
|
|
10632
|
+
syncClockPlotControl();
|
|
10608
10633
|
}
|
|
10609
10634
|
|
|
10610
10635
|
// The tree was EDITED (a node deleted). Whether it has two layouts is asked
|
|
@@ -10623,6 +10648,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
10623
10648
|
forester.captureDivergence(_treeData);
|
|
10624
10649
|
}
|
|
10625
10650
|
_branchScaleAvailable = forester.hasTimeAndDivergence(_treeData);
|
|
10651
|
+
_clockPlotKind = forester.clockPlotKind(_treeData);
|
|
10626
10652
|
if (was && _branchScaleAvailable && _state.branchScale === 'divergence') {
|
|
10627
10653
|
forester.applyDivergenceBranchLengths(_treeData);
|
|
10628
10654
|
} else if (was) {
|
|
@@ -11000,6 +11026,833 @@ function (root, d3, forester, phyloXml) {
|
|
|
11000
11026
|
shell.dialog.showModal();
|
|
11001
11027
|
}
|
|
11002
11028
|
|
|
11029
|
+
// ===================== Clock plot =====================
|
|
11030
|
+
// The tips of the tree on view as points, each one's date against its
|
|
11031
|
+
// divergence from the root, with a least-squares line through them (and
|
|
11032
|
+
// the ancestors' points too, when asked for): the picture TempEst,
|
|
11033
|
+
// TreeTime and Auspice's clock view draw, here as a
|
|
11034
|
+
// panel beside the tree rather than a layout of it, so an outlier is seen
|
|
11035
|
+
// on the plot and in the tree at once (Christian, 2026-10-02). What is
|
|
11036
|
+
// plotted, which trees have a plot and what the line is fitted to are
|
|
11037
|
+
// forester.clockPlotData's; this is the drawing and the link:
|
|
11038
|
+
//
|
|
11039
|
+
// - a point wears its node's colour in the tree (found, selected, the
|
|
11040
|
+
// Color visualization, the file's own styles);
|
|
11041
|
+
// - pointing at one lights its node in the tree, and pointing at a node
|
|
11042
|
+
// in the tree rings its point;
|
|
11043
|
+
// - a click selects or deselects the node, a drag selects the tips in
|
|
11044
|
+
// the box -- the tree's own selection, so everything that reads it
|
|
11045
|
+
// (the labels, the downloads, the host page) sees it;
|
|
11046
|
+
// - tips drawn on one spot are one dot, and the dot answers for all of
|
|
11047
|
+
// them: named together, lit together, selected together.
|
|
11048
|
+
//
|
|
11049
|
+
// The plot follows the view: in a subtree it is the clade's, with the
|
|
11050
|
+
// clade's own line. Modeless, and it can be dragged by its title: the
|
|
11051
|
+
// point is to work in the tree with it open.
|
|
11052
|
+
const CLOCK_PLOT_WHAT = 'clock plot: each tip\'s date against its divergence from the root, with a'
|
|
11053
|
+
+ ' line through the tips (its slope is the rate); a panel linked to the tree';
|
|
11054
|
+
const CLOCK_PLOT_W = 440;
|
|
11055
|
+
const CLOCK_PLOT_H = 300;
|
|
11056
|
+
const CLOCK_PLOT_PAD = {top: 10, right: 14, bottom: 38, left: 58};
|
|
11057
|
+
const CLOCK_PLOT_HIT_PX = 7; // how near the pointer has to be to a point
|
|
11058
|
+
const CLOCK_PLOT_SAME_PX = 1; // points nearer each other than this are one dot on the screen
|
|
11059
|
+
const CLOCK_PLOT_NAMES_MAX = 5; // how many of a dot's tips the readout names
|
|
11060
|
+
const CLOCK_PLOT_DRAG_PX = 4; // a press that moves further than this is a box, not a click
|
|
11061
|
+
const CLOCK_PLOT_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
11062
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
11063
|
+
let _clockPlot = null; // the open panel, or null
|
|
11064
|
+
// What the two checkboxes said last, kept while the page lives. Tips only
|
|
11065
|
+
// to begin with (Christian, 2026-10-02): the line is theirs, and a deep
|
|
11066
|
+
// root crowds them into a corner (dengue: a root in 1319, tips from 1944).
|
|
11067
|
+
let _clockPlotOptions = {line: true, internal: false};
|
|
11068
|
+
|
|
11069
|
+
// The button is shown where the tree has a plot, and lit while the panel
|
|
11070
|
+
// is open. (A tree that loses its plot -- the tip that made it deleted --
|
|
11071
|
+
// loses the panel at its next render: see renderClockPlot.)
|
|
11072
|
+
function syncClockPlotControl() {
|
|
11073
|
+
let button = byId(CLOCK_PLOT_BUTTON);
|
|
11074
|
+
if (button) {
|
|
11075
|
+
button.style.display = _clockPlotKind ? '' : 'none';
|
|
11076
|
+
button.classList.toggle('aptx-lit', !!_clockPlot);
|
|
11077
|
+
}
|
|
11078
|
+
}
|
|
11079
|
+
|
|
11080
|
+
function toggleClockPlot() {
|
|
11081
|
+
if (_clockPlot) {
|
|
11082
|
+
closeClockPlot();
|
|
11083
|
+
} else {
|
|
11084
|
+
showClockPlot();
|
|
11085
|
+
}
|
|
11086
|
+
}
|
|
11087
|
+
|
|
11088
|
+
// Closes the panel NOW. A dialog's own `close` event arrives in a later
|
|
11089
|
+
// task -- much later in a tab the browser is throttling -- and while the
|
|
11090
|
+
// viewer still believed the panel open, a press of the button "closed"
|
|
11091
|
+
// the closed panel again and did nothing (seen on the live demo,
|
|
11092
|
+
// 2026-10-02). So nothing here waits for the event.
|
|
11093
|
+
function closeClockPlot() {
|
|
11094
|
+
let cp = _clockPlot;
|
|
11095
|
+
if (!cp) {
|
|
11096
|
+
return;
|
|
11097
|
+
}
|
|
11098
|
+
_clockPlot = null;
|
|
11099
|
+
hideHoverGlow();
|
|
11100
|
+
if (cp.dialog.open) {
|
|
11101
|
+
cp.dialog.close();
|
|
11102
|
+
}
|
|
11103
|
+
cp.dialog.remove();
|
|
11104
|
+
syncClockPlotControl();
|
|
11105
|
+
}
|
|
11106
|
+
|
|
11107
|
+
function clockSvg(parent, tag, cls) {
|
|
11108
|
+
let el = document.createElementNS(SVG_NS, tag);
|
|
11109
|
+
if (cls) {
|
|
11110
|
+
el.setAttribute('class', cls);
|
|
11111
|
+
}
|
|
11112
|
+
parent.appendChild(el);
|
|
11113
|
+
return el;
|
|
11114
|
+
}
|
|
11115
|
+
|
|
11116
|
+
function showClockPlot() {
|
|
11117
|
+
if (!_clockPlotKind || !_treeData) {
|
|
11118
|
+
return;
|
|
11119
|
+
}
|
|
11120
|
+
let shell = makeDialogShell(CLOCK_PLOT_DIALOG, 'Clock plot', CLOCK_PLOT_W + 26);
|
|
11121
|
+
let body = shell.body;
|
|
11122
|
+
body.classList.add('aptx-clock');
|
|
11123
|
+
|
|
11124
|
+
let svg = clockSvg(body, 'svg', 'aptx-clock-svg');
|
|
11125
|
+
svg.setAttribute('width', CLOCK_PLOT_W);
|
|
11126
|
+
svg.setAttribute('height', CLOCK_PLOT_H);
|
|
11127
|
+
svg.setAttribute('viewBox', '0 0 ' + CLOCK_PLOT_W + ' ' + CLOCK_PLOT_H);
|
|
11128
|
+
svg.setAttribute('role', 'img');
|
|
11129
|
+
svg.setAttribute('aria-label', 'Clock plot: each tip\'s date against its divergence from the root');
|
|
11130
|
+
let clip = clockSvg(clockSvg(svg, 'defs'), 'clipPath');
|
|
11131
|
+
clip.id = CLOCK_PLOT_DIALOG + '_clip';
|
|
11132
|
+
let clipRect = clockSvg(clip, 'rect');
|
|
11133
|
+
clipRect.setAttribute('x', CLOCK_PLOT_PAD.left);
|
|
11134
|
+
clipRect.setAttribute('y', CLOCK_PLOT_PAD.top);
|
|
11135
|
+
clipRect.setAttribute('width', CLOCK_PLOT_W - CLOCK_PLOT_PAD.left - CLOCK_PLOT_PAD.right);
|
|
11136
|
+
clipRect.setAttribute('height', CLOCK_PLOT_H - CLOCK_PLOT_PAD.top - CLOCK_PLOT_PAD.bottom);
|
|
11137
|
+
// an opaque ground under the points: the dialog is frosted glass, and
|
|
11138
|
+
// the tree's colours showing through would read as data
|
|
11139
|
+
let ground = clockSvg(svg, 'rect', 'aptx-clock-ground');
|
|
11140
|
+
['x', 'y', 'width', 'height'].forEach(function (a) {
|
|
11141
|
+
ground.setAttribute(a, clipRect.getAttribute(a));
|
|
11142
|
+
});
|
|
11143
|
+
let axes = clockSvg(svg, 'g', 'aptx-clock-axes');
|
|
11144
|
+
let inside = clockSvg(svg, 'g');
|
|
11145
|
+
inside.setAttribute('clip-path', 'url(#' + clip.id + ')');
|
|
11146
|
+
let internal = clockSvg(inside, 'g', 'aptx-clock-internal');
|
|
11147
|
+
let line = clockSvg(inside, 'line', 'aptx-clock-line');
|
|
11148
|
+
let tips = clockSvg(inside, 'g', 'aptx-clock-tips');
|
|
11149
|
+
let ring = clockSvg(svg, 'circle', 'aptx-clock-ring');
|
|
11150
|
+
ring.setAttribute('r', 6.5);
|
|
11151
|
+
ring.style.display = 'none';
|
|
11152
|
+
let box = clockSvg(svg, 'rect', 'aptx-clock-box');
|
|
11153
|
+
box.style.display = 'none';
|
|
11154
|
+
|
|
11155
|
+
let options = document.createElement('div');
|
|
11156
|
+
options.className = 'aptx-clock-options';
|
|
11157
|
+
body.appendChild(options);
|
|
11158
|
+
let check = function (label, key, title) {
|
|
11159
|
+
let row = document.createElement('label');
|
|
11160
|
+
row.className = 'aptx-reps-choice';
|
|
11161
|
+
row.title = title;
|
|
11162
|
+
let input = document.createElement('input');
|
|
11163
|
+
input.type = 'checkbox';
|
|
11164
|
+
input.checked = _clockPlotOptions[key];
|
|
11165
|
+
input.addEventListener('change', function () {
|
|
11166
|
+
_clockPlotOptions[key] = input.checked;
|
|
11167
|
+
renderClockPlot(true);
|
|
11168
|
+
});
|
|
11169
|
+
let span = document.createElement('span');
|
|
11170
|
+
span.textContent = label;
|
|
11171
|
+
row.appendChild(input);
|
|
11172
|
+
row.appendChild(span);
|
|
11173
|
+
options.appendChild(row);
|
|
11174
|
+
return input;
|
|
11175
|
+
};
|
|
11176
|
+
check('Regression line', 'line', 'to show/hide the least-squares line through the tips');
|
|
11177
|
+
check('Internal nodes', 'internal', 'to show/hide the internal nodes: their dates were inferred,'
|
|
11178
|
+
+ ' and they take no part in the line');
|
|
11179
|
+
let deselect = document.createElement('button');
|
|
11180
|
+
deselect.type = 'button';
|
|
11181
|
+
deselect.className = 'aptx-reps-button aptx-clock-deselect';
|
|
11182
|
+
deselect.textContent = 'Deselect all';
|
|
11183
|
+
deselect.title = 'deselect every selected node of the tree';
|
|
11184
|
+
deselect.addEventListener('click', function () {
|
|
11185
|
+
if (_selectedNodes.size > 0) {
|
|
11186
|
+
_selectedNodes = new Set();
|
|
11187
|
+
selectionChangedByClockPlot();
|
|
11188
|
+
}
|
|
11189
|
+
});
|
|
11190
|
+
options.appendChild(deselect);
|
|
11191
|
+
|
|
11192
|
+
let stats = document.createElement('div');
|
|
11193
|
+
stats.className = 'aptx-clock-stats';
|
|
11194
|
+
body.appendChild(stats);
|
|
11195
|
+
let note = document.createElement('p');
|
|
11196
|
+
note.className = 'aptx-shortcuts-note aptx-clock-note';
|
|
11197
|
+
body.appendChild(note);
|
|
11198
|
+
|
|
11199
|
+
_clockPlot = {dialog: shell.dialog, svg: svg, axes: axes, internal: internal, line: line, tips: tips,
|
|
11200
|
+
ring: ring, box: box, stats: stats, note: note, deselect: deselect,
|
|
11201
|
+
data: null, marks: [], seen: null, hover: null, lays: 0, x: null, y: null};
|
|
11202
|
+
// the shell's close control, and anything else that closes the dialog
|
|
11203
|
+
let closed = function () {
|
|
11204
|
+
if (_clockPlot && _clockPlot.dialog === shell.dialog) {
|
|
11205
|
+
closeClockPlot();
|
|
11206
|
+
}
|
|
11207
|
+
};
|
|
11208
|
+
shell.dialog.querySelector('.aptx-dialog-close').addEventListener('click', closed);
|
|
11209
|
+
shell.dialog.addEventListener('close', closed);
|
|
11210
|
+
bindClockPlotPointer(_clockPlot);
|
|
11211
|
+
// modeless, as the cheat sheet is: the tree stays in reach
|
|
11212
|
+
shell.dialog.show();
|
|
11213
|
+
renderClockPlot(true);
|
|
11214
|
+
placeClockPlot(shell.dialog);
|
|
11215
|
+
makeDialogDraggable(shell.dialog);
|
|
11216
|
+
syncClockPlotControl();
|
|
11217
|
+
}
|
|
11218
|
+
|
|
11219
|
+
// In the tree area's top right corner, clear of the control panel where
|
|
11220
|
+
// there is room, and inside the window.
|
|
11221
|
+
//
|
|
11222
|
+
// The page around the viewer may keep something of its own in that corner,
|
|
11223
|
+
// above everything -- the demo page's tree chooser sits there -- and the
|
|
11224
|
+
// panel opened under it, its close control out of reach (seen on the live
|
|
11225
|
+
// demo, 2026-10-02; no harness has a host page). So the panel's top edge is
|
|
11226
|
+
// asked what is on it, and the panel goes below whatever is not ours.
|
|
11227
|
+
function placeClockPlot(dialog) {
|
|
11228
|
+
let area = _container ? _container.getBoundingClientRect() : null;
|
|
11229
|
+
let w = dialog.offsetWidth;
|
|
11230
|
+
let h = dialog.offsetHeight;
|
|
11231
|
+
let right = area ? Math.min(area.right, window.innerWidth) : window.innerWidth;
|
|
11232
|
+
let top = area ? Math.max(area.top, 0) : 0;
|
|
11233
|
+
let lowest = Math.max(6, window.innerHeight - h - 6);
|
|
11234
|
+
let put = function (y) {
|
|
11235
|
+
setStyles(dialog, {
|
|
11236
|
+
'position': 'fixed',
|
|
11237
|
+
'margin': '0',
|
|
11238
|
+
'left': Math.max(6, right - w - 14) + 'px',
|
|
11239
|
+
'top': Math.max(6, Math.min(lowest, y)) + 'px'
|
|
11240
|
+
});
|
|
11241
|
+
};
|
|
11242
|
+
put(top + 14);
|
|
11243
|
+
let box = dialog.getBoundingClientRect();
|
|
11244
|
+
let under = 0;
|
|
11245
|
+
[box.left + 12, (box.left + box.right) / 2, box.right - 14].forEach(function (px) {
|
|
11246
|
+
let over = document.elementFromPoint(px, box.top + 14);
|
|
11247
|
+
if (!over || dialog.contains(over) || (_container && _container.contains(over))) {
|
|
11248
|
+
return;
|
|
11249
|
+
}
|
|
11250
|
+
// the whole of what is in the way, not the link or label inside it
|
|
11251
|
+
while (over.parentElement && over.parentElement !== document.body
|
|
11252
|
+
&& !over.parentElement.contains(dialog)) {
|
|
11253
|
+
over = over.parentElement;
|
|
11254
|
+
}
|
|
11255
|
+
under = Math.max(under, over.getBoundingClientRect().bottom);
|
|
11256
|
+
});
|
|
11257
|
+
if (under > 0) {
|
|
11258
|
+
put(under + 8);
|
|
11259
|
+
}
|
|
11260
|
+
}
|
|
11261
|
+
|
|
11262
|
+
// A modeless dialog moved by its title bar, kept inside the window.
|
|
11263
|
+
function makeDialogDraggable(dialog) {
|
|
11264
|
+
let bar = dialog.querySelector('.aptx-dialog-title');
|
|
11265
|
+
if (!bar) {
|
|
11266
|
+
return;
|
|
11267
|
+
}
|
|
11268
|
+
bar.classList.add('aptx-dialog-drag');
|
|
11269
|
+
bar.addEventListener('pointerdown', function (e) {
|
|
11270
|
+
if (e.button !== 0 || (e.target.closest && e.target.closest('button'))) {
|
|
11271
|
+
return;
|
|
11272
|
+
}
|
|
11273
|
+
let start = dialog.getBoundingClientRect();
|
|
11274
|
+
let dx = e.clientX - start.left;
|
|
11275
|
+
let dy = e.clientY - start.top;
|
|
11276
|
+
let move = function (ev) {
|
|
11277
|
+
setStyles(dialog, {
|
|
11278
|
+
'position': 'fixed',
|
|
11279
|
+
'margin': '0',
|
|
11280
|
+
'left': Math.max(0, Math.min(window.innerWidth - 40, ev.clientX - dx)) + 'px',
|
|
11281
|
+
'top': Math.max(0, Math.min(window.innerHeight - 30, ev.clientY - dy)) + 'px'
|
|
11282
|
+
});
|
|
11283
|
+
};
|
|
11284
|
+
let up = function () {
|
|
11285
|
+
document.removeEventListener('pointermove', move);
|
|
11286
|
+
document.removeEventListener('pointerup', up);
|
|
11287
|
+
document.removeEventListener('pointercancel', up);
|
|
11288
|
+
};
|
|
11289
|
+
document.addEventListener('pointermove', move);
|
|
11290
|
+
document.addEventListener('pointerup', up);
|
|
11291
|
+
document.addEventListener('pointercancel', up);
|
|
11292
|
+
e.preventDefault();
|
|
11293
|
+
});
|
|
11294
|
+
}
|
|
11295
|
+
|
|
11296
|
+
// A number of the plot at three significant digits; very small or very
|
|
11297
|
+
// large in exponent form, which is how a rate is usually written.
|
|
11298
|
+
function clockNumber(v, digits) {
|
|
11299
|
+
if (typeof v !== 'number' || !isFinite(v)) {
|
|
11300
|
+
return '';
|
|
11301
|
+
}
|
|
11302
|
+
let sig = digits || 3;
|
|
11303
|
+
let a = Math.abs(v);
|
|
11304
|
+
if (a !== 0 && (a < 1e-4 || a >= 1e7)) {
|
|
11305
|
+
return v.toExponential(sig - 1);
|
|
11306
|
+
}
|
|
11307
|
+
return String(Number(v.toPrecision(sig)));
|
|
11308
|
+
}
|
|
11309
|
+
|
|
11310
|
+
// A date of the plot: a calendar year to two decimals (about four days),
|
|
11311
|
+
// anything else as a number.
|
|
11312
|
+
function clockDate(v, calendar) {
|
|
11313
|
+
return calendar ? String(forester.roundNumber(v, 2)) : clockNumber(v, 5);
|
|
11314
|
+
}
|
|
11315
|
+
|
|
11316
|
+
function clockTicksX(from, to, calendar) {
|
|
11317
|
+
if (calendar) {
|
|
11318
|
+
if ((to - from) >= 3) {
|
|
11319
|
+
return forester.calendarTickYears(from, to).map(function (y) {
|
|
11320
|
+
return {value: y, label: String(y)};
|
|
11321
|
+
});
|
|
11322
|
+
}
|
|
11323
|
+
let months = forester.calendarTickMonths(from, to);
|
|
11324
|
+
if (months.length >= 2) {
|
|
11325
|
+
return months.map(function (m) {
|
|
11326
|
+
return {value: m.value, label: CLOCK_PLOT_MONTHS[m.month - 1] + ' ' + m.year};
|
|
11327
|
+
});
|
|
11328
|
+
}
|
|
11329
|
+
}
|
|
11330
|
+
return d3.ticks(from, to, 6).map(function (v) {
|
|
11331
|
+
return {value: v, label: String(Number(v.toPrecision(12)))};
|
|
11332
|
+
});
|
|
11333
|
+
}
|
|
11334
|
+
|
|
11335
|
+
// The colour a node wears in the tree, for its point; null where it wears
|
|
11336
|
+
// none, and the point takes the panel's ink.
|
|
11337
|
+
function clockPointColor(node, tip) {
|
|
11338
|
+
let found = getFoundColor(node);
|
|
11339
|
+
if (found || !tip) {
|
|
11340
|
+
return found;
|
|
11341
|
+
}
|
|
11342
|
+
let vis = _state.showVisualizations ? visualizationColorFor(node) : null;
|
|
11343
|
+
if (vis) {
|
|
11344
|
+
return vis;
|
|
11345
|
+
}
|
|
11346
|
+
let style = nodeStyle(node);
|
|
11347
|
+
if (style && (style.nodeColor || style.fontColor)) {
|
|
11348
|
+
return style.nodeColor || style.fontColor;
|
|
11349
|
+
}
|
|
11350
|
+
if (_state.useVisualStyles && node.color) {
|
|
11351
|
+
return 'rgb(' + node.color.red + ',' + node.color.green + ',' + node.color.blue + ')';
|
|
11352
|
+
}
|
|
11353
|
+
return null;
|
|
11354
|
+
}
|
|
11355
|
+
|
|
11356
|
+
/**
|
|
11357
|
+
* Draws the open clock plot for the tree on view. Called at the end of
|
|
11358
|
+
* every render, so it has to cost next to nothing when what it shows has
|
|
11359
|
+
* not changed. The points are worked out and laid down again only for
|
|
11360
|
+
* another tree, another clade, a tree on view with a different number of
|
|
11361
|
+
* nodes (one deleted) or a checkbox; any other render -- a zoom step, a
|
|
11362
|
+
* selection -- costs a count of the nodes and one pass over the points
|
|
11363
|
+
* for their colours, with a DOM write only where one changed.
|
|
11364
|
+
*
|
|
11365
|
+
* Until review (2026-10-02) the points were worked out afresh on every
|
|
11366
|
+
* render and only the DRAWING was skipped, while this comment claimed
|
|
11367
|
+
* the opposite: 5 ms a render on a 7,199-node build, where the count
|
|
11368
|
+
* takes 0.2.
|
|
11369
|
+
*
|
|
11370
|
+
* @param force work everything out and lay it down again
|
|
11371
|
+
*/
|
|
11372
|
+
function renderClockPlot(force) {
|
|
11373
|
+
let cp = _clockPlot;
|
|
11374
|
+
if (!cp) {
|
|
11375
|
+
return;
|
|
11376
|
+
}
|
|
11377
|
+
if (!cp.dialog.isConnected) { // the viewer was torn down under it
|
|
11378
|
+
_clockPlot = null;
|
|
11379
|
+
return;
|
|
11380
|
+
}
|
|
11381
|
+
let top = _treeData ? (topNode() || forester.getTreeRoot(_treeData)) : null;
|
|
11382
|
+
let count = 0;
|
|
11383
|
+
if (top) {
|
|
11384
|
+
forester.preOrderTraversalAll(top, function () {
|
|
11385
|
+
++count;
|
|
11386
|
+
});
|
|
11387
|
+
}
|
|
11388
|
+
let seen = cp.seen; // (another tree is another top: no need to ask for both)
|
|
11389
|
+
if (force || !seen || seen.top !== top || seen.count !== count) {
|
|
11390
|
+
let data = top ? forester.clockPlotData(_treeData, top) : null;
|
|
11391
|
+
if (!data) {
|
|
11392
|
+
closeClockPlot(); // the tree no longer has a plot (a tip deleted)
|
|
11393
|
+
return;
|
|
11394
|
+
}
|
|
11395
|
+
cp.seen = {top: top, count: count};
|
|
11396
|
+
cp.data = data;
|
|
11397
|
+
layClockPlot(cp, data);
|
|
11398
|
+
}
|
|
11399
|
+
colorClockPlot(cp);
|
|
11400
|
+
// The render that brought us here put the tree's glow out (the node
|
|
11401
|
+
// may have moved). The pointer is still on its point, so the node is
|
|
11402
|
+
// lit again where it now is: a click on a point redraws the tree, and
|
|
11403
|
+
// left the point ringed and its node dark until the pointer moved to
|
|
11404
|
+
// another point and back.
|
|
11405
|
+
if (cp.hover) {
|
|
11406
|
+
lightClockNodes(cp.hover);
|
|
11407
|
+
}
|
|
11408
|
+
}
|
|
11409
|
+
|
|
11410
|
+
function layClockPlot(cp, data) {
|
|
11411
|
+
let calendar = data.forward && !!_timeInfo && _timeInfo.type === 'calendar';
|
|
11412
|
+
let shown = data.points.filter(function (p) {
|
|
11413
|
+
return p.tip || _clockPlotOptions.internal;
|
|
11414
|
+
});
|
|
11415
|
+
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
|
|
11416
|
+
shown.forEach(function (p) {
|
|
11417
|
+
x0 = Math.min(x0, p.date);
|
|
11418
|
+
x1 = Math.max(x1, p.date);
|
|
11419
|
+
y0 = Math.min(y0, p.div);
|
|
11420
|
+
y1 = Math.max(y1, p.div);
|
|
11421
|
+
});
|
|
11422
|
+
let padX = ((x1 - x0) * 0.04) || 0.5;
|
|
11423
|
+
let padY = ((y1 - y0) * 0.06) || (Math.abs(y1) * 0.06) || 1e-6;
|
|
11424
|
+
let left = CLOCK_PLOT_PAD.left;
|
|
11425
|
+
let right = CLOCK_PLOT_W - CLOCK_PLOT_PAD.right;
|
|
11426
|
+
let top = CLOCK_PLOT_PAD.top;
|
|
11427
|
+
let bottom = CLOCK_PLOT_H - CLOCK_PLOT_PAD.bottom;
|
|
11428
|
+
// time runs left to right, whichever way its numbers do
|
|
11429
|
+
let x = d3.scaleLinear().domain([x0 - padX, x1 + padX]).range(data.forward ? [left, right] : [right, left]);
|
|
11430
|
+
let y = d3.scaleLinear().domain([y0 - padY, y1 + padY]).range([bottom, top]);
|
|
11431
|
+
cp.x = x;
|
|
11432
|
+
cp.y = y;
|
|
11433
|
+
|
|
11434
|
+
// --- the frame, the ticks and what each axis measures
|
|
11435
|
+
while (cp.axes.firstChild) {
|
|
11436
|
+
cp.axes.removeChild(cp.axes.firstChild);
|
|
11437
|
+
}
|
|
11438
|
+
let seg = function (cls, xa, ya, xb, yb) {
|
|
11439
|
+
let l = clockSvg(cp.axes, 'line', cls);
|
|
11440
|
+
l.setAttribute('x1', xa);
|
|
11441
|
+
l.setAttribute('y1', ya);
|
|
11442
|
+
l.setAttribute('x2', xb);
|
|
11443
|
+
l.setAttribute('y2', yb);
|
|
11444
|
+
};
|
|
11445
|
+
let text = function (cls, s, tx, ty, anchor) {
|
|
11446
|
+
let t = clockSvg(cp.axes, 'text', cls);
|
|
11447
|
+
t.setAttribute('x', tx);
|
|
11448
|
+
t.setAttribute('y', ty);
|
|
11449
|
+
t.setAttribute('text-anchor', anchor);
|
|
11450
|
+
t.textContent = s;
|
|
11451
|
+
return t;
|
|
11452
|
+
};
|
|
11453
|
+
clockTicksX(x.domain()[0], x.domain()[1], calendar).forEach(function (tick) {
|
|
11454
|
+
let px = x(tick.value);
|
|
11455
|
+
seg('aptx-clock-grid', px, top, px, bottom);
|
|
11456
|
+
text('aptx-clock-tick', tick.label, px, bottom + 13, 'middle');
|
|
11457
|
+
});
|
|
11458
|
+
d3.ticks(y.domain()[0], y.domain()[1], 5).forEach(function (v) {
|
|
11459
|
+
let py = y(v);
|
|
11460
|
+
seg('aptx-clock-grid', left, py, right, py);
|
|
11461
|
+
text('aptx-clock-tick', String(Number(v.toPrecision(12))), left - 6, py + 3.5, 'end');
|
|
11462
|
+
});
|
|
11463
|
+
seg('aptx-clock-frame', left, bottom, right, bottom);
|
|
11464
|
+
seg('aptx-clock-frame', left, top, left, bottom);
|
|
11465
|
+
let unit = data.unit && !calendar ? ' (' + data.unit + ')' : '';
|
|
11466
|
+
text('aptx-clock-title', (data.forward ? 'Date' : 'Age') + unit, (left + right) / 2, CLOCK_PLOT_H - 6, 'middle');
|
|
11467
|
+
let yt = text('aptx-clock-title', 'Divergence (subs/site)', 0, 0, 'middle');
|
|
11468
|
+
yt.setAttribute('transform', 'translate(12,' + ((top + bottom) / 2) + ') rotate(-90)');
|
|
11469
|
+
|
|
11470
|
+
// --- the points: ancestors under the line, tips over it
|
|
11471
|
+
cp.marks = [];
|
|
11472
|
+
[cp.internal, cp.tips].forEach(function (g) {
|
|
11473
|
+
while (g.firstChild) {
|
|
11474
|
+
g.removeChild(g.firstChild);
|
|
11475
|
+
}
|
|
11476
|
+
});
|
|
11477
|
+
shown.forEach(function (p) {
|
|
11478
|
+
let c = clockSvg(p.tip ? cp.tips : cp.internal, 'circle');
|
|
11479
|
+
c.setAttribute('cx', x(p.date));
|
|
11480
|
+
c.setAttribute('cy', y(p.div));
|
|
11481
|
+
c.setAttribute('r', p.tip ? 3 : 2);
|
|
11482
|
+
cp.marks.push({p: p, el: c, px: x(p.date), py: y(p.div), key: ''});
|
|
11483
|
+
});
|
|
11484
|
+
|
|
11485
|
+
// --- the line, across the frame (clipped to it)
|
|
11486
|
+
let fit = data.fit;
|
|
11487
|
+
if (fit && _clockPlotOptions.line) {
|
|
11488
|
+
let d = x.domain();
|
|
11489
|
+
cp.line.setAttribute('x1', x(d[0]));
|
|
11490
|
+
cp.line.setAttribute('y1', y(fit.intercept + (fit.slope * d[0])));
|
|
11491
|
+
cp.line.setAttribute('x2', x(d[1]));
|
|
11492
|
+
cp.line.setAttribute('y2', y(fit.intercept + (fit.slope * d[1])));
|
|
11493
|
+
cp.line.style.display = '';
|
|
11494
|
+
} else {
|
|
11495
|
+
cp.line.style.display = 'none';
|
|
11496
|
+
}
|
|
11497
|
+
// every point may have moved from under the pointer: what it is on
|
|
11498
|
+
// now is known at its next move
|
|
11499
|
+
cp.hover = null;
|
|
11500
|
+
clockRing(cp, null);
|
|
11501
|
+
|
|
11502
|
+
// --- what the line says
|
|
11503
|
+
while (cp.stats.firstChild) {
|
|
11504
|
+
cp.stats.removeChild(cp.stats.firstChild);
|
|
11505
|
+
}
|
|
11506
|
+
let tipCount = data.points.reduce(function (n, p) {
|
|
11507
|
+
return n + (p.tip ? 1 : 0);
|
|
11508
|
+
}, 0);
|
|
11509
|
+
let per = ' subs/site per ' + (calendar ? 'year' : (data.unit || 'unit of time'));
|
|
11510
|
+
if (fit) {
|
|
11511
|
+
treePropRow(cp.stats, 'Rate', clockNumber(fit.rate) + per);
|
|
11512
|
+
treePropRow(cp.stats, 'Root date, by the line', fit.rootDate === null
|
|
11513
|
+
? 'none: divergence does not rise with time' : clockDate(fit.rootDate, calendar));
|
|
11514
|
+
treePropRow(cp.stats, 'Root date, in the tree', clockDate(data.root.date, calendar));
|
|
11515
|
+
treePropRow(cp.stats, 'R²', fit.r2 === null ? 'none: every tip has one divergence' : clockNumber(fit.r2));
|
|
11516
|
+
} else {
|
|
11517
|
+
treePropRow(cp.stats, 'Line', 'none: it takes three tips, not all on one date');
|
|
11518
|
+
}
|
|
11519
|
+
treePropRow(cp.stats, 'Tips', countNumber(tipCount) + (_in_subtree ? ' (the clade on view)' : ''));
|
|
11520
|
+
cp.note.textContent = (data.fromRates
|
|
11521
|
+
? 'Divergence here is each branch’s time × its clock rate: the points show the model’s rates, not a measurement of their own. '
|
|
11522
|
+
: '')
|
|
11523
|
+
+ 'The line is fitted to the tips only. Tips share ancestry, so R² describes the fit and is no test.'
|
|
11524
|
+
+ ' Click a point to select its node; drag to select the tips in a box.';
|
|
11525
|
+
// what was laid down, for the browser harness: read here, where it is
|
|
11526
|
+
// computed, not off the DOM
|
|
11527
|
+
cp.dialog._aptxClockPlot = {data: data, shown: shown.length, calendar: calendar, lays: ++cp.lays};
|
|
11528
|
+
}
|
|
11529
|
+
|
|
11530
|
+
// One pass: a point whose colour or highlight differs from what it wears
|
|
11531
|
+
// is repainted, and a highlighted one is lifted over its neighbours.
|
|
11532
|
+
function colorClockPlot(cp) {
|
|
11533
|
+
for (let i = 0; i < cp.marks.length; ++i) {
|
|
11534
|
+
let m = cp.marks[i];
|
|
11535
|
+
let hit = getFoundColor(m.p.node) !== null;
|
|
11536
|
+
let color = clockPointColor(m.p.node, m.p.tip);
|
|
11537
|
+
let key = (color || '') + (hit ? '*' : '');
|
|
11538
|
+
if (key === m.key) {
|
|
11539
|
+
continue;
|
|
11540
|
+
}
|
|
11541
|
+
m.key = key;
|
|
11542
|
+
m.el.style.fill = color || '';
|
|
11543
|
+
m.el.style.stroke = hit ? makeFoundOutlineColor(m.p.node) : '';
|
|
11544
|
+
m.el.setAttribute('r', hit ? 4 : (m.p.tip ? 3 : 2));
|
|
11545
|
+
m.el.setAttribute('class', hit ? 'aptx-clock-hit' : '');
|
|
11546
|
+
if (hit) {
|
|
11547
|
+
m.el.parentNode.appendChild(m.el);
|
|
11548
|
+
}
|
|
11549
|
+
}
|
|
11550
|
+
cp.deselect.disabled = _selectedNodes.size === 0;
|
|
11551
|
+
}
|
|
11552
|
+
|
|
11553
|
+
// The node a point's node is seen AS in the tree: itself, or the
|
|
11554
|
+
// collapsed clade that holds it (the highest one, which is the one drawn).
|
|
11555
|
+
function clockTreeNode(node) {
|
|
11556
|
+
let seen = node;
|
|
11557
|
+
let top = topNode();
|
|
11558
|
+
for (let n = node.parent; n && n !== _root; n = n.parent) {
|
|
11559
|
+
if (isCollapsed(n)) {
|
|
11560
|
+
seen = n;
|
|
11561
|
+
}
|
|
11562
|
+
if (n === top) {
|
|
11563
|
+
break;
|
|
11564
|
+
}
|
|
11565
|
+
}
|
|
11566
|
+
return seen;
|
|
11567
|
+
}
|
|
11568
|
+
|
|
11569
|
+
/**
|
|
11570
|
+
* The points under the pointer: the nearest one within reach, and every
|
|
11571
|
+
* other of its kind drawn on the same spot. Tips with one date and one
|
|
11572
|
+
* divergence -- identical sequences sampled on one day -- are one dot on
|
|
11573
|
+
* the screen (778 of the 2,985 tips of a measles build share theirs with
|
|
11574
|
+
* another), and a dot that answered for only the first of them left the
|
|
11575
|
+
* rest beyond pointing, naming or deselecting (code review; Christian,
|
|
11576
|
+
* 2026-10-02: the dot stands for all of them). The SAME SPOT, not
|
|
11577
|
+
* everything within reach: in a dense cloud that is hundreds of tips,
|
|
11578
|
+
* and a click meant for one dot would take them all.
|
|
11579
|
+
*
|
|
11580
|
+
* @returns {Array} the marks, in the tree's own order, top to bottom;
|
|
11581
|
+
* empty where the pointer is on none
|
|
11582
|
+
*/
|
|
11583
|
+
function clockMarksAt(cp, px, py) {
|
|
11584
|
+
let best = null;
|
|
11585
|
+
let bestD = CLOCK_PLOT_HIT_PX * CLOCK_PLOT_HIT_PX;
|
|
11586
|
+
for (let i = 0; i < cp.marks.length; ++i) {
|
|
11587
|
+
let m = cp.marks[i];
|
|
11588
|
+
let dx = m.px - px;
|
|
11589
|
+
let dy = m.py - py;
|
|
11590
|
+
let d = (dx * dx) + (dy * dy);
|
|
11591
|
+
// a tip wins over an ancestor at the same distance: it is the datum
|
|
11592
|
+
if (d < bestD || (d === bestD && best && m.p.tip && !best.p.tip)) {
|
|
11593
|
+
best = m;
|
|
11594
|
+
bestD = d;
|
|
11595
|
+
}
|
|
11596
|
+
}
|
|
11597
|
+
if (!best) {
|
|
11598
|
+
return [];
|
|
11599
|
+
}
|
|
11600
|
+
// (the points were laid down from a walk that takes the last child
|
|
11601
|
+
// first, so the tree's order is theirs backwards)
|
|
11602
|
+
return cp.marks.filter(function (m) {
|
|
11603
|
+
return m.p.tip === best.p.tip && Math.abs(m.px - best.px) < CLOCK_PLOT_SAME_PX
|
|
11604
|
+
&& Math.abs(m.py - best.py) < CLOCK_PLOT_SAME_PX;
|
|
11605
|
+
}).reverse();
|
|
11606
|
+
}
|
|
11607
|
+
|
|
11608
|
+
function sameClockMarks(a, b) {
|
|
11609
|
+
if (!a || !b || a.length !== b.length) {
|
|
11610
|
+
return a === b;
|
|
11611
|
+
}
|
|
11612
|
+
return a.every(function (m, i) {
|
|
11613
|
+
return m === b[i];
|
|
11614
|
+
});
|
|
11615
|
+
}
|
|
11616
|
+
|
|
11617
|
+
// Lights, in the tree, the nodes of the points under the pointer: each as
|
|
11618
|
+
// the tree shows it (clockTreeNode), each once.
|
|
11619
|
+
function lightClockNodes(marks) {
|
|
11620
|
+
let nodes = [];
|
|
11621
|
+
marks.forEach(function (m) {
|
|
11622
|
+
let n = clockTreeNode(m.p.node);
|
|
11623
|
+
if (nodes.indexOf(n) < 0) {
|
|
11624
|
+
nodes.push(n);
|
|
11625
|
+
}
|
|
11626
|
+
});
|
|
11627
|
+
showHoverGlow(nodes[0], nodes.slice(1));
|
|
11628
|
+
}
|
|
11629
|
+
|
|
11630
|
+
function clockRing(cp, mark) {
|
|
11631
|
+
if (!mark) {
|
|
11632
|
+
cp.ring.style.display = 'none';
|
|
11633
|
+
return;
|
|
11634
|
+
}
|
|
11635
|
+
cp.ring.setAttribute('cx', mark.px);
|
|
11636
|
+
cp.ring.setAttribute('cy', mark.py);
|
|
11637
|
+
cp.ring.style.display = '';
|
|
11638
|
+
}
|
|
11639
|
+
|
|
11640
|
+
// A node pointed at IN THE TREE rings its point, where it has one.
|
|
11641
|
+
function markClockPlot(node) {
|
|
11642
|
+
let cp = _clockPlot;
|
|
11643
|
+
if (!cp) {
|
|
11644
|
+
return;
|
|
11645
|
+
}
|
|
11646
|
+
let mark = null;
|
|
11647
|
+
if (node) {
|
|
11648
|
+
for (let i = 0; i < cp.marks.length; ++i) {
|
|
11649
|
+
if (cp.marks[i].p.node === node) {
|
|
11650
|
+
mark = cp.marks[i];
|
|
11651
|
+
break;
|
|
11652
|
+
}
|
|
11653
|
+
}
|
|
11654
|
+
}
|
|
11655
|
+
clockRing(cp, mark);
|
|
11656
|
+
}
|
|
11657
|
+
|
|
11658
|
+
function clockSigned(v) {
|
|
11659
|
+
return (v > 0 ? '+' : '') + clockNumber(v, 3);
|
|
11660
|
+
}
|
|
11661
|
+
|
|
11662
|
+
// One value where the points agree, "least - greatest" where they do not
|
|
11663
|
+
// (points a fraction of a pixel apart are one dot and may differ a hair).
|
|
11664
|
+
function clockSpan(values, format) {
|
|
11665
|
+
let lo = Math.min.apply(null, values);
|
|
11666
|
+
let hi = Math.max.apply(null, values);
|
|
11667
|
+
return lo === hi ? format(lo) : (format(lo) + ' \u2013 ' + format(hi));
|
|
11668
|
+
}
|
|
11669
|
+
|
|
11670
|
+
// What the readout says of the points under the pointer: of one, what it
|
|
11671
|
+
// is; of several on one spot, how many, the names of the first few, and
|
|
11672
|
+
// what they share.
|
|
11673
|
+
function clockPointText(marks) {
|
|
11674
|
+
let p = marks[0].p;
|
|
11675
|
+
if (marks.length === 1) {
|
|
11676
|
+
let text = (p.node.name ? 'Name: ' + p.node.name : (p.tip ? 'Tip' : 'Internal node')) + '<br>';
|
|
11677
|
+
let date = dateText(p.node.date);
|
|
11678
|
+
if (date) {
|
|
11679
|
+
text += 'Date: ' + date + '<br>';
|
|
11680
|
+
}
|
|
11681
|
+
text += 'Divergence: ' + clockNumber(p.div, 4) + '<br>';
|
|
11682
|
+
if (typeof p.residual === 'number') {
|
|
11683
|
+
text += 'Off the line: ' + clockSigned(p.residual) + '<br>';
|
|
11684
|
+
}
|
|
11685
|
+
if (!p.tip) {
|
|
11686
|
+
text += 'Tips below: ' + forester.getAllExternalNodes(p.node).length + '<br>';
|
|
11687
|
+
}
|
|
11688
|
+
return text;
|
|
11689
|
+
}
|
|
11690
|
+
let text = marks.length + (p.tip ? ' tips here' : ' internal nodes here') + '<br>';
|
|
11691
|
+
let named = marks.filter(function (m) {
|
|
11692
|
+
return !!m.p.node.name;
|
|
11693
|
+
});
|
|
11694
|
+
named.slice(0, CLOCK_PLOT_NAMES_MAX).forEach(function (m) {
|
|
11695
|
+
text += 'Name: ' + m.p.node.name + '<br>';
|
|
11696
|
+
});
|
|
11697
|
+
if (named.length > CLOCK_PLOT_NAMES_MAX) {
|
|
11698
|
+
text += 'Name: \u2026 and ' + (named.length - CLOCK_PLOT_NAMES_MAX) + ' more<br>';
|
|
11699
|
+
}
|
|
11700
|
+
let unit = (p.node.date && p.node.date.unit) ? ' ' + p.node.date.unit : '';
|
|
11701
|
+
text += 'Date: ' + clockSpan(marks.map(function (m) { return m.p.date; }), String) + unit + '<br>';
|
|
11702
|
+
text += 'Divergence: ' + clockSpan(marks.map(function (m) { return m.p.div; }), function (v) {
|
|
11703
|
+
return clockNumber(v, 4);
|
|
11704
|
+
}) + '<br>';
|
|
11705
|
+
if (marks.every(function (m) { return typeof m.p.residual === 'number'; })) {
|
|
11706
|
+
text += 'Off the line: ' + clockSpan(marks.map(function (m) { return m.p.residual; }), clockSigned) + '<br>';
|
|
11707
|
+
}
|
|
11708
|
+
return text;
|
|
11709
|
+
}
|
|
11710
|
+
|
|
11711
|
+
function selectionChangedByClockPlot() {
|
|
11712
|
+
update(null, 0, true);
|
|
11713
|
+
document.dispatchEvent(new Event('selected_nodes_changed_event'));
|
|
11714
|
+
}
|
|
11715
|
+
|
|
11716
|
+
function bindClockPlotPointer(cp) {
|
|
11717
|
+
let svg = cp.svg;
|
|
11718
|
+
let press = null; // {x, y, boxed} while the button is down
|
|
11719
|
+
let at = function (e) {
|
|
11720
|
+
let r = svg.getBoundingClientRect();
|
|
11721
|
+
return [(e.clientX - r.left) * (CLOCK_PLOT_W / (r.width || CLOCK_PLOT_W)),
|
|
11722
|
+
(e.clientY - r.top) * (CLOCK_PLOT_H / (r.height || CLOCK_PLOT_H))];
|
|
11723
|
+
};
|
|
11724
|
+
let readout = function (e, marks) {
|
|
11725
|
+
if (!_node_mouseover_div) {
|
|
11726
|
+
return;
|
|
11727
|
+
}
|
|
11728
|
+
if (!marks) {
|
|
11729
|
+
_node_mouseover_div.interrupt().style('opacity', 1e-6);
|
|
11730
|
+
return;
|
|
11731
|
+
}
|
|
11732
|
+
let tip = _node_mouseover_div.node();
|
|
11733
|
+
tip.classList.remove('aptx-light', 'aptx-dark');
|
|
11734
|
+
if (_panelTheme) {
|
|
11735
|
+
tip.classList.add('aptx-' + _panelTheme);
|
|
11736
|
+
}
|
|
11737
|
+
_node_mouseover_div.html(markUpDataLabels(escapeHtmlKeepBreaks(clockPointText(marks))));
|
|
11738
|
+
_node_mouseover_div.interrupt().style('opacity', 0.95);
|
|
11739
|
+
placeHoverReadout(tip, e);
|
|
11740
|
+
};
|
|
11741
|
+
// cp.hover: the points the pointer is on (null for none), kept for the
|
|
11742
|
+
// renders in between
|
|
11743
|
+
let point = function (e) {
|
|
11744
|
+
let xy = at(e);
|
|
11745
|
+
let found = clockMarksAt(cp, xy[0], xy[1]);
|
|
11746
|
+
let marks = found.length > 0 ? found : null;
|
|
11747
|
+
if (!sameClockMarks(marks, cp.hover)) {
|
|
11748
|
+
cp.hover = marks;
|
|
11749
|
+
clockRing(cp, marks ? marks[0] : null);
|
|
11750
|
+
if (marks) {
|
|
11751
|
+
lightClockNodes(marks);
|
|
11752
|
+
} else {
|
|
11753
|
+
hideHoverGlow();
|
|
11754
|
+
}
|
|
11755
|
+
svg.style.cursor = marks ? 'pointer' : '';
|
|
11756
|
+
}
|
|
11757
|
+
readout(e, marks);
|
|
11758
|
+
};
|
|
11759
|
+
svg.addEventListener('pointerdown', function (e) {
|
|
11760
|
+
if (e.button !== 0) {
|
|
11761
|
+
return;
|
|
11762
|
+
}
|
|
11763
|
+
let xy = at(e);
|
|
11764
|
+
press = {x: xy[0], y: xy[1], boxed: false};
|
|
11765
|
+
try {
|
|
11766
|
+
svg.setPointerCapture(e.pointerId); // the box follows the pointer out of the drawing
|
|
11767
|
+
} catch {
|
|
11768
|
+
// no pointer capture available; a release outside is then missed
|
|
11769
|
+
}
|
|
11770
|
+
e.preventDefault();
|
|
11771
|
+
});
|
|
11772
|
+
svg.addEventListener('pointermove', function (e) {
|
|
11773
|
+
if (!press) {
|
|
11774
|
+
point(e);
|
|
11775
|
+
return;
|
|
11776
|
+
}
|
|
11777
|
+
let xy = at(e);
|
|
11778
|
+
if (!press.boxed && Math.hypot(xy[0] - press.x, xy[1] - press.y) > CLOCK_PLOT_DRAG_PX) {
|
|
11779
|
+
press.boxed = true;
|
|
11780
|
+
readout(e, null);
|
|
11781
|
+
clockRing(cp, null);
|
|
11782
|
+
hideHoverGlow();
|
|
11783
|
+
cp.hover = null;
|
|
11784
|
+
}
|
|
11785
|
+
if (press.boxed) {
|
|
11786
|
+
cp.box.setAttribute('x', Math.min(press.x, xy[0]));
|
|
11787
|
+
cp.box.setAttribute('y', Math.min(press.y, xy[1]));
|
|
11788
|
+
cp.box.setAttribute('width', Math.abs(xy[0] - press.x));
|
|
11789
|
+
cp.box.setAttribute('height', Math.abs(xy[1] - press.y));
|
|
11790
|
+
cp.box.style.display = '';
|
|
11791
|
+
}
|
|
11792
|
+
});
|
|
11793
|
+
let release = function (e, cancelled) {
|
|
11794
|
+
if (!press) {
|
|
11795
|
+
return;
|
|
11796
|
+
}
|
|
11797
|
+
let was = press;
|
|
11798
|
+
press = null;
|
|
11799
|
+
cp.box.style.display = 'none';
|
|
11800
|
+
if (cancelled) {
|
|
11801
|
+
return;
|
|
11802
|
+
}
|
|
11803
|
+
let xy = at(e);
|
|
11804
|
+
if (was.boxed) {
|
|
11805
|
+
// the tips in the box join the selection; an ancestor is
|
|
11806
|
+
// selected by a click, where it is one node and meant
|
|
11807
|
+
let xa = Math.min(was.x, xy[0]), xb = Math.max(was.x, xy[0]);
|
|
11808
|
+
let ya = Math.min(was.y, xy[1]), yb = Math.max(was.y, xy[1]);
|
|
11809
|
+
let added = 0;
|
|
11810
|
+
cp.marks.forEach(function (m) {
|
|
11811
|
+
if (m.p.tip && m.px >= xa && m.px <= xb && m.py >= ya && m.py <= yb
|
|
11812
|
+
&& !_selectedNodes.has(m.p.node)) {
|
|
11813
|
+
_selectedNodes.add(m.p.node);
|
|
11814
|
+
++added;
|
|
11815
|
+
}
|
|
11816
|
+
});
|
|
11817
|
+
if (added > 0) {
|
|
11818
|
+
selectionChangedByClockPlot();
|
|
11819
|
+
}
|
|
11820
|
+
return;
|
|
11821
|
+
}
|
|
11822
|
+
// a click selects every node of the dot; where all of them are
|
|
11823
|
+
// selected already, it deselects them
|
|
11824
|
+
let marks = clockMarksAt(cp, xy[0], xy[1]);
|
|
11825
|
+
if (marks.length > 0) {
|
|
11826
|
+
let all = marks.every(function (m) {
|
|
11827
|
+
return _selectedNodes.has(m.p.node);
|
|
11828
|
+
});
|
|
11829
|
+
marks.forEach(function (m) {
|
|
11830
|
+
if (all) {
|
|
11831
|
+
_selectedNodes.delete(m.p.node);
|
|
11832
|
+
} else {
|
|
11833
|
+
_selectedNodes.add(m.p.node);
|
|
11834
|
+
}
|
|
11835
|
+
});
|
|
11836
|
+
selectionChangedByClockPlot();
|
|
11837
|
+
}
|
|
11838
|
+
};
|
|
11839
|
+
svg.addEventListener('pointerup', function (e) {
|
|
11840
|
+
release(e, false);
|
|
11841
|
+
});
|
|
11842
|
+
svg.addEventListener('pointercancel', function (e) {
|
|
11843
|
+
release(e, true);
|
|
11844
|
+
});
|
|
11845
|
+
svg.addEventListener('pointerleave', function (e) {
|
|
11846
|
+
if (!press) {
|
|
11847
|
+
cp.hover = null;
|
|
11848
|
+
clockRing(cp, null);
|
|
11849
|
+
hideHoverGlow();
|
|
11850
|
+
readout(e, null);
|
|
11851
|
+
svg.style.cursor = '';
|
|
11852
|
+
}
|
|
11853
|
+
});
|
|
11854
|
+
}
|
|
11855
|
+
|
|
11003
11856
|
// ===================== Protein domain architectures =====================
|
|
11004
11857
|
// The desktop's domain display, ported from its RenderableDomainArchitecture
|
|
11005
11858
|
// and TreePanel (the spec is section D1 of the repo's TODO.md). Each tip's
|
|
@@ -15256,6 +16109,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
15256
16109
|
for (let i = 0; i < btns.length; ++i) {
|
|
15257
16110
|
btns[i].innerHTML = panelThemeIcon(); // a drawn glyph, not a text character
|
|
15258
16111
|
}
|
|
16112
|
+
// a modeless dialog (the cheat sheet, the clock plot) stays open across
|
|
16113
|
+
// the switch, and took the theme only when it was made
|
|
16114
|
+
document.querySelectorAll('dialog.aptx-dialog').forEach(function (dlg) {
|
|
16115
|
+
dlg.classList.remove('aptx-light', 'aptx-dark');
|
|
16116
|
+
if (_panelTheme === 'light' || _panelTheme === 'dark') {
|
|
16117
|
+
dlg.classList.add('aptx-' + _panelTheme);
|
|
16118
|
+
}
|
|
16119
|
+
});
|
|
15259
16120
|
applyTreeTheme();
|
|
15260
16121
|
}
|
|
15261
16122
|
|
|
@@ -16002,6 +16863,29 @@ function (root, d3, forester, phyloXml) {
|
|
|
16002
16863
|
+ ' color:var(--p-accent-ink); }'
|
|
16003
16864
|
+ '.aptx-reps-primary { font-weight:600; }'
|
|
16004
16865
|
+ '.aptx-reps-button:focus-visible { outline:none; box-shadow:0 0 0 3px var(--p-accent-weak); }'
|
|
16866
|
+
+ '.aptx-reps-button:disabled { opacity:0.4; cursor:default; pointer-events:none; }'
|
|
16867
|
+
// the clock plot: a drawing in the panel's own inks, so it turns
|
|
16868
|
+
// with the theme; a point's colour, where it has one, is the
|
|
16869
|
+
// node's in the tree and is set on the point itself
|
|
16870
|
+
+ '.aptx-dialog-drag { cursor:move; touch-action:none; user-select:none; -webkit-user-select:none; }'
|
|
16871
|
+
+ '.aptx-clock.aptx-dialog-body { overflow:visible; }'
|
|
16872
|
+
+ '.aptx-clock-svg { display:block; touch-action:none; user-select:none; -webkit-user-select:none; }'
|
|
16873
|
+
+ '.aptx-clock-svg text { font-size:10px; fill:var(--p-muted); font-variant-numeric:tabular-nums; }'
|
|
16874
|
+
+ '.aptx-clock-svg text.aptx-clock-title { font-size:10.5px; fill:var(--p-ink); }'
|
|
16875
|
+
+ '.aptx-clock-ground { fill:var(--p-surface2); }'
|
|
16876
|
+
+ '.aptx-clock-grid { stroke:var(--p-line); stroke-width:1; shape-rendering:crispEdges; }'
|
|
16877
|
+
+ '.aptx-clock-frame { stroke:var(--p-line-strong); stroke-width:1; shape-rendering:crispEdges; }'
|
|
16878
|
+
+ '.aptx-clock-line { stroke:var(--p-accent); stroke-width:1.6; }'
|
|
16879
|
+
+ '.aptx-clock-internal circle { fill:var(--p-faint); fill-opacity:0.55; }'
|
|
16880
|
+
+ '.aptx-clock-tips circle { fill:var(--p-ink); fill-opacity:0.8; }'
|
|
16881
|
+
+ '.aptx-clock-svg circle.aptx-clock-hit { fill-opacity:1; stroke-width:1.2; }'
|
|
16882
|
+
+ '.aptx-clock-ring { fill:none; stroke:var(--p-accent); stroke-width:1.8; pointer-events:none; }'
|
|
16883
|
+
+ '.aptx-clock-box { fill:var(--p-accent-weak); stroke:var(--p-accent); stroke-width:1;'
|
|
16884
|
+
+ ' shape-rendering:crispEdges; pointer-events:none; }'
|
|
16885
|
+
+ '.aptx-clock-options { display:flex; align-items:center; gap:14px; margin:6px 0 8px; }'
|
|
16886
|
+
+ '.aptx-clock-options .aptx-clock-deselect { margin-left:auto; height:22px; padding:0 9px; }'
|
|
16887
|
+
+ '.aptx-clock-stats .aptx-dialog-key { flex-basis:38%; }'
|
|
16888
|
+
+ '.aptx-clock-note { margin-top:8px; }'
|
|
16005
16889
|
+ '.aptx-dialog-mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Courier New",monospace;'
|
|
16006
16890
|
+ ' font-size:11px; white-space:pre-wrap; overflow-wrap:anywhere; }'
|
|
16007
16891
|
// The hover tooltip, on the same palette.
|
|
@@ -16111,6 +16995,12 @@ function (root, d3, forester, phyloXml) {
|
|
|
16111
16995
|
+ '.aptx-panel .aptx-seg:hover { color:var(--p-accent-ink); background:var(--p-accent-weak); }'
|
|
16112
16996
|
+ '.aptx-panel .aptx-seg:has(> input:checked) { background:var(--p-accent); color:#fff; }'
|
|
16113
16997
|
+ '.aptx-panel .aptx-seg:has(> input:disabled) { opacity:0.4; cursor:default; }'
|
|
16998
|
+
// the Clock plot button sits in the mode bar beside the segments
|
|
16999
|
+
// and matches their height; lit while its panel is open
|
|
17000
|
+
+ '.aptx-panel .aptx-modebar input[type=button].aptx-clockbtn { height:auto; margin:0; padding:3px 8px;'
|
|
17001
|
+
+ ' font-weight:600; color:var(--p-muted); border-radius:7px; }'
|
|
17002
|
+
+ '.aptx-panel .aptx-modebar input[type=button].aptx-clockbtn.aptx-lit { background:var(--p-accent);'
|
|
17003
|
+
+ ' border-color:var(--p-accent); color:#fff; }'
|
|
16114
17004
|
+ '.aptx-panel .aptx-actions { margin-left:auto; display:flex; align-items:center; gap:5px; }'
|
|
16115
17005
|
+ '.aptx-panel .aptx-theme-btn, .aptx-panel .aptx-info-btn { flex:none; width:20px; height:20px; display:grid; place-items:center; padding:0; border:1px solid var(--p-line-strong); border-radius:6px; background:var(--p-surface2); color:var(--p-muted); cursor:pointer; font-size:12px; line-height:1; }'
|
|
16116
17006
|
+ '.aptx-panel .aptx-theme-btn:hover { background:var(--p-accent-weak); color:var(--p-accent-ink); border-color:var(--p-accent); }'
|
|
@@ -17517,6 +18407,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
17517
18407
|
on(LAYOUT_RECT_BUTTON, 'click', layoutButtonClicked);
|
|
17518
18408
|
on(BRANCH_SCALE_TIME_BUTTON, 'click', branchScaleButtonClicked);
|
|
17519
18409
|
on(BRANCH_SCALE_DIV_BUTTON, 'click', branchScaleButtonClicked);
|
|
18410
|
+
on(CLOCK_PLOT_BUTTON, 'click', toggleClockPlot);
|
|
17520
18411
|
|
|
17521
18412
|
on(LAYOUT_CIRC_BUTTON, 'click', layoutButtonClicked);
|
|
17522
18413
|
on(LAYOUT_UNROOTED_BUTTON, 'click', layoutButtonClicked);
|
|
@@ -17960,6 +18851,12 @@ function (root, d3, forester, phyloXml) {
|
|
|
17960
18851
|
h = h.concat(makeSegment('Div', BRANCH_SCALE_DIV_BUTTON, 'branch_scale_radio',
|
|
17961
18852
|
'lay the tree out by DIVERGENCE (substitutions per site: as the tree records it, or on a BEAST tree each branch\'s time x its clock rate)'));
|
|
17962
18853
|
h = h.concat('</div>');
|
|
18854
|
+
// The clock plot, where the tree has one (forester.clockPlotKind).
|
|
18855
|
+
// Beside the switch because it draws the same two quantities
|
|
18856
|
+
// against each other; a button of its own because it opens a
|
|
18857
|
+
// panel and leaves the layout alone.
|
|
18858
|
+
h = h.concat('<input type="button" class="aptx-clockbtn" value="Clock plot" name="' + CLOCK_PLOT_BUTTON
|
|
18859
|
+
+ '" id="' + CLOCK_PLOT_BUTTON + '" title="' + CLOCK_PLOT_WHAT + '">');
|
|
17963
18860
|
h = h.concat('</div>');
|
|
17964
18861
|
h = h.concat('</fieldset>');
|
|
17965
18862
|
return h;
|