archaeopteryx 3.5.0 → 3.6.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 +12 -7
- package/archaeopteryx.js +922 -416
- package/forester.js +37 -3
- package/package.json +1 -1
package/archaeopteryx.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
// v 3.
|
|
23
|
+
// v 3.6.0
|
|
24
24
|
// 2026-09-10
|
|
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.6.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';
|
|
@@ -283,16 +283,24 @@ function (root, d3, forester, phyloXml) {
|
|
|
283
283
|
const SVG_EXPORT_FORMAT = 'SVG';
|
|
284
284
|
const TOP_AND_BOTTOM_BORDER_HEIGHT = 10;
|
|
285
285
|
const TRANSITION_DURATION_DEFAULT = 750;
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
286
|
+
// A tree of at least this many nodes OPENS behind a "Drawing N nodes"
|
|
287
|
+
// card: its first draw is deferred by one frame, so the page shows it is
|
|
288
|
+
// busy instead of appearing frozen. A first draw cannot be timed before
|
|
289
|
+
// it happens, so this one stays a node count. Measured 2026-09-14 with
|
|
290
|
+
// Chrome's CPU slowed 6x (DevTools' low-end machine): a bare 5,000-node
|
|
291
|
+
// tree opens in about half a second there, 20,000 in a second, and a real
|
|
292
|
+
// annotated tree takes several times longer than a bare one its size.
|
|
291
293
|
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
|
|
294
|
+
// This was BIG_TREE_NODES = 3000, which also decided the "Redrawing" card
|
|
295
|
+
// and whether redraws coalesce. Both are now decided otherwise -- see
|
|
296
|
+
// scheduleUpdate -- because node count does not predict a redraw: a
|
|
297
|
+
// 1,386-node tree with 1,428 domain architectures redraws slower than a
|
|
298
|
+
// 50,000-node tree without.
|
|
299
|
+
const OPENING_CARD_NODES = 5000;
|
|
300
|
+
// A redraw shows the "Redrawing" card when this tree's previous redraw
|
|
301
|
+
// took at least this long on this machine, measured to the frame that
|
|
302
|
+
// painted it. Below that the card would only flash.
|
|
303
|
+
const REDRAW_CARD_MS = 300;
|
|
296
304
|
const WARNING = 'ArchaeopteryxJS: WARNING';
|
|
297
305
|
const MESSAGE = 'ArchaeopteryxJS: ';
|
|
298
306
|
const ERROR = 'ArchaeopteryxJS: ERROR: ';
|
|
@@ -581,11 +589,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
581
589
|
// Bumped by every launch() and destroy(), so a first draw deferred by a
|
|
582
590
|
// launch that was since torn down or replaced knows not to run.
|
|
583
591
|
let _launchSeq = 0;
|
|
584
|
-
// A redraw waiting for its frame
|
|
585
|
-
//
|
|
586
|
-
// them all.
|
|
592
|
+
// A redraw waiting for its frame: the arguments of every scheduleUpdate()
|
|
593
|
+
// call made since, merged, so that one update() serves them all.
|
|
587
594
|
let _pendingUpdate = null;
|
|
588
|
-
//
|
|
595
|
+
// How long this tree's last redraw took on this machine, ms, from the
|
|
596
|
+
// start of update() to the frame that painted it (timeRedraw); decides
|
|
597
|
+
// whether the next one shows the "Redrawing" card. 0 until timed.
|
|
598
|
+
let _redrawMs = 0;
|
|
599
|
+
// Work to run once the pending redraw has happened:
|
|
589
600
|
// the viewport re-centring a zoom needs the new layout for.
|
|
590
601
|
let _afterUpdate = [];
|
|
591
602
|
// The viewport centre captured by the FIRST zoom step of a burst; the
|
|
@@ -1532,79 +1543,104 @@ function (root, d3, forester, phyloXml) {
|
|
|
1532
1543
|
document.addEventListener('keydown', onKey);
|
|
1533
1544
|
}
|
|
1534
1545
|
|
|
1535
|
-
//
|
|
1546
|
+
// Hover and click find their node by POSITION, not by element.
|
|
1536
1547
|
//
|
|
1537
|
-
//
|
|
1538
|
-
//
|
|
1539
|
-
//
|
|
1540
|
-
//
|
|
1541
|
-
//
|
|
1542
|
-
//
|
|
1543
|
-
//
|
|
1544
|
-
//
|
|
1548
|
+
// A node is not an element any more: branches, dots and shapes are a
|
|
1549
|
+
// handful of batched paths (drawTreeGeometry), and a node has a <g> of its
|
|
1550
|
+
// own only when something is drawn for it individually -- a label, a
|
|
1551
|
+
// collapsed clade, a branch value. Every node used to carry an invisible
|
|
1552
|
+
// 5px circle as its hover target; the hit area is that circle's, a radius
|
|
1553
|
+
// of NODE_HIT_RADIUS in tree units (so it scales with the zoom as the
|
|
1554
|
+
// circle did), looked up in a quadtree built on the first pointer event
|
|
1555
|
+
// after each render.
|
|
1545
1556
|
//
|
|
1546
|
-
//
|
|
1547
|
-
//
|
|
1557
|
+
// One listener per type on the svg rather than the tree group: the group
|
|
1558
|
+
// only receives events over painted content, and a hit area is mostly
|
|
1559
|
+
// empty space. Namespaced, so d3.zoom's own listeners on the same svg
|
|
1560
|
+
// are left alone. A click on a node's own group -- its label, a collapsed
|
|
1561
|
+
// clade's wedge -- takes that node first: a label is a much easier thing
|
|
1562
|
+
// to aim at than a point.
|
|
1548
1563
|
function attachNodeEventDelegation(group) {
|
|
1564
|
+
let svg = d3.select(group.node().ownerSVGElement);
|
|
1565
|
+
_hoverNode = null;
|
|
1549
1566
|
|
|
1550
|
-
//
|
|
1551
|
-
//
|
|
1552
|
-
//
|
|
1553
|
-
function
|
|
1554
|
-
|
|
1567
|
+
// Over the tree: on the tree group, or on the empty canvas around it --
|
|
1568
|
+
// which is the full-size background rect, not the svg itself. NOT on
|
|
1569
|
+
// the legends, the overview or the floating strips drawn over it.
|
|
1570
|
+
function overTree(event) {
|
|
1571
|
+
let t = event.target;
|
|
1572
|
+
return t === svg.node() || group.node().contains(t)
|
|
1573
|
+
|| (t.classList !== undefined && t.classList.contains(BASE_BACKGROUND));
|
|
1555
1574
|
}
|
|
1556
1575
|
|
|
1557
|
-
|
|
1558
|
-
// node, or the tooltip would follow the labels too.
|
|
1559
|
-
function hoverDatum(event) {
|
|
1576
|
+
function nodeDatum(event) {
|
|
1560
1577
|
let t = event.target;
|
|
1561
|
-
|
|
1562
|
-
|
|
1578
|
+
let g = (t && t.closest) ? t.closest('g.node') : null;
|
|
1579
|
+
if (g && g.__data__ && group.node().contains(g)) {
|
|
1580
|
+
return g.__data__;
|
|
1563
1581
|
}
|
|
1564
|
-
return
|
|
1582
|
+
return overTree(event) ? nodeAtPointer(event) : null;
|
|
1565
1583
|
}
|
|
1566
1584
|
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1585
|
+
function hover(event, d) {
|
|
1586
|
+
if (d === _hoverNode) {
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
if (_hoverNode) {
|
|
1590
|
+
mouseout();
|
|
1591
|
+
}
|
|
1592
|
+
_hoverNode = d;
|
|
1593
|
+
svg.style('cursor', d ? 'pointer' : null);
|
|
1594
|
+
if (d) {
|
|
1595
|
+
mouseover(event, d);
|
|
1596
|
+
}
|
|
1573
1597
|
}
|
|
1574
1598
|
|
|
1575
|
-
|
|
1576
|
-
.on('click', function (event) {
|
|
1599
|
+
svg
|
|
1600
|
+
.on('click.aptxnode', function (event) {
|
|
1577
1601
|
let d = nodeDatum(event);
|
|
1578
1602
|
if (d && _treeFn.clickEvent) {
|
|
1579
1603
|
_treeFn.clickEvent(event, d);
|
|
1580
1604
|
}
|
|
1581
1605
|
})
|
|
1582
|
-
.on('contextmenu', function (event) {
|
|
1606
|
+
.on('contextmenu.aptxnode', function (event) {
|
|
1583
1607
|
let d = nodeDatum(event);
|
|
1584
1608
|
if (d && _treeFn.clickEvent) {
|
|
1585
1609
|
event.preventDefault(); // ours instead of the browser's menu
|
|
1586
1610
|
_treeFn.clickEvent(event, d);
|
|
1587
1611
|
}
|
|
1588
1612
|
})
|
|
1589
|
-
.on('
|
|
1590
|
-
let d =
|
|
1591
|
-
|
|
1592
|
-
mouseover(event, d);
|
|
1593
|
-
}
|
|
1594
|
-
})
|
|
1595
|
-
.on('mousemove', function (event) {
|
|
1596
|
-
let d = hoverDatum(event);
|
|
1613
|
+
.on('mousemove.aptxnode', function (event) {
|
|
1614
|
+
let d = overTree(event) ? nodeAtPointer(event) : null;
|
|
1615
|
+
hover(event, d);
|
|
1597
1616
|
if (d) {
|
|
1598
1617
|
mousemove(event, d);
|
|
1599
1618
|
}
|
|
1600
1619
|
})
|
|
1601
|
-
.on('
|
|
1602
|
-
|
|
1603
|
-
mouseout();
|
|
1604
|
-
}
|
|
1620
|
+
.on('mouseleave.aptxnode', function (event) {
|
|
1621
|
+
hover(event, null);
|
|
1605
1622
|
});
|
|
1606
1623
|
}
|
|
1607
1624
|
|
|
1625
|
+
// The node whose hit area holds the pointer, nearest first; null if none.
|
|
1626
|
+
function nodeAtPointer(event) {
|
|
1627
|
+
if (!_svgGroup || _hitNodes.length === 0) {
|
|
1628
|
+
return null;
|
|
1629
|
+
}
|
|
1630
|
+
if (!_nodeIndex) {
|
|
1631
|
+
_nodeIndex = d3.quadtree()
|
|
1632
|
+
.x(function (d) {
|
|
1633
|
+
return layoutPointXY(d)[0];
|
|
1634
|
+
})
|
|
1635
|
+
.y(function (d) {
|
|
1636
|
+
return layoutPointXY(d)[1];
|
|
1637
|
+
})
|
|
1638
|
+
.addAll(_hitNodes);
|
|
1639
|
+
}
|
|
1640
|
+
let p = d3.pointer(event, _svgGroup.node());
|
|
1641
|
+
return _nodeIndex.find(p[0], p[1], NODE_HIT_RADIUS) || null;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1608
1644
|
function mouseout() {
|
|
1609
1645
|
hideHoverGlow();
|
|
1610
1646
|
// Fade only. Emptying the tooltip here collapsed it to nothing but its
|
|
@@ -2556,20 +2592,25 @@ function (root, d3, forester, phyloXml) {
|
|
|
2556
2592
|
|
|
2557
2593
|
// --------------------------------------------------------------
|
|
2558
2594
|
|
|
2559
|
-
// Redraw
|
|
2560
|
-
//
|
|
2561
|
-
// Two things this buys on a big tree, both measured on the 18,512-node
|
|
2562
|
-
// BV-BRC tree where one update() costs over a second:
|
|
2595
|
+
// Redraw on the next animation frame, coalescing every request made
|
|
2596
|
+
// before it -- for a tree of any size.
|
|
2563
2597
|
//
|
|
2564
2598
|
// - Coalescing. Several handlers redraw more than once per click:
|
|
2565
2599
|
// nine of them run search0(), search1() and update() -- three full
|
|
2566
2600
|
// redraws, since runSearches() ends in its own update() -- and the
|
|
2567
2601
|
// Visualizations and Auto-hide toggles call update() twice back to
|
|
2568
|
-
// back. A slider drag fires an update per input event
|
|
2569
|
-
//
|
|
2570
|
-
//
|
|
2571
|
-
//
|
|
2572
|
-
//
|
|
2602
|
+
// back. A slider drag fires an update per input event, a wheel flick
|
|
2603
|
+
// one per notch. Every call before the frame collapses into ONE
|
|
2604
|
+
// update(). This was for trees over 3,000 nodes only, and below that
|
|
2605
|
+
// every notch redrew on its own: with the CPU slowed 6x, a ten-notch
|
|
2606
|
+
// flick froze a 3,000-node tree for 0.77 s and a coalesced 5,000-node
|
|
2607
|
+
// tree for 0.39 s. The wait is one frame, which is not visible.
|
|
2608
|
+
// - The card. When this tree's previous redraw took REDRAW_CARD_MS or
|
|
2609
|
+
// more on this machine, the redraw waits for the "Redrawing" card to
|
|
2610
|
+
// paint first, so a click is answered by a visible card instead of a
|
|
2611
|
+
// page that has stopped responding. Timed, not counted: a redraw's
|
|
2612
|
+
// cost depends on the machine and on what the tree carries as much
|
|
2613
|
+
// as on its size.
|
|
2573
2614
|
//
|
|
2574
2615
|
// Only for callers that read nothing back afterwards. zoomToFit() and
|
|
2575
2616
|
// the layout switches update() and then read the fresh layout in the
|
|
@@ -2584,11 +2625,6 @@ function (root, d3, forester, phyloXml) {
|
|
|
2584
2625
|
runAfterUpdate(); // nothing pending; anything queued can run now
|
|
2585
2626
|
return;
|
|
2586
2627
|
}
|
|
2587
|
-
if (!_basicTreeProperties || _basicTreeProperties.nodeCount < BIG_TREE_NODES) {
|
|
2588
|
-
update(source, transitionDuration, doNotRecalculateWidth);
|
|
2589
|
-
runAfterUpdate();
|
|
2590
|
-
return;
|
|
2591
|
-
}
|
|
2592
2628
|
let dur = (transitionDuration === undefined) ? TRANSITION_DURATION_DEFAULT : transitionDuration;
|
|
2593
2629
|
if (_pendingUpdate) {
|
|
2594
2630
|
_pendingUpdate.source = _pendingUpdate.source || source;
|
|
@@ -2598,24 +2634,35 @@ function (root, d3, forester, phyloXml) {
|
|
|
2598
2634
|
}
|
|
2599
2635
|
_pendingUpdate = {source: source, duration: dur, noWidth: doNotRecalculateWidth === true};
|
|
2600
2636
|
let seq = _launchSeq;
|
|
2601
|
-
|
|
2637
|
+
let card = _redrawMs >= REDRAW_CARD_MS && _basicTreeProperties !== null;
|
|
2638
|
+
if (card) {
|
|
2639
|
+
showBusy(_container, 'Redrawing ' + _basicTreeProperties.nodeCount.toLocaleString() + ' nodes');
|
|
2640
|
+
}
|
|
2641
|
+
// After the paint even without the card: a redraw run INSIDE the next
|
|
2642
|
+
// frame never merged the wheel notch arriving in that frame, and a
|
|
2643
|
+
// ten-notch flick measured about twice as long on a 50,000-node tree
|
|
2644
|
+
// (0.9 s against 0.47 s). One frame later is not visible.
|
|
2602
2645
|
afterPaint(function () {
|
|
2603
2646
|
let p = _pendingUpdate;
|
|
2604
2647
|
_pendingUpdate = null;
|
|
2605
2648
|
if (seq !== _launchSeq || !p) {
|
|
2606
2649
|
return; // the view was torn down or replaced meanwhile
|
|
2607
2650
|
}
|
|
2651
|
+
let start = performance.now();
|
|
2608
2652
|
try {
|
|
2609
2653
|
update(p.source, p.duration, p.noWidth);
|
|
2610
2654
|
runAfterUpdate();
|
|
2611
2655
|
} finally {
|
|
2612
|
-
|
|
2656
|
+
if (card) {
|
|
2657
|
+
hideBusy();
|
|
2658
|
+
}
|
|
2613
2659
|
}
|
|
2660
|
+
timeRedraw(start, seq, 1);
|
|
2614
2661
|
});
|
|
2615
2662
|
}
|
|
2616
2663
|
|
|
2617
|
-
// Run fn once the redraw has happened: now, if none is pending
|
|
2618
|
-
//
|
|
2664
|
+
// Run fn once the redraw has happened: now, if none is pending, else
|
|
2665
|
+
// after the deferred update() runs.
|
|
2619
2666
|
function afterUpdate(fn) {
|
|
2620
2667
|
if (_pendingUpdate) {
|
|
2621
2668
|
_afterUpdate.push(fn);
|
|
@@ -2891,51 +2938,24 @@ function (root, d3, forester, phyloXml) {
|
|
|
2891
2938
|
|
|
2892
2939
|
updateButtonEnabledState();
|
|
2893
2940
|
|
|
2941
|
+
// Only the nodes with something drawn for them individually get a
|
|
2942
|
+
// <g>: a label, a collapsed clade, a branch value or a support dot
|
|
2943
|
+
// (prepareNodeDrawing). Branches, dots, shapes and halos are batched
|
|
2944
|
+
// paths, drawn below by drawTreeGeometry, which also places these
|
|
2945
|
+
// groups. On a 50,000-node tree with its labels auto-hidden that is
|
|
2946
|
+
// tens of groups, not 50,000.
|
|
2894
2947
|
let node = _svgGroup.selectAll('g.node')
|
|
2895
|
-
.data(nodes, function (d) {
|
|
2896
|
-
return d.id
|
|
2948
|
+
.data(prepareNodeDrawing(nodes), function (d) {
|
|
2949
|
+
return d.id;
|
|
2897
2950
|
});
|
|
2898
2951
|
|
|
2899
|
-
// No listeners here:
|
|
2900
|
-
//
|
|
2952
|
+
// No listeners and no children here: hover and click find their node
|
|
2953
|
+
// by position (attachNodeEventDelegation), and the children are
|
|
2954
|
+
// created on demand by drawCollapsedClades and syncOptionalNodeChildren.
|
|
2901
2955
|
let nodeEnter = node.enter().append('g')
|
|
2902
2956
|
.attr('class', 'node')
|
|
2903
|
-
.attr('transform', function () {
|
|
2904
|
-
return 'translate(' + source.y0 + ',' + source.x0 + ')';
|
|
2905
|
-
})
|
|
2906
2957
|
.style('cursor', 'default');
|
|
2907
2958
|
|
|
2908
|
-
|
|
2909
|
-
// Only the three elements EVERY node needs are created here. The other
|
|
2910
|
-
// six -- the found halo, the four text labels and the support dot --
|
|
2911
|
-
// are created on demand by syncOptionalNodeChildren() below.
|
|
2912
|
-
//
|
|
2913
|
-
// They used to be appended unconditionally, nine elements per node
|
|
2914
|
-
// whether or not they would ever show anything, and on a real BV-BRC
|
|
2915
|
-
// tree (18,512 nodes) that was 222,197 SVG elements of which HALF were
|
|
2916
|
-
// inert: 74,052 of 74,064 <text> elements empty, 43,413 circles at
|
|
2917
|
-
// r=0. The cost is not creating them -- allocating that many elements
|
|
2918
|
-
// takes about two seconds -- it is that every later redraw walks them
|
|
2919
|
-
// all, setting attributes and running an accessor per element. Dropping
|
|
2920
|
-
// the inert half measured 2.7x faster redraws.
|
|
2921
|
-
nodeEnter.append('path')
|
|
2922
|
-
.attr('d', 'M0,0');
|
|
2923
|
-
|
|
2924
|
-
nodeEnter.append('circle')
|
|
2925
|
-
.attr('class', 'nodeCircle')
|
|
2926
|
-
.attr('r', 0);
|
|
2927
|
-
|
|
2928
|
-
nodeEnter.append('circle')
|
|
2929
|
-
.style('cursor', 'pointer')
|
|
2930
|
-
.style('opacity', '0')
|
|
2931
|
-
.attr('class', 'nodeCircleOptions')
|
|
2932
|
-
.attr('r', function (d) {
|
|
2933
|
-
if (d.parent) {
|
|
2934
|
-
return 5;
|
|
2935
|
-
}
|
|
2936
|
-
return 0;
|
|
2937
|
-
});
|
|
2938
|
-
|
|
2939
2959
|
// Grab the exit selection BEFORE merging. In d3 v3 the exit selection
|
|
2940
2960
|
// survived on the merged result, but in v4+ merge() returns a NEW
|
|
2941
2961
|
// selection with no exit, so calling .exit() after this line silently
|
|
@@ -3128,54 +3148,10 @@ function (root, d3, forester, phyloXml) {
|
|
|
3128
3148
|
}
|
|
3129
3149
|
});
|
|
3130
3150
|
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
// animations otherwise composite on every frame for the life
|
|
3136
|
-
// of the page
|
|
3137
|
-
return isNodeFound(d) ? 'foundHalo aptx-found-halo' : 'foundHalo';
|
|
3138
|
-
})
|
|
3139
|
-
.attr('r', function (d) {
|
|
3140
|
-
// the trough must clear the node dot, or a medium-sized node
|
|
3141
|
-
// buries its own pulse (the halo is UNDER the node); the CSS
|
|
3142
|
-
// 2.5x peak then keeps the desktop's 4->10px pulse ratio
|
|
3143
|
-
return isNodeFound(d) ? Math.max(4, makeNodeSize(d) + 2) : 0;
|
|
3144
|
-
})
|
|
3145
|
-
.style('fill', function (d) {
|
|
3146
|
-
return isNodeFound(d) ? getFoundColor(d) : 'none';
|
|
3147
|
-
});
|
|
3148
|
-
}
|
|
3149
|
-
|
|
3150
|
-
// One pass, and the fill colour computed once.
|
|
3151
|
-
//
|
|
3152
|
-
// This was four chained .attr/.style calls, so d3 walked all 18.5k
|
|
3153
|
-
// circles four times; and makeNodeFillColor() was called TWICE per
|
|
3154
|
-
// node -- once to decide whether the dot collapses to r=0, once for
|
|
3155
|
-
// the fill itself -- which is not a cheap accessor: it checks the
|
|
3156
|
-
// search hits, the node events, the visualization colour and the
|
|
3157
|
-
// node's own style.
|
|
3158
|
-
//
|
|
3159
|
-
// The union of the two conditions that need the colour is just the
|
|
3160
|
-
// fill condition, since (showVisualizations && !showNodeEvents)
|
|
3161
|
-
// implies showVisualizations. So it is computed once when needed and
|
|
3162
|
-
// reused, and left null when neither branch wants it.
|
|
3163
|
-
node.select('circle.nodeCircle').each(function (d) {
|
|
3164
|
-
let fill = (_state.showVisualizations || _state.showNodeEvents
|
|
3165
|
-
|| isNodeFound(d) || isNodeSelected(d)) ? makeNodeFillColor(d) : null;
|
|
3166
|
-
let r = ((_state.showVisualizations && !_state.showNodeEvents)
|
|
3167
|
-
&& (fill === _state.backgroundColorDefault)) ? 0 : makeNodeSize(d);
|
|
3168
|
-
this.setAttribute('r', r);
|
|
3169
|
-
this.style.stroke = makeNodeStrokeColor(d);
|
|
3170
|
-
this.style.strokeWidth = _state.branchWidthDefault;
|
|
3171
|
-
this.style.fill = (fill === null) ? _state.backgroundColorDefault : fill;
|
|
3172
|
-
});
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
// Dim Non-Matches: one opacity on the node GROUP dims its labels, dot,
|
|
3176
|
-
// shape and branch-data numbers together; the branch lines are separate
|
|
3177
|
-
// path.link elements and keep their full colour, as on the desktop.
|
|
3178
|
-
// Hits and selected nodes (getFoundColor) are never dimmed -- and
|
|
3151
|
+
// Dim Non-Matches: one opacity on the node GROUP dims its labels and
|
|
3152
|
+
// branch-data numbers together, and drawTreeGeometry gives its dot and
|
|
3153
|
+
// shape the same opacity; the branch lines keep their full colour, as
|
|
3154
|
+
// on the desktop. Hits and selected nodes (getFoundColor) are never dimmed -- and
|
|
3179
3155
|
// neither is a collapsed clade holding one: its wedge and label live in
|
|
3180
3156
|
// the clade node's group, and the clade node itself is never a hit.
|
|
3181
3157
|
node.style('opacity', function (d) {
|
|
@@ -3185,58 +3161,39 @@ function (root, d3, forester, phyloXml) {
|
|
|
3185
3161
|
return DIM_NON_MATCH_OPACITY;
|
|
3186
3162
|
});
|
|
3187
3163
|
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
});
|
|
3192
|
-
|
|
3193
|
-
// The same rule for a node's CHILD elements: derive from the transition
|
|
3194
|
-
// when animating, else select straight off the node and interrupt any
|
|
3195
|
-
// animation still on that child.
|
|
3164
|
+
// A node's CHILD elements animate (or are set outright) on their own;
|
|
3165
|
+
// the group itself is moved by drawTreeGeometry, in step with the
|
|
3166
|
+
// branches.
|
|
3196
3167
|
function nodeChild(selector) {
|
|
3197
|
-
return transitionDuration
|
|
3198
|
-
? nodeUpdate.select(selector)
|
|
3199
|
-
: node.select(selector).interrupt();
|
|
3168
|
+
return animateOrSet(node.select(selector), transitionDuration);
|
|
3200
3169
|
}
|
|
3201
3170
|
|
|
3202
3171
|
nodeChild('text')
|
|
3203
3172
|
.style('fill-opacity', 1);
|
|
3204
3173
|
|
|
3174
|
+
// The texts were computed by prepareNodeDrawing, which decided on them
|
|
3175
|
+
// which groups exist. Guarded, not just passed a null accessor:
|
|
3176
|
+
// syncOptionalNodeChildren has already removed these elements when the
|
|
3177
|
+
// switch is off, and a .select() that finds nothing still walks every
|
|
3178
|
+
// group to find out.
|
|
3205
3179
|
nodeChild('text.extlabel')
|
|
3206
3180
|
.text(function (d) {
|
|
3207
|
-
return d._extLabelText;
|
|
3181
|
+
return d._extLabelText;
|
|
3208
3182
|
});
|
|
3209
|
-
|
|
3210
|
-
// Guarded, not just passed a null accessor: syncOptionalNodeChildren
|
|
3211
|
-
// has already removed these elements when the switch is off, and a
|
|
3212
|
-
// .select() that finds nothing still walks every node to find out.
|
|
3213
3183
|
if (_state.showBranchLengthValues) {
|
|
3214
|
-
nodeChild('text.bllabel').text(
|
|
3184
|
+
nodeChild('text.bllabel').text(function (d) {
|
|
3185
|
+
return d._blText;
|
|
3186
|
+
});
|
|
3215
3187
|
}
|
|
3216
3188
|
if (_state.showConfidenceValues || _state.showMadValues) {
|
|
3217
|
-
nodeChild('text.conflabel').text(
|
|
3189
|
+
nodeChild('text.conflabel').text(function (d) {
|
|
3190
|
+
return d._confText;
|
|
3191
|
+
});
|
|
3218
3192
|
}
|
|
3219
3193
|
if (_state.showBranchEvents) {
|
|
3220
|
-
nodeChild('text.brancheventlabel').text(
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
let drawShapes = _state.showVisualizations || stylesActive();
|
|
3224
|
-
nodeChild('path')
|
|
3225
|
-
.style('stroke', drawShapes ? makeNodeStrokeColor : null)
|
|
3226
|
-
.style('stroke-width', _state.branchWidthDefault)
|
|
3227
|
-
.style('fill', drawShapes ? makeNodeFillColor : null)
|
|
3228
|
-
.attr('d', drawShapes ? makeNodeVisShape : null);
|
|
3229
|
-
|
|
3230
|
-
// Collapse the vis shape on internal nodes that no longer have one.
|
|
3231
|
-
// This used to be a node.each() that built a fresh d3 selection AND a
|
|
3232
|
-
// fresh transition per internal node -- 5,265 of them on a big tree,
|
|
3233
|
-
// and a full 18k-node walk even when drawShapes made it a no-op. One
|
|
3234
|
-
// filtered selection with one transition does the same thing.
|
|
3235
|
-
if (!drawShapes) {
|
|
3236
|
-
animateOrSet(node.filter(function (d) {
|
|
3237
|
-
return d.children && makeNodeVisShape(d) === null;
|
|
3238
|
-
}).select('path'), transitionDuration)
|
|
3239
|
-
.attr('d', 'M0,0');
|
|
3194
|
+
nodeChild('text.brancheventlabel').text(function (d) {
|
|
3195
|
+
return d._eventText;
|
|
3196
|
+
});
|
|
3240
3197
|
}
|
|
3241
3198
|
|
|
3242
3199
|
// Departing nodes are removed OUTRIGHT rather than on the end of a
|
|
@@ -3249,70 +3206,7 @@ function (root, d3, forester, phyloXml) {
|
|
|
3249
3206
|
// way; nodes now match.
|
|
3250
3207
|
nodeExitSelection.remove();
|
|
3251
3208
|
|
|
3252
|
-
|
|
3253
|
-
// links against the PRE-JOIN data and the transition below overwrote
|
|
3254
|
-
// the result, so every redraw computed the same geometry twice.
|
|
3255
|
-
// stroke-width used to ride along here and is applied on the merged
|
|
3256
|
-
// selection instead -- it is the only place existing links get it.
|
|
3257
|
-
let link = _svgGroup.selectAll('path.link')
|
|
3258
|
-
.data(links, function (d) {
|
|
3259
|
-
return d.target.id;
|
|
3260
|
-
});
|
|
3261
|
-
|
|
3262
|
-
// The reference node for the insert below, resolved ONCE.
|
|
3263
|
-
//
|
|
3264
|
-
// d3's insert(name, before) takes `before` as a SELECTOR STRING and
|
|
3265
|
-
// then runs parent.querySelector(before) for EVERY element it
|
|
3266
|
-
// inserts. With 18.5k links going into a group that already holds
|
|
3267
|
-
// 18.5k node <g>s, that is 18.5k full-subtree queries and it
|
|
3268
|
-
// dominated the first draw: measured 1,883 ms against 66 ms for the
|
|
3269
|
-
// same inserts with the lookup hoisted -- 28x, and about a third of
|
|
3270
|
-
// the time to open the biggest demo tree.
|
|
3271
|
-
//
|
|
3272
|
-
// Passing a function instead makes d3 use its return value as-is. The
|
|
3273
|
-
// result is identical: every link still lands before the first node
|
|
3274
|
-
// group, in the same order, so links stay painted behind nodes. It
|
|
3275
|
-
// must be re-resolved each update because the node join above may
|
|
3276
|
-
// have just created or removed those groups.
|
|
3277
|
-
let linkBefore = _svgGroup.node().querySelector('g');
|
|
3278
|
-
|
|
3279
|
-
let linkEnter = link.enter().insert('path', function () {
|
|
3280
|
-
return linkBefore;
|
|
3281
|
-
})
|
|
3282
|
-
.attr('class', 'link')
|
|
3283
|
-
.attr('fill', 'none')
|
|
3284
|
-
.attr('stroke-width', makeBranchWidth)
|
|
3285
|
-
.attr('stroke', makeBranchColor)
|
|
3286
|
-
.attr('d', function () {
|
|
3287
|
-
let o = {
|
|
3288
|
-
x: source.x0, y: source.y0,
|
|
3289
|
-
ux: source.ux, uy: source.uy
|
|
3290
|
-
};
|
|
3291
|
-
return elbow({
|
|
3292
|
-
source: o, target: o
|
|
3293
|
-
});
|
|
3294
|
-
});
|
|
3295
|
-
|
|
3296
|
-
let linkExitSelection = link.exit(); // before the merge -- see nodeExitSelection
|
|
3297
|
-
|
|
3298
|
-
link = linkEnter.merge(link);
|
|
3299
|
-
|
|
3300
|
-
link.attr('stroke-width', makeBranchWidth);
|
|
3301
|
-
|
|
3302
|
-
animateOrSet(link, transitionDuration)
|
|
3303
|
-
.attr('stroke', makeBranchColor)
|
|
3304
|
-
.attr('d', elbow);
|
|
3305
|
-
|
|
3306
|
-
linkExitSelection
|
|
3307
|
-
.attr('d', function () {
|
|
3308
|
-
let o = {
|
|
3309
|
-
x: source.x, y: source.y
|
|
3310
|
-
};
|
|
3311
|
-
return elbow({
|
|
3312
|
-
source: o, target: o
|
|
3313
|
-
});
|
|
3314
|
-
})
|
|
3315
|
-
.remove();
|
|
3209
|
+
drawTreeGeometry(nodes, links, node, source, transitionDuration);
|
|
3316
3210
|
|
|
3317
3211
|
|
|
3318
3212
|
// Aligned phylogram: a faint extension from each tip out to the column
|
|
@@ -3326,9 +3220,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
3326
3220
|
// while these are built from rectangular geometry and would be drawn as
|
|
3327
3221
|
// straight lines right across the circular tree.
|
|
3328
3222
|
//
|
|
3329
|
-
//
|
|
3330
|
-
//
|
|
3331
|
-
// -- the main link data-join, and the overview's miniature.
|
|
3223
|
+
// Inserted before the tree group's first <g>, so they paint under the
|
|
3224
|
+
// batched branches (drawTreeGeometry's layers) and the node groups.
|
|
3332
3225
|
_svgGroup.selectAll('g.aptx-align-ext').remove();
|
|
3333
3226
|
let alignedRect = !radialDisplay() && _state.phylogram && _state.alignPhylogram;
|
|
3334
3227
|
let tipGuides = alignedRect && _state.showExternalLabels
|
|
@@ -3576,7 +3469,9 @@ function (root, d3, forester, phyloXml) {
|
|
|
3576
3469
|
return _state.labelColorDefault;
|
|
3577
3470
|
};
|
|
3578
3471
|
|
|
3579
|
-
|
|
3472
|
+
// The d3 symbol type a node is drawn as, or null for none. Drawn at the
|
|
3473
|
+
// node's position, at nodeSymbolArea(), by drawTreeGeometry.
|
|
3474
|
+
function nodeShapeType(node) {
|
|
3580
3475
|
if (isNodeFound(node) || isNodeSelected(node)
|
|
3581
3476
|
|| (_state.showNodeEvents && node.events && (node.events.duplications || node.events.speciations))) {
|
|
3582
3477
|
return null;
|
|
@@ -3586,16 +3481,16 @@ function (root, d3, forester, phyloXml) {
|
|
|
3586
3481
|
let value = forester.visualizationNodeValue(node, vis);
|
|
3587
3482
|
if (value !== null) {
|
|
3588
3483
|
node.hasVis = true;
|
|
3589
|
-
return
|
|
3484
|
+
return d3SymbolType(vis.shapeScale(value));
|
|
3590
3485
|
}
|
|
3591
3486
|
}
|
|
3592
3487
|
let style = nodeStyle(node);
|
|
3593
3488
|
if (style && style.shape) {
|
|
3594
3489
|
node.hasVis = true;
|
|
3595
|
-
return
|
|
3490
|
+
return d3SymbolType(style.shape);
|
|
3596
3491
|
}
|
|
3597
3492
|
return null;
|
|
3598
|
-
}
|
|
3493
|
+
}
|
|
3599
3494
|
|
|
3600
3495
|
// ONE colour visualization drives both the label and the node fill -- these
|
|
3601
3496
|
// were two identical code paths over two identical maps. Returns null when
|
|
@@ -3822,6 +3717,40 @@ function (root, d3, forester, phyloXml) {
|
|
|
3822
3717
|
|| (_foundNodes1 && _foundNodes1.size > 0);
|
|
3823
3718
|
}
|
|
3824
3719
|
|
|
3720
|
+
// What each displayed node draws individually, decided over plain objects
|
|
3721
|
+
// before any DOM is touched: the texts of its labels and whether it wants
|
|
3722
|
+
// a support dot. Returns the nodes that need a <g> -- those with any of
|
|
3723
|
+
// them, and every collapsed clade (its wedge and label). The rest are
|
|
3724
|
+
// drawn entirely by drawTreeGeometry's batched paths.
|
|
3725
|
+
function prepareNodeDrawing(nodes) {
|
|
3726
|
+
let wantBl = _state.showBranchLengthValues === true;
|
|
3727
|
+
let wantConf = _state.showConfidenceValues === true || _state.showMadValues === true;
|
|
3728
|
+
let wantEvent = _state.showBranchEvents === true;
|
|
3729
|
+
let wantDot = _state.showSupportDots === true;
|
|
3730
|
+
let asText = function (v) {
|
|
3731
|
+
return (v === undefined || v === null) ? '' : String(v);
|
|
3732
|
+
};
|
|
3733
|
+
let drawn = [];
|
|
3734
|
+
for (let i = 0, len = nodes.length; i !== len; ++i) {
|
|
3735
|
+
let d = nodes[i];
|
|
3736
|
+
if (!d.id) {
|
|
3737
|
+
d.id = ++_i;
|
|
3738
|
+
}
|
|
3739
|
+
// Empty is the common case on a big tree: Auto-hide Labels blanks
|
|
3740
|
+
// them exactly when there are most nodes to blank.
|
|
3741
|
+
d._extLabelText = (!_state.dynahide || !d.hide) ? (makeNodeLabel(d) || '') : '';
|
|
3742
|
+
d._blText = wantBl ? asText(makeBranchLengthLabel(d)) : '';
|
|
3743
|
+
d._confText = wantConf ? asText(makeConfidenceValuesLabel(d)) : '';
|
|
3744
|
+
d._eventText = wantEvent ? asText(makeBranchEventsLabel(d)) : '';
|
|
3745
|
+
d._suppDot = wantDot && showSupportDot(d);
|
|
3746
|
+
if (d._extLabelText !== '' || d._blText !== '' || d._confText !== ''
|
|
3747
|
+
|| d._eventText !== '' || d._suppDot || isCollapsed(d)) {
|
|
3748
|
+
drawn.push(d);
|
|
3749
|
+
}
|
|
3750
|
+
}
|
|
3751
|
+
return drawn;
|
|
3752
|
+
}
|
|
3753
|
+
|
|
3825
3754
|
function syncOptionalNodeChildren(node) {
|
|
3826
3755
|
let wantBl = _state.showBranchLengthValues === true;
|
|
3827
3756
|
let wantConf = _state.showConfidenceValues === true || _state.showMadValues === true;
|
|
@@ -3855,83 +3784,59 @@ function (root, d3, forester, phyloXml) {
|
|
|
3855
3784
|
dropAll('circle.suppdot');
|
|
3856
3785
|
}
|
|
3857
3786
|
|
|
3858
|
-
//
|
|
3859
|
-
//
|
|
3860
|
-
let anyFound = anySearchHits();
|
|
3861
|
-
if (!anyFound) {
|
|
3862
|
-
dropAll('circle.foundHalo');
|
|
3863
|
-
}
|
|
3864
|
-
|
|
3865
|
-
// Raw DOM inside the loop, not d3 selections: this runs once per node
|
|
3866
|
-
// per redraw, and wrapping each node in a d3 selection just to ask
|
|
3787
|
+
// Raw DOM inside the loop, not d3 selections: this runs once per group
|
|
3788
|
+
// per redraw, and wrapping each group in a d3 selection just to ask
|
|
3867
3789
|
// whether a child exists costs more than the question is worth.
|
|
3868
3790
|
let font = _state.defaultFont;
|
|
3869
|
-
|
|
3870
|
-
let
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
// are most nodes to blank.
|
|
3875
|
-
d._extLabelText = (!_state.dynahide || !d.hide) ? (makeNodeLabel(d) || '') : '';
|
|
3876
|
-
let ext = g.querySelector('text.extlabel');
|
|
3877
|
-
if (d._extLabelText !== '') {
|
|
3878
|
-
if (!ext) {
|
|
3879
|
-
d3.select(g).append('text')
|
|
3880
|
-
.attr('class', 'extlabel')
|
|
3881
|
-
.attr('text-anchor', d.children ? 'end' : 'start')
|
|
3882
|
-
.style('font-family', font)
|
|
3883
|
-
.style('fill-opacity', 0.5);
|
|
3884
|
-
}
|
|
3885
|
-
} else if (ext) {
|
|
3886
|
-
ext.remove();
|
|
3887
|
-
}
|
|
3888
|
-
|
|
3889
|
-
if (anyFound) {
|
|
3890
|
-
// The pulse behind a search hit. It must be FIRST in the group
|
|
3891
|
-
// so everything else draws over it -- appending would put it
|
|
3892
|
-
// on top.
|
|
3893
|
-
let halo = g.querySelector('circle.foundHalo');
|
|
3894
|
-
if (isNodeFound(d)) {
|
|
3895
|
-
if (!halo) {
|
|
3896
|
-
d3.select(g).insert('circle', ':first-child')
|
|
3897
|
-
.attr('class', 'foundHalo aptx-found-halo')
|
|
3898
|
-
.attr('r', 0)
|
|
3899
|
-
.style('pointer-events', 'none');
|
|
3900
|
-
}
|
|
3901
|
-
} else if (halo) {
|
|
3902
|
-
halo.remove();
|
|
3791
|
+
let sync = function (g, selector, wanted, create) {
|
|
3792
|
+
let el = g.querySelector(selector);
|
|
3793
|
+
if (wanted) {
|
|
3794
|
+
if (!el) {
|
|
3795
|
+
create(d3.select(g));
|
|
3903
3796
|
}
|
|
3797
|
+
} else if (el) {
|
|
3798
|
+
el.remove();
|
|
3904
3799
|
}
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3800
|
+
};
|
|
3801
|
+
node.each(function (d) {
|
|
3802
|
+
let g = this;
|
|
3803
|
+
sync(g, 'text.extlabel', d._extLabelText !== '', function (sel) {
|
|
3804
|
+
sel.append('text')
|
|
3805
|
+
.attr('class', 'extlabel')
|
|
3806
|
+
.attr('text-anchor', d.children ? 'end' : 'start')
|
|
3909
3807
|
.style('font-family', font)
|
|
3910
3808
|
.style('fill-opacity', 0.5);
|
|
3809
|
+
});
|
|
3810
|
+
if (wantBl) {
|
|
3811
|
+
sync(g, 'text.bllabel', d._blText !== '', function (sel) {
|
|
3812
|
+
sel.append('text')
|
|
3813
|
+
.attr('class', 'bllabel')
|
|
3814
|
+
.style('font-family', font)
|
|
3815
|
+
.style('fill-opacity', 0.5);
|
|
3816
|
+
});
|
|
3911
3817
|
}
|
|
3912
|
-
if (wantConf
|
|
3913
|
-
|
|
3914
|
-
.
|
|
3915
|
-
|
|
3916
|
-
|
|
3818
|
+
if (wantConf) {
|
|
3819
|
+
sync(g, 'text.conflabel', d._confText !== '', function (sel) {
|
|
3820
|
+
sel.append('text')
|
|
3821
|
+
.attr('class', 'conflabel')
|
|
3822
|
+
.attr('text-anchor', 'middle')
|
|
3823
|
+
.style('font-family', font);
|
|
3824
|
+
});
|
|
3917
3825
|
}
|
|
3918
|
-
if (wantEvent
|
|
3919
|
-
|
|
3920
|
-
.
|
|
3921
|
-
|
|
3922
|
-
|
|
3826
|
+
if (wantEvent) {
|
|
3827
|
+
sync(g, 'text.brancheventlabel', d._eventText !== '', function (sel) {
|
|
3828
|
+
sel.append('text')
|
|
3829
|
+
.attr('class', 'brancheventlabel')
|
|
3830
|
+
.attr('text-anchor', 'middle')
|
|
3831
|
+
.style('font-family', font);
|
|
3832
|
+
});
|
|
3923
3833
|
}
|
|
3924
3834
|
if (wantDot) {
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
.style('pointer-events', 'none');
|
|
3931
|
-
}
|
|
3932
|
-
} else if (dot) {
|
|
3933
|
-
dot.remove();
|
|
3934
|
-
}
|
|
3835
|
+
sync(g, 'circle.suppdot', d._suppDot, function (sel) {
|
|
3836
|
+
sel.append('circle')
|
|
3837
|
+
.attr('class', 'suppdot')
|
|
3838
|
+
.style('pointer-events', 'none');
|
|
3839
|
+
});
|
|
3935
3840
|
}
|
|
3936
3841
|
});
|
|
3937
3842
|
}
|
|
@@ -4241,11 +4146,6 @@ function (root, d3, forester, phyloXml) {
|
|
|
4241
4146
|
return [d.y, d.x];
|
|
4242
4147
|
}
|
|
4243
4148
|
|
|
4244
|
-
function nodeTransform(d) {
|
|
4245
|
-
let p = layoutPointXY(d);
|
|
4246
|
-
return 'translate(' + p[0] + ',' + p[1] + ')';
|
|
4247
|
-
}
|
|
4248
|
-
|
|
4249
4149
|
// The screen angle (radians, y down) of the node's spoke: the direction
|
|
4250
4150
|
// its incoming branch points away from the centre. Circular derives it
|
|
4251
4151
|
// from the cluster position; unrooted carries it from the equal-angle
|
|
@@ -4285,20 +4185,436 @@ function (root, d3, forester, phyloXml) {
|
|
|
4285
4185
|
return 'rotate(' + labelAngleDeg(d) + ') translate(' + mid + ',0)' + (labelFlip(d) ? ' rotate(180)' : '');
|
|
4286
4186
|
}
|
|
4287
4187
|
|
|
4288
|
-
|
|
4188
|
+
// ===================== Batched tree geometry =====================
|
|
4189
|
+
// The branches, node dots, node shapes and search halos are drawn as a
|
|
4190
|
+
// handful of paths, not as elements per node.
|
|
4191
|
+
//
|
|
4192
|
+
// Every node used to own a <g> holding a shape path (usually empty), a
|
|
4193
|
+
// dot circle and an invisible 5px hover circle, and every branch its own
|
|
4194
|
+
// path: 250,374 elements for a 50,000-node tree. Measured 2026-09-14 in
|
|
4195
|
+
// headless Chrome (test_trees/bench.html), dragging that tree ran at about
|
|
4196
|
+
// 12 frames a second -- and it was the node groups, not the branches:
|
|
4197
|
+
// hiding the groups made the same drag nearly smooth, and hiding them
|
|
4198
|
+
// with the branches merged into one path made it 60. The browser pays per
|
|
4199
|
+
// ELEMENT painted, not per segment drawn.
|
|
4200
|
+
//
|
|
4201
|
+
// So: one path per distinct branch style (colour, width), and one per
|
|
4202
|
+
// distinct shape or dot style (fill, stroke, dimmed), in layers under the
|
|
4203
|
+
// node groups -- which exist only for nodes with something drawn
|
|
4204
|
+
// individually (prepareNodeDrawing). Styles, not nodes, set the element
|
|
4205
|
+
// count: an uncoloured tree is ONE branch path however big it is. Where
|
|
4206
|
+
// dots of different styles overlap, the later style now paints on top,
|
|
4207
|
+
// where tree order used to decide.
|
|
4208
|
+
//
|
|
4209
|
+
// Animation moves every node from where it was last drawn to where it
|
|
4210
|
+
// goes, and each frame redraws the paths and places the groups from those
|
|
4211
|
+
// positions, so branches, dots and labels move as one. The circular layout
|
|
4212
|
+
// interpolates angle and radius, so nodes sweep along the circle; d3's
|
|
4213
|
+
// path-string interpolation used to cut straight across it.
|
|
4214
|
+
|
|
4215
|
+
const NODE_HIT_RADIUS = 5; // tree units: the radius of the hover circle every node used to carry
|
|
4216
|
+
const OUTLINED_DOTS_MAX = 2000; // outlined dots (hits, selection) drawn one element each up to this many
|
|
4217
|
+
const ANIMATED_HALOS_MAX = 1000; // search-hit halos pulse up to this many hits; past it they are still
|
|
4218
|
+
const HALO_REST_OPACITY = 0.35; // a halo's opacity at rest: the pulse's ends, and a still halo
|
|
4219
|
+
let _geomLayers = null; // {branches, halos, shapes, dots}: the layer <g>s at the bottom of the tree group
|
|
4220
|
+
let _drawnGeom = null; // the positions last drawn: {mode, nodes, a, b}, for the next animation to start from
|
|
4221
|
+
let _nodeIndex = null; // quadtree over _hitNodes, built on the first pointer event after a render
|
|
4222
|
+
let _hitNodes = []; // the nodes hover and click can find: every displayed node but the wrapper
|
|
4223
|
+
let _hoverNode = null; // the node under the pointer, as hover last saw it
|
|
4224
|
+
|
|
4225
|
+
// A node's position in the current layout's own two coordinates, the
|
|
4226
|
+
// ones an animation interpolates: x and y in the rectangular and unrooted
|
|
4227
|
+
// layouts, angle and radius in the circular one.
|
|
4228
|
+
function geometryMode() {
|
|
4289
4229
|
if (_state.unrootedDisplay) {
|
|
4290
|
-
return '
|
|
4230
|
+
return 'unrooted';
|
|
4291
4231
|
}
|
|
4292
|
-
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4232
|
+
return _state.circularDisplay ? 'circular' : 'rectangular';
|
|
4233
|
+
}
|
|
4234
|
+
|
|
4235
|
+
function geometryA(d, mode) {
|
|
4236
|
+
if (mode === 'unrooted') {
|
|
4237
|
+
return d.ux;
|
|
4238
|
+
}
|
|
4239
|
+
return mode === 'circular' ? radialAngle(d.x) : d.y;
|
|
4240
|
+
}
|
|
4241
|
+
|
|
4242
|
+
function geometryB(d, mode) {
|
|
4243
|
+
if (mode === 'unrooted') {
|
|
4244
|
+
return d.uy;
|
|
4245
|
+
}
|
|
4246
|
+
return mode === 'circular' ? radialRadius(d.y) : d.x;
|
|
4247
|
+
}
|
|
4248
|
+
|
|
4249
|
+
// One branch, from its parent's position (sa, sb) to its node's (ta, tb).
|
|
4250
|
+
function branchSegment(mode, sa, sb, ta, tb) {
|
|
4251
|
+
if (mode === 'unrooted') {
|
|
4252
|
+
return 'M' + sa + ',' + sb + 'L' + ta + ',' + tb;
|
|
4253
|
+
}
|
|
4254
|
+
if (mode === 'circular') {
|
|
4255
|
+
let sp = polarXY(sa, sb), mp = polarXY(ta, sb), tp = polarXY(ta, tb);
|
|
4296
4256
|
let large = Math.abs(ta - sa) > Math.PI ? 1 : 0, sweep = ta > sa ? 1 : 0;
|
|
4297
|
-
return 'M' + sp[0] + ',' + sp[1] + 'A' +
|
|
4257
|
+
return 'M' + sp[0] + ',' + sp[1] + 'A' + sb + ',' + sb + ' 0 ' + large + ' ' + sweep + ' ' + mp[0] + ',' + mp[1] + 'L' + tp[0] + ',' + tp[1];
|
|
4298
4258
|
}
|
|
4299
|
-
return 'M' +
|
|
4259
|
+
return 'M' + sa + ',' + sb + 'V' + tb + 'H' + ta;
|
|
4260
|
+
}
|
|
4261
|
+
|
|
4262
|
+
let elbow = function (d) {
|
|
4263
|
+
let mode = geometryMode();
|
|
4264
|
+
return branchSegment(mode, geometryA(d.source, mode), geometryB(d.source, mode),
|
|
4265
|
+
geometryA(d.target, mode), geometryB(d.target, mode));
|
|
4300
4266
|
};
|
|
4301
4267
|
|
|
4268
|
+
// Clear `layer` down, or fill it up, to `count` children of `tag`.
|
|
4269
|
+
function sizeLayer(layer, tag, count) {
|
|
4270
|
+
while (layer.childElementCount > count) {
|
|
4271
|
+
layer.lastChild.remove();
|
|
4272
|
+
}
|
|
4273
|
+
while (layer.childElementCount < count) {
|
|
4274
|
+
layer.appendChild(document.createElementNS(d3.namespaces.svg, tag));
|
|
4275
|
+
}
|
|
4276
|
+
return layer.children;
|
|
4277
|
+
}
|
|
4278
|
+
|
|
4279
|
+
// nodes: every displayed node, links: their branches, groups: the node
|
|
4280
|
+
// <g>s this render keeps, source: the node an animation grows out of.
|
|
4281
|
+
function drawTreeGeometry(nodes, links, groups, source, duration) {
|
|
4282
|
+
let treeGroup = _svgGroup.node();
|
|
4283
|
+
if (!_geomLayers || _geomLayers.branches.parentNode !== treeGroup) {
|
|
4284
|
+
_geomLayers = {};
|
|
4285
|
+
// inserted topmost first, so they end up bottom to top: branches,
|
|
4286
|
+
// halos (under the node they mark), shapes, dots, outlined dots
|
|
4287
|
+
[['outlined', 'aptx-node-outlined'], ['dots', 'aptx-node-dots'], ['shapes', 'aptx-node-shapes'],
|
|
4288
|
+
['halos', 'aptx-halos'], ['branches', 'aptx-branches']].forEach(function (layer) {
|
|
4289
|
+
let g = document.createElementNS(d3.namespaces.svg, 'g');
|
|
4290
|
+
g.setAttribute('class', layer[1]);
|
|
4291
|
+
// hover and click find nodes by position; hit-testing a
|
|
4292
|
+
// 50,000-segment path on every mouse move is pure cost
|
|
4293
|
+
g.style.pointerEvents = 'none';
|
|
4294
|
+
treeGroup.insertBefore(g, treeGroup.firstChild);
|
|
4295
|
+
_geomLayers[layer[0]] = g;
|
|
4296
|
+
});
|
|
4297
|
+
}
|
|
4298
|
+
let driver = d3.select(_geomLayers.branches);
|
|
4299
|
+
driver.interrupt('aptx-geometry');
|
|
4300
|
+
|
|
4301
|
+
let mode = geometryMode();
|
|
4302
|
+
let n = nodes.length;
|
|
4303
|
+
let endA = new Float64Array(n);
|
|
4304
|
+
let endB = new Float64Array(n);
|
|
4305
|
+
for (let i = 0; i !== n; ++i) {
|
|
4306
|
+
endA[i] = geometryA(nodes[i], mode);
|
|
4307
|
+
endB[i] = geometryB(nodes[i], mode);
|
|
4308
|
+
}
|
|
4309
|
+
|
|
4310
|
+
// Where each node starts: where it was last drawn -- mid-flight, if
|
|
4311
|
+
// this redraw cut an animation short. A node new to the screen grows
|
|
4312
|
+
// out of the source's last drawn position, as entering elements did.
|
|
4313
|
+
let animate = duration > 0;
|
|
4314
|
+
let startA = null;
|
|
4315
|
+
let startB = null;
|
|
4316
|
+
if (animate) {
|
|
4317
|
+
let prev = (_drawnGeom && _drawnGeom.mode === mode) ? _drawnGeom : null;
|
|
4318
|
+
let prevIndex = function (d) {
|
|
4319
|
+
let j = d._geomIndex;
|
|
4320
|
+
return (prev && j !== undefined && prev.nodes[j] === d) ? j : -1;
|
|
4321
|
+
};
|
|
4322
|
+
let sj = prevIndex(source);
|
|
4323
|
+
let fromA;
|
|
4324
|
+
let fromB;
|
|
4325
|
+
if (sj >= 0) {
|
|
4326
|
+
fromA = prev.a[sj];
|
|
4327
|
+
fromB = prev.b[sj];
|
|
4328
|
+
} else if (mode === 'rectangular' && source.x0 !== undefined) {
|
|
4329
|
+
fromA = source.y0; // a first draw unfolds from the launch's root point
|
|
4330
|
+
fromB = source.x0;
|
|
4331
|
+
} else {
|
|
4332
|
+
fromA = geometryA(source, mode);
|
|
4333
|
+
fromB = geometryB(source, mode);
|
|
4334
|
+
}
|
|
4335
|
+
let fromKnown = isFinite(fromA) && isFinite(fromB);
|
|
4336
|
+
startA = new Float64Array(n);
|
|
4337
|
+
startB = new Float64Array(n);
|
|
4338
|
+
for (let i = 0; i !== n; ++i) {
|
|
4339
|
+
let j = prevIndex(nodes[i]);
|
|
4340
|
+
if (j >= 0) {
|
|
4341
|
+
startA[i] = prev.a[j];
|
|
4342
|
+
startB[i] = prev.b[j];
|
|
4343
|
+
} else {
|
|
4344
|
+
startA[i] = fromKnown ? fromA : endA[i];
|
|
4345
|
+
startB[i] = fromKnown ? fromB : endB[i];
|
|
4346
|
+
}
|
|
4347
|
+
}
|
|
4348
|
+
}
|
|
4349
|
+
for (let i = 0; i !== n; ++i) {
|
|
4350
|
+
nodes[i]._geomIndex = i;
|
|
4351
|
+
}
|
|
4352
|
+
|
|
4353
|
+
// ---- the styles: everything that does not move with the nodes ----
|
|
4354
|
+
let branchBatches = new Map();
|
|
4355
|
+
for (let i = 0, len = links.length; i !== len; ++i) {
|
|
4356
|
+
let color = makeBranchColor(links[i]);
|
|
4357
|
+
let width = makeBranchWidth(links[i]);
|
|
4358
|
+
let key = color + '|' + width;
|
|
4359
|
+
let b = branchBatches.get(key);
|
|
4360
|
+
if (!b) {
|
|
4361
|
+
b = {color: color, width: width, items: []};
|
|
4362
|
+
branchBatches.set(key, b);
|
|
4363
|
+
}
|
|
4364
|
+
b.items.push(links[i].source._geomIndex, links[i].target._geomIndex);
|
|
4365
|
+
}
|
|
4366
|
+
|
|
4367
|
+
let halos = [];
|
|
4368
|
+
let outlined = []; // dots whose outline is not their fill: search hits, selected nodes
|
|
4369
|
+
let shapeBatches = new Map();
|
|
4370
|
+
let dotBatches = new Map();
|
|
4371
|
+
let hitDotBatches = new Map(); // outlined dots past OUTLINED_DOTS_MAX: painted after every other dot
|
|
4372
|
+
let addDot = function (batches, i, fill, stroke, r, dim) {
|
|
4373
|
+
let key = fill + '|' + stroke + '|' + r + '|' + dim;
|
|
4374
|
+
let b = batches.get(key);
|
|
4375
|
+
if (!b) {
|
|
4376
|
+
b = {fill: fill, stroke: stroke, r: r, dim: dim, items: []};
|
|
4377
|
+
batches.set(key, b);
|
|
4378
|
+
}
|
|
4379
|
+
b.items.push(i);
|
|
4380
|
+
};
|
|
4381
|
+
let anyFound = anySearchHits();
|
|
4382
|
+
let drawShapes = _state.showVisualizations || stylesActive();
|
|
4383
|
+
let bg = _state.backgroundColorDefault;
|
|
4384
|
+
for (let i = 0; i !== n; ++i) {
|
|
4385
|
+
let d = nodes[i];
|
|
4386
|
+
if (anyFound && isNodeFound(d)) {
|
|
4387
|
+
// the trough must clear the node dot, or a medium-sized node
|
|
4388
|
+
// buries its own pulse; the CSS 2.5x peak then keeps the
|
|
4389
|
+
// desktop's 4->10px pulse ratio
|
|
4390
|
+
halos.push({i: i, r: Math.max(4, makeNodeSize(d) + 2), fill: getFoundColor(d)});
|
|
4391
|
+
}
|
|
4392
|
+
// Dim Non-Matches, by the rule update() applies to node groups
|
|
4393
|
+
let dim = _dimNonMatches && !getFoundColor(d) && !(isCollapsed(d) && collapsedHoldsHighlight(d));
|
|
4394
|
+
|
|
4395
|
+
// The shape BEFORE the dot: nodeShapeType marks the node hasVis,
|
|
4396
|
+
// which takes its dot away.
|
|
4397
|
+
let type = drawShapes ? nodeShapeType(d) : null;
|
|
4398
|
+
if (type) {
|
|
4399
|
+
let fill = makeNodeFillColor(d);
|
|
4400
|
+
let stroke = makeNodeStrokeColor(d);
|
|
4401
|
+
let key = fill + '|' + stroke + '|' + dim;
|
|
4402
|
+
let b = shapeBatches.get(key);
|
|
4403
|
+
if (!b) {
|
|
4404
|
+
b = {fill: fill, stroke: stroke, dim: dim, items: [], types: []};
|
|
4405
|
+
shapeBatches.set(key, b);
|
|
4406
|
+
}
|
|
4407
|
+
b.items.push(i);
|
|
4408
|
+
b.types.push(type);
|
|
4409
|
+
}
|
|
4410
|
+
|
|
4411
|
+
// The dot, with its fill computed once: the union of the two
|
|
4412
|
+
// conditions that need it is just the fill condition, since
|
|
4413
|
+
// (showVisualizations && !showNodeEvents) implies showVisualizations.
|
|
4414
|
+
let fill = (_state.showVisualizations || _state.showNodeEvents
|
|
4415
|
+
|| isNodeFound(d) || isNodeSelected(d)) ? makeNodeFillColor(d) : null;
|
|
4416
|
+
let r = ((_state.showVisualizations && !_state.showNodeEvents)
|
|
4417
|
+
&& (fill === bg)) ? 0 : makeNodeSize(d);
|
|
4418
|
+
if (r > 0) {
|
|
4419
|
+
let dotFill = (fill === null) ? bg : fill;
|
|
4420
|
+
let stroke = makeNodeStrokeColor(d);
|
|
4421
|
+
if (stroke !== dotFill) {
|
|
4422
|
+
outlined.push({i: i, fill: dotFill, stroke: stroke, r: r, dim: dim});
|
|
4423
|
+
} else {
|
|
4424
|
+
addDot(dotBatches, i, dotFill, stroke, r, dim);
|
|
4425
|
+
}
|
|
4426
|
+
}
|
|
4427
|
+
}
|
|
4428
|
+
// A path paints ALL its fills before ANY of its strokes, so a stack of
|
|
4429
|
+
// overlapping outlined dots in one stroked path reads as a mesh of
|
|
4430
|
+
// rings, where one element per dot paints each dot whole over the last.
|
|
4431
|
+
// Those are the search hits and selected nodes, usually few: they keep
|
|
4432
|
+
// an element each, in tree order. Past OUTLINED_DOTS_MAX (a search
|
|
4433
|
+
// hitting thousands of tips) the elements would bring the cost back,
|
|
4434
|
+
// so each outline style becomes two FILLED paths: discs as wide as the
|
|
4435
|
+
// outer edge of the stroke in the outline colour, then discs as wide
|
|
4436
|
+
// as its inner edge in the fill colour. A lone dot comes out exactly
|
|
4437
|
+
// as a stroked circle does; a stack comes out as one outlined blob.
|
|
4438
|
+
if (outlined.length > OUTLINED_DOTS_MAX) {
|
|
4439
|
+
let half = _state.branchWidthDefault / 2;
|
|
4440
|
+
outlined.forEach(function (o) {
|
|
4441
|
+
addDot(hitDotBatches, o.i, o.stroke, 'none', o.r + half, o.dim);
|
|
4442
|
+
if (o.r > half) {
|
|
4443
|
+
addDot(hitDotBatches, o.i, o.fill, 'none', o.r - half, o.dim);
|
|
4444
|
+
}
|
|
4445
|
+
});
|
|
4446
|
+
outlined = [];
|
|
4447
|
+
}
|
|
4448
|
+
|
|
4449
|
+
// The most common style paints first, so where dots of different
|
|
4450
|
+
// styles overlap the rarer one stays on top: a single sample of a rare
|
|
4451
|
+
// category is not buried in a dense cluster of the common one. (Tree
|
|
4452
|
+
// order decided when every dot was its own element.)
|
|
4453
|
+
let byCount = function (a, b) {
|
|
4454
|
+
return b.items.length - a.items.length;
|
|
4455
|
+
};
|
|
4456
|
+
let branchList = Array.from(branchBatches.values()).sort(byCount);
|
|
4457
|
+
let els = sizeLayer(_geomLayers.branches, 'path', branchList.length);
|
|
4458
|
+
branchList.forEach(function (b, k) {
|
|
4459
|
+
b.el = els[k];
|
|
4460
|
+
b.el.setAttribute('fill', 'none');
|
|
4461
|
+
b.el.setAttribute('stroke', b.color);
|
|
4462
|
+
b.el.setAttribute('stroke-width', b.width);
|
|
4463
|
+
});
|
|
4464
|
+
// `last`: batches painted after the sorted ones, whatever their count
|
|
4465
|
+
let paintLayer = function (batches, layer, last) {
|
|
4466
|
+
let list = Array.from(batches.values()).sort(byCount)
|
|
4467
|
+
.concat(last ? Array.from(last.values()) : []);
|
|
4468
|
+
let paths = sizeLayer(layer, 'path', list.length);
|
|
4469
|
+
list.forEach(function (b, k) {
|
|
4470
|
+
b.el = paths[k];
|
|
4471
|
+
b.el.style.fill = b.fill;
|
|
4472
|
+
b.el.style.stroke = b.stroke;
|
|
4473
|
+
b.el.style.strokeWidth = _state.branchWidthDefault;
|
|
4474
|
+
b.el.style.opacity = b.dim ? DIM_NON_MATCH_OPACITY : '';
|
|
4475
|
+
});
|
|
4476
|
+
return list;
|
|
4477
|
+
};
|
|
4478
|
+
let shapeList = paintLayer(shapeBatches, _geomLayers.shapes);
|
|
4479
|
+
let dotList = paintLayer(dotBatches, _geomLayers.dots, hitDotBatches);
|
|
4480
|
+
// The pulse only while there are few enough hits for it to point at
|
|
4481
|
+
// something. Each pulsing halo is an animation the browser recomposites
|
|
4482
|
+
// on every frame: measured on the 50,000-node tree with 3,571 hits,
|
|
4483
|
+
// the drag ran at ~41 ms a frame with them pulsing and 17 ms with the
|
|
4484
|
+
// same halos still -- the elements were never the cost.
|
|
4485
|
+
let pulse = halos.length <= ANIMATED_HALOS_MAX;
|
|
4486
|
+
let haloEls = sizeLayer(_geomLayers.halos, 'circle', halos.length);
|
|
4487
|
+
halos.forEach(function (h, k) {
|
|
4488
|
+
h.el = haloEls[k];
|
|
4489
|
+
h.el.setAttribute('class', pulse ? 'foundHalo aptx-found-halo' : 'foundHalo');
|
|
4490
|
+
h.el.setAttribute('r', h.r);
|
|
4491
|
+
h.el.style.fill = h.fill;
|
|
4492
|
+
h.el.style.opacity = pulse ? '' : HALO_REST_OPACITY;
|
|
4493
|
+
});
|
|
4494
|
+
let outlinedEls = sizeLayer(_geomLayers.outlined, 'circle', outlined.length);
|
|
4495
|
+
outlined.forEach(function (o, k) {
|
|
4496
|
+
o.el = outlinedEls[k];
|
|
4497
|
+
o.el.setAttribute('r', o.r);
|
|
4498
|
+
o.el.style.fill = o.fill;
|
|
4499
|
+
o.el.style.stroke = o.stroke;
|
|
4500
|
+
o.el.style.strokeWidth = _state.branchWidthDefault;
|
|
4501
|
+
o.el.style.opacity = o.dim ? DIM_NON_MATCH_OPACITY : '';
|
|
4502
|
+
});
|
|
4503
|
+
let groupEls = [];
|
|
4504
|
+
let groupIndex = [];
|
|
4505
|
+
groups.each(function (d) {
|
|
4506
|
+
groupEls.push(this);
|
|
4507
|
+
groupIndex.push(d._geomIndex);
|
|
4508
|
+
});
|
|
4509
|
+
|
|
4510
|
+
// ---- the positions: set once, or on every animation frame ----
|
|
4511
|
+
let curA = new Float64Array(n);
|
|
4512
|
+
let curB = new Float64Array(n);
|
|
4513
|
+
_drawnGeom = {mode: mode, nodes: nodes, a: curA, b: curB};
|
|
4514
|
+
_hitNodes = nodes.filter(function (d) {
|
|
4515
|
+
return !!d.parent;
|
|
4516
|
+
});
|
|
4517
|
+
_nodeIndex = null;
|
|
4518
|
+
|
|
4519
|
+
let circular = mode === 'circular';
|
|
4520
|
+
let area = nodeSymbolArea();
|
|
4521
|
+
let x = 0;
|
|
4522
|
+
let y = 0;
|
|
4523
|
+
let place = function (i) { // node i's point on screen, into x and y (layoutPointXY)
|
|
4524
|
+
if (circular) {
|
|
4525
|
+
let a = curA[i] - Math.PI / 2;
|
|
4526
|
+
x = curB[i] * Math.cos(a);
|
|
4527
|
+
y = curB[i] * Math.sin(a);
|
|
4528
|
+
} else {
|
|
4529
|
+
x = curA[i];
|
|
4530
|
+
y = curB[i];
|
|
4531
|
+
}
|
|
4532
|
+
};
|
|
4533
|
+
// a d3 symbol draws around the origin; this context moves it to (x, y)
|
|
4534
|
+
let symbolPath = null;
|
|
4535
|
+
let atPoint = {
|
|
4536
|
+
moveTo: function (px, py) {
|
|
4537
|
+
symbolPath.moveTo(px + x, py + y);
|
|
4538
|
+
},
|
|
4539
|
+
lineTo: function (px, py) {
|
|
4540
|
+
symbolPath.lineTo(px + x, py + y);
|
|
4541
|
+
},
|
|
4542
|
+
arc: function (px, py, radius, a0, a1, ccw) {
|
|
4543
|
+
symbolPath.arc(px + x, py + y, radius, a0, a1, ccw);
|
|
4544
|
+
},
|
|
4545
|
+
rect: function (px, py, w, h) {
|
|
4546
|
+
symbolPath.rect(px + x, py + y, w, h);
|
|
4547
|
+
},
|
|
4548
|
+
closePath: function () {
|
|
4549
|
+
symbolPath.closePath();
|
|
4550
|
+
}
|
|
4551
|
+
};
|
|
4552
|
+
|
|
4553
|
+
let frame = function (t) {
|
|
4554
|
+
if (animate && t < 1) {
|
|
4555
|
+
for (let i = 0; i !== n; ++i) {
|
|
4556
|
+
curA[i] = startA[i] + ((endA[i] - startA[i]) * t);
|
|
4557
|
+
curB[i] = startB[i] + ((endB[i] - startB[i]) * t);
|
|
4558
|
+
}
|
|
4559
|
+
} else {
|
|
4560
|
+
curA.set(endA);
|
|
4561
|
+
curB.set(endB);
|
|
4562
|
+
}
|
|
4563
|
+
branchList.forEach(function (b) {
|
|
4564
|
+
let items = b.items;
|
|
4565
|
+
let parts = new Array(items.length / 2);
|
|
4566
|
+
for (let k = 0; k < items.length; k += 2) {
|
|
4567
|
+
let s = items[k];
|
|
4568
|
+
let e = items[k + 1];
|
|
4569
|
+
parts[k / 2] = branchSegment(mode, curA[s], curB[s], curA[e], curB[e]);
|
|
4570
|
+
}
|
|
4571
|
+
b.el.setAttribute('d', parts.join(''));
|
|
4572
|
+
});
|
|
4573
|
+
shapeList.forEach(function (b) {
|
|
4574
|
+
symbolPath = d3.path();
|
|
4575
|
+
for (let k = 0; k < b.items.length; ++k) {
|
|
4576
|
+
place(b.items[k]);
|
|
4577
|
+
b.types[k].draw(atPoint, area);
|
|
4578
|
+
}
|
|
4579
|
+
b.el.setAttribute('d', symbolPath.toString());
|
|
4580
|
+
});
|
|
4581
|
+
dotList.forEach(function (b) {
|
|
4582
|
+
let r = b.r;
|
|
4583
|
+
let arcs = 'a' + r + ',' + r + ' 0 1,0 ' + (2 * r) + ',0a' + r + ',' + r + ' 0 1,0 ' + (-2 * r) + ',0Z';
|
|
4584
|
+
let parts = new Array(b.items.length);
|
|
4585
|
+
for (let k = 0; k < b.items.length; ++k) {
|
|
4586
|
+
place(b.items[k]);
|
|
4587
|
+
parts[k] = 'M' + (x - r) + ',' + y + arcs;
|
|
4588
|
+
}
|
|
4589
|
+
b.el.setAttribute('d', parts.join(''));
|
|
4590
|
+
});
|
|
4591
|
+
halos.forEach(function (h) {
|
|
4592
|
+
place(h.i);
|
|
4593
|
+
h.el.setAttribute('cx', x);
|
|
4594
|
+
h.el.setAttribute('cy', y);
|
|
4595
|
+
});
|
|
4596
|
+
outlined.forEach(function (o) {
|
|
4597
|
+
place(o.i);
|
|
4598
|
+
o.el.setAttribute('cx', x);
|
|
4599
|
+
o.el.setAttribute('cy', y);
|
|
4600
|
+
});
|
|
4601
|
+
for (let k = 0; k < groupEls.length; ++k) {
|
|
4602
|
+
place(groupIndex[k]);
|
|
4603
|
+
groupEls[k].setAttribute('transform', 'translate(' + x + ',' + y + ')');
|
|
4604
|
+
}
|
|
4605
|
+
};
|
|
4606
|
+
|
|
4607
|
+
if (animate) {
|
|
4608
|
+
frame(0);
|
|
4609
|
+
driver.transition('aptx-geometry').duration(duration)
|
|
4610
|
+
.tween('geometry', function () {
|
|
4611
|
+
return frame;
|
|
4612
|
+
});
|
|
4613
|
+
} else {
|
|
4614
|
+
frame(1);
|
|
4615
|
+
}
|
|
4616
|
+
}
|
|
4617
|
+
|
|
4302
4618
|
let connection = function (n) {
|
|
4303
4619
|
if (_state.phylogram) {
|
|
4304
4620
|
let x1 = n.y + 5;
|
|
@@ -5236,6 +5552,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
5236
5552
|
_basicTreeProperties = null;
|
|
5237
5553
|
_baseSvg = null;
|
|
5238
5554
|
_svgGroup = null;
|
|
5555
|
+
_geomLayers = null;
|
|
5556
|
+
_drawnGeom = null;
|
|
5557
|
+
_hitNodes = [];
|
|
5558
|
+
_nodeIndex = null;
|
|
5559
|
+
_hoverNode = null;
|
|
5239
5560
|
_overviewGroup = null;
|
|
5240
5561
|
_overviewLinks = null;
|
|
5241
5562
|
_overviewContent = null;
|
|
@@ -5382,6 +5703,11 @@ function (root, d3, forester, phyloXml) {
|
|
|
5382
5703
|
_in_subtree = false;
|
|
5383
5704
|
_baseSvg = null;
|
|
5384
5705
|
_svgGroup = null;
|
|
5706
|
+
_geomLayers = null;
|
|
5707
|
+
_drawnGeom = null;
|
|
5708
|
+
_hitNodes = [];
|
|
5709
|
+
_nodeIndex = null;
|
|
5710
|
+
_hoverNode = null;
|
|
5385
5711
|
_overviewGroup = null;
|
|
5386
5712
|
_overviewContent = null;
|
|
5387
5713
|
_overviewViewport = null;
|
|
@@ -5448,8 +5774,14 @@ function (root, d3, forester, phyloXml) {
|
|
|
5448
5774
|
// unresolvable container -- so deferring the rest keeps launch()'s
|
|
5449
5775
|
// synchronous-error contract intact. The handle closes over module
|
|
5450
5776
|
// state, so it is valid before the draw; viewer.ready says when.
|
|
5777
|
+
//
|
|
5778
|
+
// The first draw is timed too, and stands in for this tree's redraws
|
|
5779
|
+
// until one has been timed -- at a third, since it builds everything
|
|
5780
|
+
// from scratch. Measured 2026-09-14 (bench, CPU 1x to 6x), a redraw
|
|
5781
|
+
// took from 0.1 to 0.9 of a first draw, most often a quarter to a third.
|
|
5451
5782
|
let nodeCount = _basicTreeProperties ? _basicTreeProperties.nodeCount : 0;
|
|
5452
|
-
|
|
5783
|
+
_redrawMs = 0;
|
|
5784
|
+
if (nodeCount >= OPENING_CARD_NODES) {
|
|
5453
5785
|
showBusy(containerEl, 'Drawing ' + nodeCount.toLocaleString() + ' nodes',
|
|
5454
5786
|
'this can take a moment on a tree this size');
|
|
5455
5787
|
_readyPromise = new Promise(function (resolve) {
|
|
@@ -5459,16 +5791,20 @@ function (root, d3, forester, phyloXml) {
|
|
|
5459
5791
|
resolve();
|
|
5460
5792
|
return;
|
|
5461
5793
|
}
|
|
5794
|
+
let start = performance.now();
|
|
5462
5795
|
try {
|
|
5463
5796
|
setupAndDraw();
|
|
5464
5797
|
} finally {
|
|
5465
5798
|
hideBusy();
|
|
5466
5799
|
resolve();
|
|
5467
5800
|
}
|
|
5801
|
+
timeRedraw(start, seq, 1 / 3);
|
|
5468
5802
|
});
|
|
5469
5803
|
});
|
|
5470
5804
|
} else {
|
|
5805
|
+
let start = performance.now();
|
|
5471
5806
|
setupAndDraw();
|
|
5807
|
+
timeRedraw(start, seq, 1 / 3);
|
|
5472
5808
|
_readyPromise = Promise.resolve();
|
|
5473
5809
|
}
|
|
5474
5810
|
|
|
@@ -7977,54 +8313,134 @@ function (root, d3, forester, phyloXml) {
|
|
|
7977
8313
|
return Math.max(DOMAIN_BOX_MIN_H, Math.min(DOMAIN_BOX_MAX_H, Math.round(pitch)));
|
|
7978
8314
|
}
|
|
7979
8315
|
|
|
8316
|
+
// A rounded rectangle as path data. Returns '' for a non-finite
|
|
8317
|
+
// rectangle, which in a shared path would end the path data there and
|
|
8318
|
+
// lose every rectangle after it.
|
|
8319
|
+
//
|
|
8320
|
+
// Compact on purpose: one absolute point, then relative steps, every
|
|
8321
|
+
// number to a hundredth of a pixel (below what a screen shows at the 10x
|
|
8322
|
+
// zoom limit). Full-precision coordinates made the exported SVG larger
|
|
8323
|
+
// than the rects this replaced: the dense influenza tree's went from
|
|
8324
|
+
// 5.5 MB to 10.6 MB; with this, 3.9 MB.
|
|
8325
|
+
//
|
|
8326
|
+
// Each corner is ONE cubic curve, the usual quarter-circle approximation
|
|
8327
|
+
// (off by 0.03% of the radius, which is at most a few px here), not an
|
|
8328
|
+
// arc: svg2pdf splits an arc into several curves, and a circular export
|
|
8329
|
+
// came out with 5.4 curves per rectangle against 4.0 for rects -- the
|
|
8330
|
+
// circular influenza tree's PDF 4.6 MB against 3.96 MB (2026-09-14).
|
|
8331
|
+
function roundRectPath(x, y, w, h, r) {
|
|
8332
|
+
if (!isFinite(x) || !isFinite(y) || !(w > 0) || !isFinite(w) || !(h > 0) || !isFinite(h)) {
|
|
8333
|
+
return '';
|
|
8334
|
+
}
|
|
8335
|
+
let num = function (n) {
|
|
8336
|
+
return Math.round(n * 100) / 100;
|
|
8337
|
+
};
|
|
8338
|
+
if (!(r > 0)) {
|
|
8339
|
+
return 'M' + num(x) + ',' + num(y) + 'h' + num(w) + 'v' + num(h) + 'h' + num(-w) + 'Z';
|
|
8340
|
+
}
|
|
8341
|
+
let iw = num(w - (2 * r));
|
|
8342
|
+
let ih = num(h - (2 * r));
|
|
8343
|
+
let a = num(r);
|
|
8344
|
+
let b = num(r * (1 - 0.5522847498)); // the control points sit 0.5523 r along each tangent
|
|
8345
|
+
return 'M' + num(x + r) + ',' + num(y)
|
|
8346
|
+
+ 'h' + iw + 'c' + num(a - b) + ',0 ' + a + ',' + b + ' ' + a + ',' + a
|
|
8347
|
+
+ 'v' + ih + 'c0,' + num(a - b) + ' ' + (-b) + ',' + a + ' ' + (-a) + ',' + a
|
|
8348
|
+
+ 'h' + (-iw) + 'c' + num(b - a) + ',0 ' + (-a) + ',' + (-b) + ' ' + (-a) + ',' + (-a)
|
|
8349
|
+
+ 'v' + (-ih) + 'c0,' + num(b - a) + ' ' + b + ',' + (-a) + ' ' + a + ',' + (-a) + 'Z';
|
|
8350
|
+
}
|
|
8351
|
+
|
|
8352
|
+
// The track, drawn the desktop's way: per architecture a backbone, per
|
|
8353
|
+
// admitted domain a stepped drop shadow, the optional glow, the gradient
|
|
8354
|
+
// body with its border, and -- rectangular only -- the name when it fits.
|
|
8355
|
+
//
|
|
8356
|
+
// Batched like the tree (drawTreeGeometry). Each domain used to be three
|
|
8357
|
+
// shadow rects, two glow rects when on, and a body, in a <g> per tip in
|
|
8358
|
+
// the radial layouts: 32,346 elements on a 1,386-node BV-BRC tree with
|
|
8359
|
+
// 1,428 architectures, 23,208 of them shadows, and a redraw of 430 ms
|
|
8360
|
+
// against 22 ms with the track off (2026-09-14). Now the backbones are one
|
|
8361
|
+
// path, each shadow level one path and each glow level one path per
|
|
8362
|
+
// colour -- for the whole track in the rectangular layout, per tip in the
|
|
8363
|
+
// radial ones (see "collect" below) -- all under the bodies; the elements
|
|
8364
|
+
// are kept and reused from redraw to redraw.
|
|
8365
|
+
//
|
|
8366
|
+
// The BODIES stay one rect each: their vertical gradient is sized to the
|
|
8367
|
+
// element's own bounding box, so one path per colour would stretch a
|
|
8368
|
+
// single gradient over the whole column. (One path per colour per ROW
|
|
8369
|
+
// would keep it exact, but on the flu trees nearly every box in a row
|
|
8370
|
+
// has a colour of its own: 7,706 such pairs for 7,736 boxes.)
|
|
8371
|
+
//
|
|
8372
|
+
// What batching changes: where two domains of one architecture overlap,
|
|
8373
|
+
// the later one's shadow and glow used to fall across the earlier body,
|
|
8374
|
+
// and now lie under it; overlapping shadows of one level no longer
|
|
8375
|
+
// darken each other.
|
|
7980
8376
|
function drawDomainArchitectures() {
|
|
7981
8377
|
if (!_svgGroup) {
|
|
7982
8378
|
return;
|
|
7983
8379
|
}
|
|
7984
|
-
_svgGroup.
|
|
7985
|
-
|
|
7986
|
-
return;
|
|
7987
|
-
}
|
|
7988
|
-
let tips = displayedTips().filter(function (d) {
|
|
8380
|
+
let existing = _svgGroup.select('g.aptx-domains');
|
|
8381
|
+
let tips = (domainsShown() && _root) ? displayedTips().filter(function (d) {
|
|
7989
8382
|
return _state.unrootedDisplay ? (d.ux !== undefined) : (d.x !== undefined);
|
|
7990
|
-
});
|
|
7991
|
-
let f = domainScale();
|
|
8383
|
+
}) : [];
|
|
8384
|
+
let f = tips.length > 0 ? domainScale() : 0;
|
|
7992
8385
|
if (tips.length === 0 || !(f > 0) || !isFinite(f)) {
|
|
8386
|
+
existing.remove();
|
|
7993
8387
|
return;
|
|
7994
8388
|
}
|
|
7995
|
-
|
|
8389
|
+
// [defs, the batched paths, the bodies, the names], kept between
|
|
8390
|
+
// redraws and moved to the end of the tree group, where a fresh
|
|
8391
|
+
// append used to put it
|
|
8392
|
+
let g = existing.node();
|
|
8393
|
+
if (!g) {
|
|
8394
|
+
g = document.createElementNS(d3.namespaces.svg, 'g');
|
|
8395
|
+
g.setAttribute('class', 'aptx-domains');
|
|
8396
|
+
g.style.pointerEvents = 'none';
|
|
8397
|
+
['defs', 'g', 'g', 'g'].forEach(function (tag) {
|
|
8398
|
+
g.appendChild(document.createElementNS(d3.namespaces.svg, tag));
|
|
8399
|
+
});
|
|
8400
|
+
}
|
|
8401
|
+
_svgGroup.node().appendChild(g);
|
|
8402
|
+
let defs = g.children[0];
|
|
8403
|
+
let underLayer = g.children[1];
|
|
8404
|
+
let bodyLayer = g.children[2];
|
|
8405
|
+
let labelLayer = g.children[3];
|
|
8406
|
+
|
|
7996
8407
|
// one vertical gradient per base colour, lighter at the top; the
|
|
7997
8408
|
// defs ride into the SVG / PDF / PNG exports with the boxes
|
|
7998
|
-
|
|
8409
|
+
defs.textContent = '';
|
|
7999
8410
|
let made = Object.create(null);
|
|
8000
8411
|
function gradientFor(base) {
|
|
8001
8412
|
let id = 'aptx-dom-' + base.substring(1);
|
|
8002
8413
|
if (!made[id]) {
|
|
8003
8414
|
made[id] = true;
|
|
8004
|
-
let grad = defs.append('linearGradient').attr('id', id)
|
|
8415
|
+
let grad = d3.select(defs).append('linearGradient').attr('id', id)
|
|
8005
8416
|
.attr('x1', 0).attr('y1', 0).attr('x2', 0).attr('y2', 1);
|
|
8006
8417
|
grad.append('stop').attr('offset', '0%').attr('stop-color', forester.domainLighten(base, 0.12));
|
|
8007
8418
|
grad.append('stop').attr('offset', '100%').attr('stop-color', forester.domainDarken(base, 0.10));
|
|
8008
8419
|
}
|
|
8009
8420
|
return 'url(#' + id + ')';
|
|
8010
8421
|
}
|
|
8422
|
+
|
|
8423
|
+
// Where each tip's architecture goes: its start along the track, the
|
|
8424
|
+
// top and height of its boxes, and in the radial layouts the
|
|
8425
|
+
// transform that turns (and for unrooted moves) it onto its spoke.
|
|
8011
8426
|
let labelSpace = domainLabelSpace();
|
|
8427
|
+
let rows = [];
|
|
8012
8428
|
if (_state.circularDisplay) {
|
|
8013
8429
|
// every bar starts on one ring past the labels and rides its
|
|
8014
8430
|
// tip's spoke outward
|
|
8015
8431
|
let r0 = _radial.maxRad + labelSpace + DOMAIN_RADIAL_GAP;
|
|
8016
8432
|
let h = domainBoxHeight((r0 * 2 * Math.PI) / tips.length);
|
|
8017
8433
|
tips.forEach(function (d) {
|
|
8018
|
-
|
|
8019
|
-
|
|
8434
|
+
rows.push({d: d, start: r0, y1: -h / 2, h: h,
|
|
8435
|
+
transform: 'rotate(' + labelAngleDeg(d) + ')', labelsOn: false});
|
|
8020
8436
|
});
|
|
8021
8437
|
} else if (_state.unrootedDisplay) {
|
|
8022
8438
|
// no common ring: each bar starts past its own tip's label
|
|
8023
8439
|
let h = domainBoxHeight((Math.PI * 2 * _unroot.maxRad) / tips.length);
|
|
8024
8440
|
let start = labelSpace + DOMAIN_RADIAL_GAP;
|
|
8025
8441
|
tips.forEach(function (d) {
|
|
8026
|
-
|
|
8027
|
-
|
|
8442
|
+
rows.push({d: d, start: start, y1: -h / 2, h: h,
|
|
8443
|
+
transform: 'translate(' + d.ux + ',' + d.uy + ') rotate(' + labelAngleDeg(d) + ')', labelsOn: false});
|
|
8028
8444
|
});
|
|
8029
8445
|
} else {
|
|
8030
8446
|
// one aligned column: past the deepest tip and the longest label.
|
|
@@ -8038,61 +8454,132 @@ function (root, d3, forester, phyloXml) {
|
|
|
8038
8454
|
let start = _w + _state.nodeLabelGap + labelSpace + DOMAIN_TRACK_START_GAP;
|
|
8039
8455
|
let labelsOn = _state.domainLabels === 'domains';
|
|
8040
8456
|
tips.forEach(function (d) {
|
|
8041
|
-
|
|
8457
|
+
rows.push({d: d, start: start, y1: d.x - (h / 2), h: h, transform: null, labelsOn: labelsOn});
|
|
8042
8458
|
});
|
|
8043
8459
|
}
|
|
8044
|
-
}
|
|
8045
|
-
|
|
8046
|
-
function domainRect(g, x, y, w, h, r) {
|
|
8047
|
-
return g.append('rect')
|
|
8048
|
-
.attr('x', x).attr('y', y).attr('width', w).attr('height', h)
|
|
8049
|
-
.attr('rx', r).attr('ry', r);
|
|
8050
|
-
}
|
|
8051
8460
|
|
|
8052
|
-
|
|
8053
|
-
|
|
8054
|
-
|
|
8055
|
-
|
|
8056
|
-
|
|
8057
|
-
|
|
8058
|
-
|
|
8059
|
-
|
|
8060
|
-
let
|
|
8061
|
-
|
|
8062
|
-
|
|
8063
|
-
|
|
8064
|
-
|
|
8065
|
-
|
|
8066
|
-
|
|
8067
|
-
|
|
8068
|
-
|
|
8069
|
-
|
|
8070
|
-
|
|
8071
|
-
|
|
8072
|
-
|
|
8073
|
-
|
|
8461
|
+
// ---- collect. Backbones, shadows and glows go into shared path
|
|
8462
|
+
// data: one set for the whole track in the rectangular layout, one
|
|
8463
|
+
// set per tip in the radial layouts, drawn in the tip's own turned
|
|
8464
|
+
// frame as its bodies are. One set across a circular track holds
|
|
8465
|
+
// coordinates already turned onto the spokes, every one different,
|
|
8466
|
+
// and svg2pdf writes each of them to sixteen digits: the circular
|
|
8467
|
+
// influenza tree's PDF came out at 9.1 MB against 3.96 MB for the
|
|
8468
|
+
// rects, whose local coordinates repeat and so compress (2026-09-14).
|
|
8469
|
+
let under = []; // {d, fill, opacity, transform}, bottom to top
|
|
8470
|
+
let pathSet = function () {
|
|
8471
|
+
return {
|
|
8472
|
+
backbones: [],
|
|
8473
|
+
shadows: DOMAIN_SHADOWS.map(function () {
|
|
8474
|
+
return [];
|
|
8475
|
+
}),
|
|
8476
|
+
glows: DOMAIN_GLOWS.map(function () {
|
|
8477
|
+
return new Map(); // base colour -> path data
|
|
8478
|
+
})
|
|
8479
|
+
};
|
|
8480
|
+
};
|
|
8481
|
+
let flush = function (set, transform) {
|
|
8482
|
+
let add = function (parts, fill, opacity) {
|
|
8483
|
+
let d = parts.join('');
|
|
8484
|
+
if (d !== '') {
|
|
8485
|
+
under.push({d: d, fill: fill, opacity: opacity, transform: transform});
|
|
8486
|
+
}
|
|
8487
|
+
};
|
|
8488
|
+
add(set.backbones, DOMAIN_BACKBONE_COLOR, '');
|
|
8489
|
+
DOMAIN_SHADOWS.forEach(function (s, k) {
|
|
8490
|
+
add(set.shadows[k], DOMAIN_SHADOW_COLOR, s[2] / 255);
|
|
8074
8491
|
});
|
|
8075
|
-
|
|
8076
|
-
|
|
8077
|
-
|
|
8078
|
-
domainRect(g, b.x - o, y1 - o, b.w + (2 * o), h + (2 * o), r + o)
|
|
8079
|
-
.style('fill', base)
|
|
8080
|
-
.style('fill-opacity', gl[1] / 255);
|
|
8492
|
+
DOMAIN_GLOWS.forEach(function (gl, k) {
|
|
8493
|
+
set.glows[k].forEach(function (parts, base) {
|
|
8494
|
+
add(parts, base, gl[1] / 255);
|
|
8081
8495
|
});
|
|
8496
|
+
});
|
|
8497
|
+
};
|
|
8498
|
+
let track = pathSet();
|
|
8499
|
+
let bodies = [];
|
|
8500
|
+
let names = [];
|
|
8501
|
+
rows.forEach(function (row) {
|
|
8502
|
+
let da = forester.domainArchitectureOf(row.d);
|
|
8503
|
+
if (!da) {
|
|
8504
|
+
return;
|
|
8082
8505
|
}
|
|
8083
|
-
|
|
8084
|
-
|
|
8085
|
-
|
|
8086
|
-
|
|
8087
|
-
|
|
8088
|
-
|
|
8089
|
-
|
|
8090
|
-
|
|
8091
|
-
|
|
8092
|
-
|
|
8093
|
-
|
|
8094
|
-
.
|
|
8506
|
+
let set = row.transform ? pathSet() : track;
|
|
8507
|
+
let geo = forester.domainBoxes(da, row.start, f, _state.domainEvalueExponent);
|
|
8508
|
+
let h = row.h;
|
|
8509
|
+
let y1 = row.y1;
|
|
8510
|
+
set.backbones.push(roundRectPath(geo.backbone.x, y1 + (h / 2) - 0.5, geo.backbone.w, 1, 0));
|
|
8511
|
+
let fs = Math.min(_state.externalNodeFontSize, h - 2);
|
|
8512
|
+
let font = fs + 'px ' + FONT_DEFAULTS;
|
|
8513
|
+
geo.boxes.forEach(function (b) {
|
|
8514
|
+
let base = domainColor(b.name);
|
|
8515
|
+
let r = Math.min(2, Math.min(b.w, h) / 2);
|
|
8516
|
+
DOMAIN_SHADOWS.forEach(function (s, k) {
|
|
8517
|
+
set.shadows[k].push(roundRectPath(b.x + s[0], y1 + s[1], b.w, h, r));
|
|
8518
|
+
});
|
|
8519
|
+
if (_state.domainGlow) {
|
|
8520
|
+
DOMAIN_GLOWS.forEach(function (gl, k) {
|
|
8521
|
+
let o = gl[0];
|
|
8522
|
+
let parts = set.glows[k].get(base);
|
|
8523
|
+
if (!parts) {
|
|
8524
|
+
parts = [];
|
|
8525
|
+
set.glows[k].set(base, parts);
|
|
8526
|
+
}
|
|
8527
|
+
parts.push(roundRectPath(b.x - o, y1 - o, b.w + (2 * o), h + (2 * o), r + o));
|
|
8528
|
+
});
|
|
8529
|
+
}
|
|
8530
|
+
bodies.push({x: b.x, y: y1, w: b.w, h: h, r: r, transform: row.transform,
|
|
8531
|
+
fill: gradientFor(base), stroke: forester.domainDarken(base, 0.24)});
|
|
8532
|
+
if (row.labelsOn && b.name && fs > 4 && legendTextWidth(b.name, font) <= b.w - 4) {
|
|
8533
|
+
names.push({x: b.x + (b.w / 2), y: y1 + (h / 2), font: font,
|
|
8534
|
+
fill: forester.domainLabelInk(base), text: b.name});
|
|
8535
|
+
}
|
|
8536
|
+
});
|
|
8537
|
+
if (row.transform) {
|
|
8538
|
+
flush(set, row.transform);
|
|
8539
|
+
}
|
|
8540
|
+
});
|
|
8541
|
+
flush(track, null); // empty in the radial layouts
|
|
8542
|
+
|
|
8543
|
+
// ---- draw: bottom to top, reusing the elements already there
|
|
8544
|
+
let paths = sizeLayer(underLayer, 'path', under.length);
|
|
8545
|
+
under.forEach(function (u, k) {
|
|
8546
|
+
paths[k].setAttribute('d', u.d);
|
|
8547
|
+
if (u.transform) {
|
|
8548
|
+
paths[k].setAttribute('transform', u.transform);
|
|
8549
|
+
} else {
|
|
8550
|
+
paths[k].removeAttribute('transform');
|
|
8551
|
+
}
|
|
8552
|
+
paths[k].style.fill = u.fill;
|
|
8553
|
+
paths[k].style.fillOpacity = u.opacity;
|
|
8554
|
+
});
|
|
8555
|
+
let rects = sizeLayer(bodyLayer, 'rect', bodies.length);
|
|
8556
|
+
bodies.forEach(function (b, k) {
|
|
8557
|
+
let el = rects[k];
|
|
8558
|
+
el.setAttribute('x', b.x);
|
|
8559
|
+
el.setAttribute('y', b.y);
|
|
8560
|
+
el.setAttribute('width', b.w);
|
|
8561
|
+
el.setAttribute('height', b.h);
|
|
8562
|
+
el.setAttribute('rx', b.r);
|
|
8563
|
+
el.setAttribute('ry', b.r);
|
|
8564
|
+
if (b.transform) {
|
|
8565
|
+
el.setAttribute('transform', b.transform);
|
|
8566
|
+
} else {
|
|
8567
|
+
el.removeAttribute('transform');
|
|
8095
8568
|
}
|
|
8569
|
+
el.style.fill = b.fill;
|
|
8570
|
+
el.style.stroke = b.stroke;
|
|
8571
|
+
el.style.strokeWidth = 1;
|
|
8572
|
+
});
|
|
8573
|
+
let texts = sizeLayer(labelLayer, 'text', names.length);
|
|
8574
|
+
names.forEach(function (t, k) {
|
|
8575
|
+
let el = texts[k];
|
|
8576
|
+
el.setAttribute('x', t.x);
|
|
8577
|
+
el.setAttribute('y', t.y);
|
|
8578
|
+
el.setAttribute('dy', '0.35em');
|
|
8579
|
+
el.setAttribute('text-anchor', 'middle');
|
|
8580
|
+
el.style.font = t.font;
|
|
8581
|
+
el.style.fill = t.fill;
|
|
8582
|
+
el.textContent = t.text;
|
|
8096
8583
|
});
|
|
8097
8584
|
}
|
|
8098
8585
|
|
|
@@ -10635,8 +11122,8 @@ function (root, d3, forester, phyloXml) {
|
|
|
10635
11122
|
+ '.aptx-panel .aptx-searchnav { align-items:center; gap:4px; margin:2px 0 4px; }'
|
|
10636
11123
|
+ '.aptx-panel .aptx-searchnav span { flex:1 1 auto; text-align:center; font-weight:600; font-size:11px; color:var(--p-ink); }'
|
|
10637
11124
|
+ '.aptx-panel .aptx-searchnav .aptx-gbtn:last-child { margin-right:0; }'
|
|
10638
|
-
+ '.aptx-found-halo { opacity:
|
|
10639
|
-
+ '@keyframes aptx-halo-pulse { 0%,100% { transform:scale(1); opacity:
|
|
11125
|
+
+ '.aptx-found-halo { opacity:' + HALO_REST_OPACITY + '; transform-box:fill-box; transform-origin:center; animation:aptx-halo-pulse 1.3s ease-in-out infinite; }'
|
|
11126
|
+
+ '@keyframes aptx-halo-pulse { 0%,100% { transform:scale(1); opacity:' + HALO_REST_OPACITY + '; } 50% { transform:scale(2.5); opacity:0.12; } }'
|
|
10640
11127
|
+ '@media (prefers-reduced-motion: reduce) { .aptx-found-halo { animation:none; } }'
|
|
10641
11128
|
+ '.aptx-panel .aptx-zoomgrid { display:flex; flex-direction:column; align-items:stretch; }'
|
|
10642
11129
|
+ '.aptx-panel .aptx-zoomgrid > input[type=button] { width:100%; margin-right:0; }'
|
|
@@ -11180,6 +11667,25 @@ function (root, d3, forester, phyloXml) {
|
|
|
11180
11667
|
setTimeout(go, 150);
|
|
11181
11668
|
}
|
|
11182
11669
|
|
|
11670
|
+
// Record how long a redraw that began at `start` took, up to the frame
|
|
11671
|
+
// that painted it: the second animation frame from here runs after that
|
|
11672
|
+
// paint, whether the redraw ran in a frame or just after one. `share`
|
|
11673
|
+
// scales the measurement (a first draw stands in for a redraw at a
|
|
11674
|
+
// third, see launchInto). A hidden tab runs no frames, and the last
|
|
11675
|
+
// estimate stands.
|
|
11676
|
+
function timeRedraw(start, seq, share) {
|
|
11677
|
+
if (typeof requestAnimationFrame !== 'function') {
|
|
11678
|
+
return;
|
|
11679
|
+
}
|
|
11680
|
+
requestAnimationFrame(function () {
|
|
11681
|
+
requestAnimationFrame(function () {
|
|
11682
|
+
if (seq === _launchSeq) {
|
|
11683
|
+
_redrawMs = (performance.now() - start) * share;
|
|
11684
|
+
}
|
|
11685
|
+
});
|
|
11686
|
+
});
|
|
11687
|
+
}
|
|
11688
|
+
|
|
11183
11689
|
function makeDialogShell(id, title, width) {
|
|
11184
11690
|
let existing = document.getElementById(id);
|
|
11185
11691
|
if (existing) {
|