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/archaeopteryx.js CHANGED
@@ -20,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.5.0
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.5.0';
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
- // Above this many nodes the first draw is deferred by one frame behind a
287
- // "working" card, so the page shows it is busy instead of appearing frozen,
288
- // and later redraws coalesce instead of running synchronously. Below it
289
- // everything stays synchronous exactly as before -- the deferral buys
290
- // nothing on a tree that draws in a few hundred milliseconds.
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
- // Raised from 2000 to 3000 on 2026-09-11: a redraw of the 18,512-node tree
293
- // went from ~4.2 s to ~1.9 s that day, so the size at which a draw is slow
294
- // enough to need announcing moved up with it.
295
- const BIG_TREE_NODES = 3000;
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 (big trees only): the arguments of every
585
- // scheduleUpdate() call made since, merged, so that one update() serves
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
- // Work to run once the pending redraw has happened (big trees only):
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
- // One set of listeners on the tree group instead of five on every node.
1546
+ // Hover and click find their node by POSITION, not by element.
1536
1547
  //
1537
- // These used to be attached in the node enter: click and contextmenu on
1538
- // each g.node, and mouseover/mousemove/mouseout on each
1539
- // circle.nodeCircleOptions. On an 18,512-node tree that is 92,560
1540
- // registrations on the first draw, and it measured 300-450 ms of one.
1541
- // Mouse events bubble, so one listener per type on the group does the same
1542
- // work -- find which node the event came from, and hand the handler its
1543
- // datum. The handlers never used `this`, which is what makes this a
1544
- // straight substitution rather than a rewrite.
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
- // Note it is mouseover/mouseout, NOT mouseenter/mouseleave: only the
1547
- // former pair bubbles, so only the former pair can be delegated.
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
- // d3 stores a node's datum on the element as __data__, which is all
1551
- // selection.datum() reads. Going straight to it avoids allocating a
1552
- // selection on every mousemove.
1553
- function datumOf(el) {
1554
- return el ? el.__data__ : null;
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
- // The hover target stays the invisible 5px circle rather than the whole
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
- if (!t || !t.classList || !t.classList.contains('nodeCircleOptions')) {
1562
- return null;
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 datumOf(t.parentNode);
1582
+ return overTree(event) ? nodeAtPointer(event) : null;
1565
1583
  }
1566
1584
 
1567
- // Click and the context menu take the whole node group, so the node's
1568
- // LABEL works as a target too -- a much easier thing to aim at than a
1569
- // 5px circle.
1570
- function nodeDatum(event) {
1571
- let t = event.target;
1572
- return datumOf(t && t.closest ? t.closest('g.node') : null);
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
- group
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('mouseover', function (event) {
1590
- let d = hoverDatum(event);
1591
- if (d) {
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('mouseout', function (event) {
1602
- if (hoverDatum(event)) {
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 -- at once on a small tree, coalesced and deferred on a big one.
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. Every call
2569
- // made in the same tick now collapses into ONE update().
2570
- // - The yield. The redraw runs behind the "working" card on the next
2571
- // frame, so a click is answered by a visible card instead of a page
2572
- // that has stopped responding.
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
- showBusy(_container, 'Redrawing ' + _basicTreeProperties.nodeCount.toLocaleString() + ' nodes');
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
- hideBusy();
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 (a small
2618
- // tree, or nothing scheduled), else after the deferred update() runs.
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 || (d.id = ++_i);
2948
+ .data(prepareNodeDrawing(nodes), function (d) {
2949
+ return d.id;
2897
2950
  });
2898
2951
 
2899
- // No listeners here: click, contextmenu and the hover trio are
2900
- // delegated once to the tree group -- see attachNodeEventDelegation().
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
- if (anySearchHits()) {
3132
- node.select('circle.foundHalo')
3133
- .attr('class', function (d) {
3134
- // the animated class ONLY on hits: hundreds of idle infinite
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
- let nodeUpdate = animateOrSet(node, transitionDuration)
3189
- .attr('transform', function (d) {
3190
- return nodeTransform(d);
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 > 0
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; // computed in syncOptionalNodeChildren
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(makeBranchLengthLabel);
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(makeConfidenceValuesLabel);
3189
+ nodeChild('text.conflabel').text(function (d) {
3190
+ return d._confText;
3191
+ });
3218
3192
  }
3219
3193
  if (_state.showBranchEvents) {
3220
- nodeChild('text.brancheventlabel').text(makeBranchEventsLabel);
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
- // No .attr('d', elbow) before the join: it ran elbow() for all 18k+
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
- // The class is deliberately NOT "link": these paths live under
3330
- // _svgGroup, so that name put them in the way of selectAll('path.link')
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
- let makeNodeVisShape = function (node) {
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 d3.symbol().type(d3SymbolType(vis.shapeScale(value))).size(nodeSymbolArea())();
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 d3.symbol().type(d3SymbolType(style.shape)).size(nodeSymbolArea())();
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
- // No search running means no halo anywhere -- one pass instead of
3859
- // 18,512 set lookups plus 18,512 DOM queries.
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
- node.each(function (d) {
3870
- let g = this;
3871
-
3872
- // The label the update is about to draw. Empty is the common case
3873
- // on a big tree: Auto-hide Labels blanks them exactly when there
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
- if (wantBl && !g.querySelector('text.bllabel')) {
3907
- d3.select(g).append('text')
3908
- .attr('class', 'bllabel')
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 && !g.querySelector('text.conflabel')) {
3913
- d3.select(g).append('text')
3914
- .attr('class', 'conflabel')
3915
- .attr('text-anchor', 'middle')
3916
- .style('font-family', font);
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 && !g.querySelector('text.brancheventlabel')) {
3919
- d3.select(g).append('text')
3920
- .attr('class', 'brancheventlabel')
3921
- .attr('text-anchor', 'middle')
3922
- .style('font-family', font);
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
- let dot = g.querySelector('circle.suppdot');
3926
- if (showSupportDot(d)) {
3927
- if (!dot) {
3928
- d3.select(g).append('circle')
3929
- .attr('class', 'suppdot')
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
- let elbow = function (d) {
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 'M' + d.source.ux + ',' + d.source.uy + 'L' + d.target.ux + ',' + d.target.uy;
4230
+ return 'unrooted';
4291
4231
  }
4292
- if (_state.circularDisplay) {
4293
- let sa = radialAngle(d.source.x), ta = radialAngle(d.target.x);
4294
- let sr = radialRadius(d.source.y), tr = radialRadius(d.target.y);
4295
- let sp = polarXY(sa, sr), mp = polarXY(ta, sr), tp = polarXY(ta, tr);
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' + sr + ',' + sr + ' 0 ' + large + ' ' + sweep + ' ' + mp[0] + ',' + mp[1] + 'L' + tp[0] + ',' + tp[1];
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' + d.source.y + ',' + d.source.x + 'V' + d.target.x + 'H' + d.target.y;
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
- if (nodeCount >= BIG_TREE_NODES) {
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.selectAll('g.aptx-domains').remove();
7985
- if (!domainsShown() || !_root) {
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
- let g = _svgGroup.append('g').attr('class', 'aptx-domains').style('pointer-events', 'none');
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
- let defs = g.append('defs');
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
- let t = g.append('g').attr('transform', 'rotate(' + labelAngleDeg(d) + ')');
8019
- drawOneArchitecture(t, forester.domainArchitectureOf(d), r0, -h / 2, h, f, false, gradientFor);
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
- let t = g.append('g').attr('transform', 'translate(' + d.ux + ',' + d.uy + ') rotate(' + labelAngleDeg(d) + ')');
8027
- drawOneArchitecture(t, forester.domainArchitectureOf(d), start, -h / 2, h, f, false, gradientFor);
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
- drawOneArchitecture(g, forester.domainArchitectureOf(d), start, d.x - (h / 2), h, f, labelsOn, gradientFor);
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
- // One architecture, in the desktop's order: the backbone, then per
8053
- // admitted domain its stepped drop shadow, the optional glow, the
8054
- // gradient body with its border, and -- rectangular only -- the name,
8055
- // when it fits the box.
8056
- function drawOneArchitecture(g, da, start, y1, h, f, labelsOn, gradientFor) {
8057
- if (!da) {
8058
- return;
8059
- }
8060
- let geo = forester.domainBoxes(da, start, f, _state.domainEvalueExponent);
8061
- g.append('rect')
8062
- .attr('x', geo.backbone.x).attr('y', y1 + (h / 2) - 0.5)
8063
- .attr('width', geo.backbone.w).attr('height', 1)
8064
- .style('fill', DOMAIN_BACKBONE_COLOR);
8065
- let fs = Math.min(_state.externalNodeFontSize, h - 2);
8066
- let font = fs + 'px ' + FONT_DEFAULTS;
8067
- geo.boxes.forEach(function (b) {
8068
- let base = domainColor(b.name);
8069
- let r = Math.min(2, Math.min(b.w, h) / 2);
8070
- DOMAIN_SHADOWS.forEach(function (s) {
8071
- domainRect(g, b.x + s[0], y1 + s[1], b.w, h, r)
8072
- .style('fill', DOMAIN_SHADOW_COLOR)
8073
- .style('fill-opacity', s[2] / 255);
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
- if (_state.domainGlow) {
8076
- DOMAIN_GLOWS.forEach(function (gl) {
8077
- let o = gl[0];
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
- domainRect(g, b.x, y1, b.w, h, r)
8084
- .style('fill', gradientFor(base))
8085
- .style('stroke', forester.domainDarken(base, 0.24))
8086
- .style('stroke-width', 1);
8087
- if (labelsOn && b.name && fs > 4 && legendTextWidth(b.name, font) <= b.w - 4) {
8088
- g.append('text')
8089
- .attr('x', b.x + (b.w / 2)).attr('y', y1 + (h / 2))
8090
- .attr('dy', '0.35em')
8091
- .attr('text-anchor', 'middle')
8092
- .style('font', font)
8093
- .style('fill', forester.domainLabelInk(base))
8094
- .text(b.name);
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:0.35; transform-box:fill-box; transform-origin:center; animation:aptx-halo-pulse 1.3s ease-in-out infinite; }'
10639
- + '@keyframes aptx-halo-pulse { 0%,100% { transform:scale(1); opacity:0.35; } 50% { transform:scale(2.5); opacity:0.12; } }'
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) {