archaeopteryx 3.10.0 → 3.11.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.10.0
23
+ // v 3.11.0
24
24
  // 2026-09-17
25
25
  //
26
26
  // Archaeopteryx.js is a software tool for the visualization and
@@ -103,7 +103,7 @@ function (root, d3, forester, phyloXml) {
103
103
  // IIFE's own function name -- a plain object says what it is.)
104
104
  let archaeopteryx = {};
105
105
 
106
- const VERSION = '3.10.0';
106
+ const VERSION = '3.11.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';
@@ -363,6 +363,11 @@ function (root, d3, forester, phyloXml) {
363
363
  const DOWNLOAD_BUTTON = 'dl_b';
364
364
  const DYNAHIDE_CB = 'dynahide_cb';
365
365
  const MSA_CB = 'msa_cb';
366
+ const HEATMAP_CB = 'heatmap_cb';
367
+ const HEATMAP_CONTROLS = 'heatmap_controls';
368
+ const HEATMAP_ORDER_SELECT = 'heatmap_order';
369
+ const HEATMAP_REORDER_BUTTON = 'heatmap_reorder';
370
+ const HEATMAP_REORDER_DIALOG = 'heatmap_reorder_dialog';
366
371
  const DOMAINS_CB = 'domains_cb';
367
372
  const DOMAIN_CONTROLS = 'domain_controls';
368
373
  const DOMAIN_WIDTH_DEC = 'domain_width_dec';
@@ -572,11 +577,47 @@ function (root, d3, forester, phyloXml) {
572
577
  const DOMAIN_LABEL_MODES = ['none', 'domains', 'legend'];
573
578
  let _domain = null; // {width, palette, next, legendFrac}, set per launch
574
579
  let _domainReserve = 0; // horizontal px reserved for the tracks, set with _w
580
+ // ------ heat map (the desktop's MATRIX annotation columns) ------
581
+ const HEATMAP_TRACK_GAP = 8; // px between what is left of the tree and the matrix
582
+ const HEATMAP_PREF_COL_W = 14; // a column never grows past this
583
+ const HEATMAP_MIN_COL_W = 3; // ... nor shrinks below it: past that the matrix is windowed
584
+ const HEATMAP_MIN_BAND_PX = 60; // the window never shrinks below this
585
+ const HEATMAP_MAX_VIEWPORT_FRACTION = 0.45; // nor grows past this share of the display
586
+ const HEATMAP_MIN_TREE_PX = 220; // the tree itself never shrinks below this
587
+ const HEATMAP_MIN_COLUMNS = 2; // one column is a stripe, not a matrix: the toggle is not offered
588
+ const HEATMAP_LABEL_MAX_PX = 96; // a turned column label is clipped to this
589
+ const HEATMAP_LABEL_MIN_COL_W = 7; // ... and dropped where the columns are thinner than this
590
+ const HEATMAP_SCALE_BAR_W = 148; // the gradient in the legend
591
+ const HEATMAP_SCALE_BAR_H = 8;
592
+ const HEATMAP_SCALE_RESERVE = 30; // vertical room for that legend and its numbers
593
+ const HEATMAP_HEAD_GAP = 5; // between the last row and the turned labels
594
+ const HEATMAP_NOT_ASSESSED = 'not assessed'; // what a blank cell says -- it does NOT say zero
595
+ const HEATMAP_RADIAL_GAP = 7; // px from the label ring to the first column ring
596
+ const HEATMAP_RADIAL_END_GAP = 8; // ... and kept free past the last one
597
+ const HEATMAP_RADIAL_MAX_FRACTION = 1.2; // the ring stack, against the tree's own radius
598
+ const HEATMAP_RADIAL_MIN_RING = 1; // a ring never thinner than this
599
+ const HEATMAP_RADIAL_MIN_NAMED_RING = 7; // ... nor named when it is thinner than this
600
+ // Neighbouring sectors OVERLAP by this much rather than meeting exactly.
601
+ // Two antialiased edges that share a coordinate each cover about half the
602
+ // boundary pixel, and a half over a half does not composite back to a
603
+ // solid edge -- it leaves the background showing through as a dotted
604
+ // line all round the rings. Measured on the sparse demo: 6987 pixels
605
+ // within 1.2px of a ring boundary came out background-coloured.
606
+ const HEATMAP_RING_BLEED = 1.2;
607
+ const HEATMAP_BLANK = 'blank'; // the run-merger's stand-in for a cell nobody filled in
608
+ const HEATMAP_DENDRO_GAP = 5; // px between the dendrogram's leaves and the first row
609
+ const HEATMAP_DENDRO_MIN_BAND = 24; // the band it is drawn in, from the label font
610
+ const HEATMAP_DENDRO_MAX_BAND = 72;
611
+ let _heatmapReserve = 0; // horizontal px reserved for the matrix, set with _w
612
+ let _heatmapModel = null; // forester.heatmapColumns of the WHOLE tree, cached per launch
613
+ let _heatmapColor = null; // the one scale every column is painted on
614
+ let _heatmapColOffset = 0; // first shown column, while the matrix is windowed
615
+ let _heatmapGeom = null; // the last draw's geometry, for the hover readout
575
616
  // ------ time axis (the desktop's geologic / calendar overlays) ------
576
617
  const TIME_GEO_RESERVE = 52; // two ICS band rows + the Ma ruler
577
618
  const TIME_CAL_RESERVE = 26; // the calendar year ruler
578
619
  const TIME_BAND_ROW_H = 13;
579
- const HPD_BAR_COLOR = 'rgba(70,130,220,0.35)'; // translucent blue, FigTree-like
620
+ const HPD_BAR_COLOR = 'rgba(70,130,220,0.35)'; // translucent blue, the usual HPD tint
580
621
  const FOSSIL_BAR_COLOR = 'rgba(150,100,55,0.86)'; // opaque-ish sepia
581
622
  let _timeInfo = null; // forester.timeAxisInfo, recomputed per render
582
623
  let _timeTree = false; // forester.isTimeTree: never re-rooted
@@ -1102,6 +1143,7 @@ function (root, d3, forester, phyloXml) {
1102
1143
  // view=[-254,-10 1497x800] with rootOffset=254, so "does it fit" said
1103
1144
  // no by precisely 254px and the overview appeared after every Fit.
1104
1145
  maxX += calcMaxTreeLengthForDisplay() - _settings.rootOffset + _domainReserve;
1146
+ minY -= heatmapTopReserve(); // the dendrogram band stands above the first row
1105
1147
  if (scaleBarShown()) {
1106
1148
  maxY += SCALE_BAR_RESERVE; // the bar sits under the last row
1107
1149
  }
@@ -1426,6 +1468,30 @@ function (root, d3, forester, phyloXml) {
1426
1468
  .style('opacity', 0.95); // only the fade -- the rest is set at creation
1427
1469
  }
1428
1470
 
1471
+ // Puts a hover readout beside the pointer -- to its right and below --
1472
+ // flipping to the other side when that would leave the window. The readout's
1473
+ // OWN measured size decides, because .aptx-tip is width:max-content and so
1474
+ // is as wide as whatever it happens to be saying. Guessing instead, with a
1475
+ // fixed shift, left a 181px readout floating a hundred pixels clear of the
1476
+ // cell it described; a matrix that hugs the right edge flips on every cell,
1477
+ // so the guess was what the reader saw every time.
1478
+ const HOVER_READOUT_GAP = 14;
1479
+
1480
+ function placeHoverReadout(el, event) {
1481
+ let w = el.offsetWidth;
1482
+ let h = el.offsetHeight;
1483
+ let left = event.pageX + HOVER_READOUT_GAP;
1484
+ if ((left + w) > (window.scrollX + window.innerWidth)) {
1485
+ left = Math.max(window.scrollX + 2, event.pageX - HOVER_READOUT_GAP - w);
1486
+ }
1487
+ let top = event.pageY + HOVER_READOUT_GAP;
1488
+ if ((top + h) > (window.scrollY + window.innerHeight)) {
1489
+ top = Math.max(window.scrollY + 2, event.pageY - HOVER_READOUT_GAP - h);
1490
+ }
1491
+ el.style.left = left + 'px';
1492
+ el.style.top = top + 'px';
1493
+ }
1494
+
1429
1495
  function mousemove(event, d) {
1430
1496
 
1431
1497
  let mo_text = nodeDataText(d);
@@ -1436,10 +1502,8 @@ function (root, d3, forester, phyloXml) {
1436
1502
  if (_panelTheme) {
1437
1503
  tip.classList.add('aptx-' + _panelTheme);
1438
1504
  }
1439
- _node_mouseover_div
1440
- .html(markUpDataLabels(escapeHtmlKeepBreaks(mo_text)))
1441
- .style('left', (event.pageX + 14) + 'px')
1442
- .style('top', (event.pageY + 14) + 'px');
1505
+ _node_mouseover_div.html(markUpDataLabels(escapeHtmlKeepBreaks(mo_text)));
1506
+ placeHoverReadout(tip, event); // this one used to run off the edge instead
1443
1507
  }
1444
1508
 
1445
1509
  // ----------------------------
@@ -2731,6 +2795,22 @@ function (root, d3, forester, phyloXml) {
2731
2795
  _msaReserve = 0;
2732
2796
  // and the domain tracks reserve their column past the labels
2733
2797
  _domainReserve = domainReserve();
2798
+ // The heat map is budgeted FIRST of the two right-hand tracks: it
2799
+ // wants a fixed, finite width (its columns at their preferred size
2800
+ // and no more), while the alignment's band is a WINDOW that scrolls
2801
+ // and so can give way without losing anything. Budgeting the
2802
+ // alignment first left an 18-column matrix a 60px sliver.
2803
+ _heatmapReserve = 0;
2804
+ if (heatmapShown() && !radialDisplay()) {
2805
+ let cols = heatmapModel().refs.length;
2806
+ let vp0 = svgSize();
2807
+ let vw0 = Math.min(_displayWidth, (vp0 && vp0.w) ? vp0.w : _displayWidth);
2808
+ let hband = Math.max(HEATMAP_MIN_BAND_PX, Math.round(vw0 * HEATMAP_MAX_VIEWPORT_FRACTION));
2809
+ let hmax = _displayWidth - calcMaxTreeLengthForDisplay() - _domainReserve
2810
+ - HEATMAP_TRACK_GAP - HEATMAP_MIN_TREE_PX;
2811
+ hband = Math.max(HEATMAP_MIN_BAND_PX, Math.min(hband, hmax));
2812
+ _heatmapReserve = HEATMAP_TRACK_GAP + Math.min(cols * HEATMAP_PREF_COL_W, hband);
2813
+ }
2734
2814
  if (msaShown()) {
2735
2815
  let fullPx = _basicTreeProperties.maxMolSeqLength * MSA_COL_WIDTH;
2736
2816
  // budgeted from the VIEWPORT: sizing it from the zoomed layout
@@ -2741,11 +2821,12 @@ function (root, d3, forester, phyloXml) {
2741
2821
  let band = Math.max(MSA_MIN_BAND_PX, Math.round(vw * MSA_MAX_VIEWPORT_FRACTION));
2742
2822
  // a wide alignment must not squeeze the tree itself away: the
2743
2823
  // band yields until the tree keeps its minimum share
2744
- let maxBand = _displayWidth - calcMaxTreeLengthForDisplay() - _domainReserve - MSA_TRACK_GAP - MSA_MIN_TREE_PX;
2824
+ let maxBand = _displayWidth - calcMaxTreeLengthForDisplay() - _domainReserve
2825
+ - _heatmapReserve - MSA_TRACK_GAP - MSA_MIN_TREE_PX;
2745
2826
  band = Math.max(MSA_MIN_BAND_PX, Math.min(band, maxBand));
2746
2827
  _msaReserve = MSA_TRACK_GAP + Math.min(fullPx, band);
2747
2828
  }
2748
- _w = _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve - _domainReserve;
2829
+ _w = _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve - _domainReserve - _heatmapReserve;
2749
2830
  if (_w < 1) {
2750
2831
  _w = 1;
2751
2832
  }
@@ -2757,7 +2838,8 @@ function (root, d3, forester, phyloXml) {
2757
2838
 
2758
2839
  _clusterH = Math.max(40, _displayHeight - (2 * TOP_AND_BOTTOM_BORDER_HEIGHT)
2759
2840
  - bottomOverlayReserve());
2760
- _treeFn = _treeFn.size([_clusterH, _w]);
2841
+ let _topReserve = topOverlayReserve();
2842
+ _treeFn = _treeFn.size([Math.max(20, _clusterH - _topReserve), _w]);
2761
2843
 
2762
2844
  // A collapsed clade is a leaf that asks for more than one row: the
2763
2845
  // separation between neighbouring leaves is the mean of their
@@ -2780,7 +2862,7 @@ function (root, d3, forester, phyloXml) {
2780
2862
  });
2781
2863
  _treeFn(hierarchy);
2782
2864
  hierarchy.each(function (hn) {
2783
- hn.data.x = hn.x;
2865
+ hn.data.x = hn.x + _topReserve;
2784
2866
  hn.data.y = hn.y;
2785
2867
  hn.data.depth = hn.depth;
2786
2868
  });
@@ -3270,7 +3352,7 @@ function (root, d3, forester, phyloXml) {
3270
3352
  }
3271
3353
 
3272
3354
  // circular: a thin dashed connector from each external node out to the
3273
- // common label ring (so labels line up like iTOL's aligned display).
3355
+ // common label ring, so every label lines up at the same radius.
3274
3356
  _svgGroup.selectAll('g.aptx-radial-conn').remove();
3275
3357
  if (_state.circularDisplay) {
3276
3358
  // tips while their labels show; a collapsed clade always (its label
@@ -3316,6 +3398,7 @@ function (root, d3, forester, phyloXml) {
3316
3398
  drawDomainArchitectures();
3317
3399
  drawDomainLegend();
3318
3400
  drawMsaTrack();
3401
+ drawHeatmapTrack();
3319
3402
  drawTimeOverlays();
3320
3403
  rebuildOverview(); // measured AFTER the overlays, so the bbox is this frame's
3321
3404
  updateSearchHitNavigation();
@@ -4844,6 +4927,9 @@ function (root, d3, forester, phyloXml) {
4844
4927
  const STATE_KEYS = [
4845
4928
  'layout',
4846
4929
  'showMsa',
4930
+ 'showHeatmap',
4931
+ 'heatmapColumnOrder',
4932
+ 'heatmapManualOrder',
4847
4933
  'showDomainArchitectures',
4848
4934
  'domainLabels',
4849
4935
  'domainGlow',
@@ -5076,6 +5162,39 @@ function (root, d3, forester, phyloXml) {
5076
5162
  _state.showMsa = _basicTreeProperties.alignedMolSeqs === true
5077
5163
  && _basicTreeProperties.maxMolSeqLength > 0;
5078
5164
  }
5165
+ // The heat map is offered whenever the tree carries two or more
5166
+ // numeric per-tip fields, which is common enough that turning it on
5167
+ // by itself would be an opinion about the tree rather than a service
5168
+ // -- the alignment track, by contrast, is on when there IS one.
5169
+ if (_state.showHeatmap === undefined) {
5170
+ _state.showHeatmap = false;
5171
+ }
5172
+ // The columns' order. Left unset it is resolved from the data on first
5173
+ // use (heatmapMode / forester.heatmapDefaultOrder): a clustered order,
5174
+ // because reading block structure is what a heat map beside a tree is
5175
+ // FOR, with the distance chosen by what the values are. An explicit
5176
+ // value always wins and is never re-derived -- including 'document',
5177
+ // which keeps the order the producer wrote the fields in.
5178
+ if (_state.heatmapColumnOrder === undefined) {
5179
+ _state.heatmapColumnOrder = null;
5180
+ } else if (_state.heatmapColumnOrder !== null
5181
+ && forester.HEATMAP_ORDER_MODES.indexOf(_state.heatmapColumnOrder) < 0) {
5182
+ throw new Error(ERROR + '"heatmapColumnOrder" must be one of '
5183
+ + forester.HEATMAP_ORDER_MODES.join(', '));
5184
+ }
5185
+ // The reader's own arrangement, as a list of refs. It is only read
5186
+ // while the order is 'manual', and a ref it names that the tree has
5187
+ // not got is ignored rather than fatal -- an order outliving one of
5188
+ // its columns is ordinary (see forester.heatmapManualOrder).
5189
+ if (_state.heatmapManualOrder === undefined) {
5190
+ _state.heatmapManualOrder = null;
5191
+ } else if (_state.heatmapManualOrder !== null
5192
+ && !(Array.isArray(_state.heatmapManualOrder)
5193
+ && _state.heatmapManualOrder.every(function (r) {
5194
+ return typeof r === 'string';
5195
+ }))) {
5196
+ throw new Error(ERROR + '"heatmapManualOrder" must be an array of property refs');
5197
+ }
5079
5198
  // A tree with protein domain architectures on its tips draws them
5080
5199
  // from the start (the desktop switches its toggle on at load), again
5081
5200
  // unless the caller decided. The track width is set at the first
@@ -5729,6 +5848,10 @@ function (root, d3, forester, phyloXml) {
5729
5848
  _radialRotation = 0;
5730
5849
  _radialLabelsHorizontal = false;
5731
5850
  _msaColOffset = 0;
5851
+ _heatmapColOffset = 0;
5852
+ _heatmapModel = null; // the columns and the scale belong to THIS tree
5853
+ _heatmapColor = null;
5854
+ _heatmapGeom = null;
5732
5855
  // ---- synchronous: everything that can throw, and the cheap setup --
5733
5856
  //
5734
5857
  // initializeSettings validates values and throws on a bad one, so it
@@ -6463,6 +6586,10 @@ function (root, d3, forester, phyloXml) {
6463
6586
  if (edited && _domain) {
6464
6587
  _domain.palette = null; // the tree changed: the domain names are dealt their colours again
6465
6588
  }
6589
+ if (edited) {
6590
+ _heatmapModel = null; // ... and the heat map's columns and scale are derived from it again
6591
+ _heatmapColor = null;
6592
+ }
6466
6593
  if (!_vis) {
6467
6594
  return;
6468
6595
  }
@@ -6501,8 +6628,10 @@ function (root, d3, forester, phyloXml) {
6501
6628
  // part of the real spans (an anchor ratio taken from anything else
6502
6629
  // once flung the whole tree off-screen after three X+ presses).
6503
6630
  return {
6504
- horizontal: Math.max(1, _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve - _domainReserve),
6505
- vertical: Math.max(40, _displayHeight - (2 * TOP_AND_BOTTOM_BORDER_HEIGHT) - bottomOverlayReserve())
6631
+ horizontal: Math.max(1, _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve
6632
+ - _domainReserve - _heatmapReserve),
6633
+ vertical: Math.max(40, _displayHeight - (2 * TOP_AND_BOTTOM_BORDER_HEIGHT)
6634
+ - bottomOverlayReserve() - topOverlayReserve())
6506
6635
  };
6507
6636
  }
6508
6637
 
@@ -7214,13 +7343,8 @@ function (root, d3, forester, phyloXml) {
7214
7343
  if (_panelTheme) {
7215
7344
  tip_el.classList.add('aptx-' + _panelTheme);
7216
7345
  }
7217
- // the track hugs the right edge, so the readout flips to the left
7218
- // of the pointer when it would otherwise run off the window
7219
- let left = (event.pageX + 290) > window.innerWidth ? (event.pageX - 280) : (event.pageX + 14);
7220
- _node_mouseover_div
7221
- .html(markUpDataLabels(escapeHtmlKeepBreaks(txt)))
7222
- .style('left', left + 'px')
7223
- .style('top', (event.pageY + 14) + 'px');
7346
+ _node_mouseover_div.html(markUpDataLabels(escapeHtmlKeepBreaks(txt)));
7347
+ placeHoverReadout(tip_el, event);
7224
7348
  _node_mouseover_div.transition().duration(100).style('opacity', 0.95);
7225
7349
  }
7226
7350
 
@@ -7233,6 +7357,1084 @@ function (root, d3, forester, phyloXml) {
7233
7357
  scheduleUpdate(null, 0);
7234
7358
  }
7235
7359
 
7360
+ // ===================== Heat map =====================
7361
+ // The desktop's MATRIX annotation columns: one cell per (tip, numeric
7362
+ // per-tip property) to the right of the labels, every column painted on
7363
+ // ONE colour scale so that a cell can be read across columns -- the
7364
+ // matrix half of a clustergram.
7365
+ //
7366
+ // Which refs are columns, the order they take and what the scale spans
7367
+ // are forester.heatmapColumns, derived once over the WHOLE tree:
7368
+ // candidacy is decided on the tree, never per view, so diving into a
7369
+ // subtree narrows the rows and leaves the columns and the scale alone.
7370
+ // A colour therefore means the same number in every view.
7371
+ //
7372
+ // A cell nobody filled in is never painted as the scale's low end: on a
7373
+ // presence/absence matrix that would state the opposite of what the file
7374
+ // says. It is drawn as an OUTLINED empty box, with a key beside the scale
7375
+ // -- leaving it bare is not enough, see the measurement at the draw.
7376
+ //
7377
+ // Rectangular and CIRCULAR: the columns stand on the tips' common edge,
7378
+ // which a circular fan has as a ring (drawHeatmapRings) and an unrooted one
7379
+ // has not got at all -- every tip there ends at its own radius.
7380
+
7381
+ function heatmapModel() {
7382
+ if (!_heatmapModel && _treeData) {
7383
+ _heatmapModel = forester.heatmapColumns(_treeData);
7384
+ }
7385
+ return _heatmapModel;
7386
+ }
7387
+
7388
+ // Offered when the tree holds at least two numeric per-tip refs that
7389
+ // actually carry values: one column is a stripe rather than a matrix,
7390
+ // and a scale needs something to span.
7391
+ function heatmapAvailable() {
7392
+ let m = heatmapModel();
7393
+ return !!m && m.refs.length >= HEATMAP_MIN_COLUMNS && m.min !== null && m.max !== null;
7394
+ }
7395
+
7396
+ // Circular draws the columns as RINGS (drawHeatmapRings); unrooted cannot,
7397
+ // because its tips end at different radii so a column has no ring to be.
7398
+ function heatmapShown() {
7399
+ return _state.showHeatmap === true && _state.unrootedDisplay !== true && heatmapAvailable();
7400
+ }
7401
+
7402
+ // The one scale, spanning the whole tree's values. Built lazily and kept
7403
+ // with the model, so every redraw and every view paints the same colours.
7404
+ function heatmapColorOf(v) {
7405
+ let m = heatmapModel();
7406
+ if (!m || v === null) {
7407
+ return null;
7408
+ }
7409
+ if (!_heatmapColor) {
7410
+ _heatmapColor = (m.max > m.min)
7411
+ ? d3.scaleLinear().range(VIS_COLOR_RAMP).domain([m.min, (m.min + m.max) / 2, m.max])
7412
+ : function () {
7413
+ // one value everywhere: a flat matrix, not a gradient
7414
+ // over a zero-width domain (which d3 maps to its last stop)
7415
+ return VIS_COLOR_RAMP[1];
7416
+ };
7417
+ }
7418
+ return _heatmapColor(v);
7419
+ }
7420
+
7421
+ // Enough digits to tell two cells apart, without a tail of noise on data
7422
+ // that is plainly integral.
7423
+ function heatmapNum(v) {
7424
+ if (v === null || v === undefined) {
7425
+ return '';
7426
+ }
7427
+ if (v !== 0 && (Math.abs(v) >= 100000 || Math.abs(v) < 0.001)) {
7428
+ return v.toExponential(2);
7429
+ }
7430
+ return String(Math.round(v * 1000) / 1000);
7431
+ }
7432
+
7433
+ // A column is as wide as the band allows, never wider than is useful and
7434
+ // never thinner than is legible -- past that the matrix is windowed and
7435
+ // scrolled, as a long alignment is.
7436
+ function heatmapColWidth() {
7437
+ let m = heatmapModel();
7438
+ if (!m || m.refs.length < 1) {
7439
+ return HEATMAP_PREF_COL_W;
7440
+ }
7441
+ let band = Math.max(1, _heatmapReserve - HEATMAP_TRACK_GAP);
7442
+ return Math.max(HEATMAP_MIN_COL_W, Math.min(HEATMAP_PREF_COL_W, band / m.refs.length));
7443
+ }
7444
+
7445
+ function heatmapLabelsFit() {
7446
+ return heatmapColWidth() >= HEATMAP_LABEL_MIN_COL_W;
7447
+ }
7448
+
7449
+ function heatmapLabelFont() {
7450
+ return Math.max(7, Math.min(11, Math.round(heatmapColWidth()))) + 'px ' + FONT_DEFAULTS;
7451
+ }
7452
+
7453
+ // The turned column labels' band: the longest label, clipped. Asked for
7454
+ // before the draw (bottomOverlayReserve sizes the layout with it), so it
7455
+ // is derived from the model and the reserve, never from the drawing.
7456
+ function heatmapHeadHeight() {
7457
+ if (!heatmapLabelsFit()) {
7458
+ return 0;
7459
+ }
7460
+ let font = heatmapLabelFont();
7461
+ let w = 0;
7462
+ heatmapModel().refs.forEach(function (r) {
7463
+ w = Math.max(w, legendTextWidth(r.label, font));
7464
+ });
7465
+ return Math.ceil(Math.min(w, HEATMAP_LABEL_MAX_PX));
7466
+ }
7467
+
7468
+ // What the strip actually paints: the turned names and the scale.
7469
+ function heatmapStripHeight() {
7470
+ return HEATMAP_HEAD_GAP + heatmapHeadHeight() + HEATMAP_SCALE_RESERVE;
7471
+ }
7472
+
7473
+ // What the layout keeps clear for it. The alignment's navigation bar is
7474
+ // fixed at the viewport bottom and would cover whatever ends under it, so
7475
+ // when that bar is out the strip has to end above it -- as the alignment's
7476
+ // own strip does (MSA_NAV_RESERVE).
7477
+ function heatmapBottomReserve() {
7478
+ if (!heatmapShown() || radialDisplay()) {
7479
+ return 0; // the rings carry their own names and legend
7480
+ }
7481
+ return heatmapStripHeight() + (msaShown() ? MSA_NAV_RESERVE : 0);
7482
+ }
7483
+
7484
+ // The mode in force: what the caller or the reader chose, else the one the
7485
+ // data asks for.
7486
+ //
7487
+ // The resolved choice is cached on the MODEL, which is where it belongs --
7488
+ // it is a fact about this tree's values and it dies with them. It is not
7489
+ // written back into _state, but not because _state would leak it: every
7490
+ // launch AND every tree switch rebuilds _state from the original config
7491
+ // (switchToTree -> launchInto -> initializeState), so a write-back could
7492
+ // not reach the next tree. Checked, because the comment that used to sit
7493
+ // here claimed exactly that protection and it would have been fiction.
7494
+ function heatmapMode() {
7495
+ if (_state.heatmapColumnOrder) {
7496
+ return _state.heatmapColumnOrder;
7497
+ }
7498
+ let m = heatmapModel();
7499
+ if (!m) {
7500
+ return 'document';
7501
+ }
7502
+ if (!m._autoMode) {
7503
+ m._autoMode = forester.heatmapDefaultOrder(_treeData, m.refs);
7504
+ }
7505
+ return m._autoMode;
7506
+ }
7507
+
7508
+ // How far this tree's own tips agree about the column order (cached with
7509
+ // the model, since it is a fact about the same values).
7510
+ function heatmapInputAgreement() {
7511
+ let m = heatmapModel();
7512
+ if (!m) {
7513
+ return null;
7514
+ }
7515
+ if (!m._agreement) {
7516
+ m._agreement = forester.heatmapInputOrderAgreement(_treeData, m.refs);
7517
+ }
7518
+ return m._agreement;
7519
+ }
7520
+
7521
+ // The columns in the order the reader asked for, with the dendrogram
7522
+ // behind that order when the order came from a clustering.
7523
+ function heatmapOrdered() {
7524
+ let m = heatmapModel();
7525
+ if (!m) {
7526
+ return null;
7527
+ }
7528
+ let mode = heatmapMode();
7529
+ // The order is kept ON the model, not in a cache beside it. The
7530
+ // desktop's equivalent was keyed on (refs, mode) and a subtree, an
7531
+ // edit or a re-import changes the VALUES while leaving both alone, so
7532
+ // a hit handed back an order of the old data and a dendrogram drawn
7533
+ // over the new -- found by code review, fixed there by invalidating at
7534
+ // the one place every such path goes through. Hanging it on the model
7535
+ // makes that structural instead: whatever drops the model drops this
7536
+ // with it, and there is no second thing to remember to clear.
7537
+ // the manual list is part of the key: it changes while the mode
7538
+ // stays 'manual', and a cache blind to that would redraw the old
7539
+ // arrangement after every move
7540
+ let manual = _state.heatmapManualOrder;
7541
+ if (!m._order || m._order.mode !== mode || m._order.manual !== manual) {
7542
+ let o = forester.heatmapOrder(_treeData, m.refs, mode, manual);
7543
+ m._order = {mode: mode, manual: manual, columns: o.columns, dendrogram: o.dendrogram};
7544
+ }
7545
+ return m._order;
7546
+ }
7547
+
7548
+ // How many columns the band can hold: every column when they fit, a window
7549
+ // otherwise. Derived from the reserve, which update() sets before it asks
7550
+ // for either overlay reserve, so the draw and the layout agree.
7551
+ function heatmapVisibleColumns() {
7552
+ let m = heatmapModel();
7553
+ if (!m || m.refs.length < 1) {
7554
+ return 0;
7555
+ }
7556
+ let bandPx = Math.max(1, _heatmapReserve - HEATMAP_TRACK_GAP);
7557
+ let visible = Math.max(1, Math.min(m.refs.length, Math.floor(bandPx / heatmapColWidth())));
7558
+ return isFinite(visible) ? visible : 0;
7559
+ }
7560
+
7561
+ // The dendrogram to draw, or null. A WINDOWED matrix gets none: the tree
7562
+ // describes every column, and drawn over some of them its merges would
7563
+ // reach columns that are not on screen -- a picture asserting a grouping
7564
+ // the reader cannot see.
7565
+ function heatmapDendrogram() {
7566
+ if (!heatmapShown() || radialDisplay()) {
7567
+ return null; // a dendrogram over a ring would have to bend; it earns nothing
7568
+ }
7569
+ let o = heatmapOrdered();
7570
+ if (!o || !o.dendrogram || o.columns.length < 3) {
7571
+ return null;
7572
+ }
7573
+ return (heatmapVisibleColumns() === o.columns.length) ? o.dendrogram : null;
7574
+ }
7575
+
7576
+ // Deliberately short: a shape to read a grouping off, not a scale anyone
7577
+ // reads a number from.
7578
+ function heatmapDendroBand() {
7579
+ return Math.max(HEATMAP_DENDRO_MIN_BAND,
7580
+ Math.min(HEATMAP_DENDRO_MAX_BAND, 3 * (_state.externalNodeFontSize + 2)));
7581
+ }
7582
+
7583
+ // Vertical room kept ABOVE the first row for the dendrogram. The band is
7584
+ // drawn at negative y, with the tree's own group, so it zooms and pans
7585
+ // with the cells it belongs to; the layout just has to leave it the space
7586
+ // and the fit has to start below it.
7587
+ function heatmapTopReserve() {
7588
+ return heatmapDendrogram() ? (heatmapDendroBand() + HEATMAP_DENDRO_GAP) : 0;
7589
+ }
7590
+
7591
+ function topOverlayReserve() {
7592
+ return heatmapTopReserve();
7593
+ }
7594
+
7595
+ function drawHeatmapTrack() {
7596
+ if (!_svgGroup) {
7597
+ return;
7598
+ }
7599
+ _svgGroup.selectAll('g.aptx-heatmap').remove();
7600
+ if (_baseSvg) {
7601
+ _baseSvg.selectAll('g.aptx-heatmap-ringlegend').remove();
7602
+ }
7603
+ if (_floatGroup) {
7604
+ _floatGroup.selectAll('g.aptx-heatmap-strip').remove();
7605
+ delete _floatStrips['aptx-heatmap-strip'];
7606
+ }
7607
+ if (!heatmapShown() || !_root) {
7608
+ _heatmapGeom = null;
7609
+ return;
7610
+ }
7611
+ if (heatmapCircular()) {
7612
+ drawHeatmapRings();
7613
+ drawHeatmapRingLegend();
7614
+ return;
7615
+ }
7616
+ let model = heatmapModel();
7617
+ let ordered = heatmapOrdered();
7618
+ let columns = ordered ? ordered.columns : model.refs;
7619
+ let tips = displayedTips().filter(function (d) {
7620
+ return d.x !== undefined;
7621
+ }).sort(function (p, q) {
7622
+ return p.x - q.x;
7623
+ });
7624
+ let total = columns.length;
7625
+ if (tips.length < 1 || total < 1) {
7626
+ _heatmapGeom = null;
7627
+ return;
7628
+ }
7629
+ let cw = heatmapColWidth();
7630
+ let bandPx = Math.max(1, _heatmapReserve - HEATMAP_TRACK_GAP);
7631
+ let visible = Math.max(1, Math.min(total, Math.floor(bandPx / cw)));
7632
+ if (!isFinite(visible)) {
7633
+ // half-initialized globals (mid-launch) can make the window NaN;
7634
+ // draw nothing rather than feeding it downstream
7635
+ _heatmapGeom = null;
7636
+ return;
7637
+ }
7638
+ let maxOffset = Math.max(0, total - visible);
7639
+ if (_heatmapColOffset > maxOffset) {
7640
+ _heatmapColOffset = maxOffset;
7641
+ }
7642
+ if (_heatmapColOffset < 0) {
7643
+ _heatmapColOffset = 0;
7644
+ }
7645
+ let offset = _heatmapColOffset;
7646
+ // The matrix ends where the alignment track begins, or at the canvas
7647
+ // edge when there is none: the fitted view has shifted the layout
7648
+ // right by rootOffset, and both reserves were taken off _w already.
7649
+ let originX = _displayWidth - _settings.rootOffset - _msaReserve - _heatmapReserve + HEATMAP_TRACK_GAP;
7650
+ let g = _svgGroup.append('g').attr('class', 'aptx-heatmap');
7651
+ let ink = _state.branchColorDefault;
7652
+
7653
+ // Row bands: each shared boundary derived ONCE, as the midpoint
7654
+ // between adjacent tip rows -- deriving it per row from y +- half a
7655
+ // row height rounds a pixel apart and paints a seam across the grid.
7656
+ let n = tips.length;
7657
+ let pad = n > 1 ? ((tips[n - 1].x - tips[0].x) / (n - 1)) / 2
7658
+ : Math.max(4, _state.externalNodeFontSize / 2);
7659
+ let bounds = new Array(n + 1);
7660
+ bounds[0] = Math.max(0, tips[0].x - pad);
7661
+ for (let r = 1; r < n; ++r) {
7662
+ bounds[r] = (tips[r - 1].x + tips[r].x) / 2;
7663
+ }
7664
+ bounds[n] = Math.min(_clusterH, tips[n - 1].x + pad);
7665
+
7666
+ // A cell nobody filled in is drawn in the background colour and OUTLINED.
7667
+ // Leaving it bare is not enough: measured on the sparse demo, a bare
7668
+ // blank stands at a contrast ratio of 13.4 against the scale's low end
7669
+ // in the light theme but 1.08 in the dark one -- that is to say, in the
7670
+ // dark theme "not assessed" and "zero" were the same picture, which is
7671
+ // the one confusion this whole design exists to prevent.
7672
+ let blanks = 0;
7673
+ // Blanks merge into one outlined band only where a cell is too narrow
7674
+ // to outline anyway: at a readable column width a run of them must stay
7675
+ // a run of CELLS, or its top and bottom edges read as a drawn rule
7676
+ // across the matrix rather than as missing data.
7677
+ let mergeBlanks = cw < HEATMAP_LABEL_MIN_COL_W;
7678
+ for (let r = 0; r < n; ++r) {
7679
+ let d = tips[r];
7680
+ let cy = Math.round(bounds[r]);
7681
+ let rh = Math.max(1, Math.round(bounds[r + 1]) - cy);
7682
+ // consecutive cells of the SAME colour merge into one rect: on a
7683
+ // presence/absence matrix that is most of them, and it is what
7684
+ // keeps a wide matrix drawable
7685
+ let runStart = -1;
7686
+ let runFill = null;
7687
+ let flush = function (endI) {
7688
+ if (runStart < 0) {
7689
+ return;
7690
+ }
7691
+ let x0 = Math.round(originX + (runStart * cw));
7692
+ let x1 = Math.round(originX + (endI * cw));
7693
+ let rect = g.append('rect').attr('x', x0).attr('y', cy)
7694
+ .attr('width', Math.max(1, x1 - x0)).attr('height', rh);
7695
+ if (runFill === HEATMAP_BLANK) {
7696
+ rect.attr('fill', _state.backgroundColorDefault)
7697
+ .attr('stroke', ink).attr('stroke-opacity', 0.45).attr('stroke-width', 1);
7698
+ } else {
7699
+ rect.attr('fill', runFill);
7700
+ }
7701
+ runStart = -1;
7702
+ runFill = null;
7703
+ };
7704
+ for (let i = 0; i < visible; ++i) {
7705
+ let v = forester.heatmapValue(d, columns[offset + i].ref);
7706
+ let fill = (v === null) ? HEATMAP_BLANK : heatmapColorOf(v);
7707
+ if (fill === HEATMAP_BLANK) {
7708
+ blanks++;
7709
+ }
7710
+ if (runFill === fill && (mergeBlanks || fill !== HEATMAP_BLANK)) {
7711
+ continue; // the run extends
7712
+ }
7713
+ flush(i);
7714
+ runStart = i;
7715
+ runFill = fill;
7716
+ }
7717
+ flush(visible);
7718
+ }
7719
+
7720
+ let rowsTop = Math.round(bounds[0]);
7721
+ let rowsBottom = Math.round(bounds[n]);
7722
+ let trackW = Math.round(originX + (visible * cw)) - Math.round(originX);
7723
+ // The frame: without it a blank cell is indistinguishable from the
7724
+ // space beside the matrix, and "not assessed" stops being visible.
7725
+ g.append('rect')
7726
+ .attr('x', Math.round(originX)).attr('y', rowsTop)
7727
+ .attr('width', trackW).attr('height', Math.max(1, rowsBottom - rowsTop))
7728
+ .attr('fill', 'none')
7729
+ .attr('stroke', ink).attr('stroke-opacity', 0.55).attr('stroke-width', 1);
7730
+
7731
+ // ---- the clustering behind the column order, above the grid ----
7732
+ // Drawn with the cells, in the tree's own group, so it zooms, pans and
7733
+ // exports with them. Heights are LINEAR in the merge distance, so a
7734
+ // block that joins low really is drawn tighter than one that joins
7735
+ // high; spacing merges evenly by rank would read more clearly and
7736
+ // would say something false.
7737
+ let dendro = heatmapDendrogram();
7738
+ if (dendro && dendro.height.length >= 1 && dendro.order.length === visible && offset === 0) {
7739
+ let band = heatmapDendroBand();
7740
+ let baseline = rowsTop - HEATMAP_DENDRO_GAP;
7741
+ let maxH = 0;
7742
+ dendro.height.forEach(function (h) {
7743
+ if (isFinite(h) && h > maxH) {
7744
+ maxH = h;
7745
+ }
7746
+ });
7747
+ let slot = new Array(dendro.order.length);
7748
+ dendro.order.forEach(function (leaf, k) {
7749
+ slot[leaf] = k;
7750
+ });
7751
+ let clusterX = new Array(dendro.height.length);
7752
+ let clusterY = new Array(dendro.height.length);
7753
+ // R's node-id convention: negative = the singleton of that index,
7754
+ // positive = the cluster formed at that stage
7755
+ let nodeX = function (id) {
7756
+ return (id < 0) ? (originX + ((slot[-id - 1] + 0.5) * cw)) : clusterX[id - 1];
7757
+ };
7758
+ let nodeY = function (id) {
7759
+ return (id < 0) ? baseline : clusterY[id - 1];
7760
+ };
7761
+ let path = '';
7762
+ for (let s = 0; s < dendro.height.length; ++s) {
7763
+ let h = dendro.height[s];
7764
+ // a merge with no finite height -- two columns that share no
7765
+ // assessed tip, so no distance was ever computed -- goes to the
7766
+ // top of the band, above every measurable merge, which is where
7767
+ // it belongs: it joined last
7768
+ let ym = (!isFinite(h) || !(maxH > 0)) ? (baseline - band) : (baseline - ((h / maxH) * band));
7769
+ let x1 = nodeX(dendro.left[s]);
7770
+ let y1 = nodeY(dendro.left[s]);
7771
+ let x2 = nodeX(dendro.right[s]);
7772
+ let y2 = nodeY(dendro.right[s]);
7773
+ path += 'M' + x1.toFixed(1) + ',' + y1.toFixed(1)
7774
+ + 'L' + x1.toFixed(1) + ',' + ym.toFixed(1)
7775
+ + 'L' + x2.toFixed(1) + ',' + ym.toFixed(1)
7776
+ + 'L' + x2.toFixed(1) + ',' + y2.toFixed(1);
7777
+ clusterX[s] = (x1 + x2) / 2;
7778
+ clusterY[s] = ym;
7779
+ }
7780
+ g.append('path').attr('class', 'aptx-heatmap-dendro')
7781
+ .attr('d', path)
7782
+ .attr('fill', 'none')
7783
+ .attr('stroke', ink)
7784
+ .attr('stroke-width', Math.max(1, _state.branchWidthDefault))
7785
+ .style('pointer-events', 'none');
7786
+ }
7787
+
7788
+ // A faint dashed guide from each tip across to its row, as the
7789
+ // alignment track draws -- skipped when the domain tracks are in
7790
+ // between, where it would run straight through their boxes.
7791
+ if (_domainReserve === 0) {
7792
+ let guideFont = _state.externalNodeFontSize + 'px ' + _state.defaultFont;
7793
+ let guideGap = _state.nodeLabelGap;
7794
+ let guideEnd = originX - 3;
7795
+ for (let r = 0; r < n; ++r) {
7796
+ let d = tips[r];
7797
+ let label = d._extLabelText || '';
7798
+ let from;
7799
+ if (label) {
7800
+ let labelX = (_state.phylogram && _state.alignPhylogram && _yScale)
7801
+ ? d.y - _yScale(d.distToRoot) + _w + guideGap
7802
+ : d.y + guideGap;
7803
+ from = labelX + legendTextWidth(label, guideFont) + 5;
7804
+ } else {
7805
+ from = d.y + makeNodeSize(d) + 4;
7806
+ }
7807
+ if (guideEnd - from < 6) {
7808
+ continue;
7809
+ }
7810
+ g.append('line')
7811
+ .attr('x1', from).attr('x2', guideEnd)
7812
+ .attr('y1', d.x).attr('y2', d.x)
7813
+ .attr('stroke', ink)
7814
+ .attr('stroke-width', 1)
7815
+ .attr('stroke-dasharray', '2,3')
7816
+ .style('stroke-opacity', 0.35)
7817
+ .style('pointer-events', 'none');
7818
+ }
7819
+ }
7820
+
7821
+ // ---- column names and the shared scale: a FLOATING strip ----
7822
+ // the strip is placed against its FULL reserve, so that a stuck strip
7823
+ // clears the alignment's navigation bar, but paints only its content
7824
+ let strip = floatStripGroup('aptx-heatmap-strip', _clusterH, heatmapBottomReserve());
7825
+ strip.append('rect').attr('x', Math.round(originX) - 2).attr('y', _clusterH)
7826
+ .attr('width', trackW + 4).attr('height', heatmapStripHeight())
7827
+ .attr('fill', _state.backgroundColorDefault);
7828
+ let labelTop = _clusterH + HEATMAP_HEAD_GAP;
7829
+ let headH = heatmapHeadHeight();
7830
+ if (headH > 0) {
7831
+ let font = heatmapLabelFont();
7832
+ for (let i = 0; i < visible; ++i) {
7833
+ let col = columns[offset + i];
7834
+ strip.append('text')
7835
+ .attr('transform', 'translate(' + (originX + (i * cw) + (cw / 2)) + ','
7836
+ + labelTop + ') rotate(-90)')
7837
+ .attr('dy', '0.32em')
7838
+ .attr('text-anchor', 'end')
7839
+ .style('font', font)
7840
+ .style('fill', _state.labelColorDefault)
7841
+ .text(clipTextToWidth(col.label, font, HEATMAP_LABEL_MAX_PX));
7842
+ }
7843
+ }
7844
+
7845
+ // the scale itself: the ramp the cells were painted from, its two
7846
+ // ends named, and the blank cell shown for what it is
7847
+ let scaleTop = labelTop + headH + 6;
7848
+ let barW = Math.round(Math.max(40, Math.min(HEATMAP_SCALE_BAR_W, trackW * 0.45)));
7849
+ let gradId = 'aptx-heatmap-ramp';
7850
+ let grad = strip.append('defs').append('linearGradient').attr('id', gradId)
7851
+ .attr('x1', 0).attr('y1', 0).attr('x2', 1).attr('y2', 0);
7852
+ VIS_COLOR_RAMP.forEach(function (c, i) {
7853
+ grad.append('stop')
7854
+ .attr('offset', ((i * 100) / (VIS_COLOR_RAMP.length - 1)) + '%')
7855
+ .attr('stop-color', c);
7856
+ });
7857
+ strip.append('rect')
7858
+ .attr('x', Math.round(originX)).attr('y', scaleTop)
7859
+ .attr('width', barW).attr('height', HEATMAP_SCALE_BAR_H)
7860
+ .attr('fill', 'url(#' + gradId + ')')
7861
+ .attr('stroke', ink).attr('stroke-opacity', 0.5).attr('stroke-width', 1);
7862
+ let numY = scaleTop + HEATMAP_SCALE_BAR_H + 10;
7863
+ strip.append('text').attr('x', Math.round(originX)).attr('y', numY)
7864
+ .attr('text-anchor', 'start')
7865
+ .style('font-size', '9px').style('fill', ink)
7866
+ .text(heatmapNum(model.min));
7867
+ strip.append('text').attr('x', Math.round(originX) + barW).attr('y', numY)
7868
+ .attr('text-anchor', 'end')
7869
+ .style('font-size', '9px').style('fill', ink)
7870
+ .text(heatmapNum(model.max));
7871
+ // the caption: what the matrix is showing, and -- when it is windowed
7872
+ // -- that there is more of it than this
7873
+ let caption = (visible < total)
7874
+ ? 'Columns ' + (offset + 1) + '–' + (offset + visible) + ' of ' + total + ', one scale (scroll over the matrix)'
7875
+ : total + ' columns, one scale';
7876
+ let capFont = '9px ' + FONT_DEFAULTS;
7877
+ let cursor = Math.round(originX) + barW + 10;
7878
+ let rightEdge = Math.round(originX) + trackW;
7879
+ // the blank's own key comes FIRST: it names a convention the reader
7880
+ // cannot guess, where the caption only repeats what is on screen
7881
+ if (blanks > 0) {
7882
+ let keyW = HEATMAP_SCALE_BAR_H + 4 + legendTextWidth(HEATMAP_NOT_ASSESSED, capFont);
7883
+ if ((rightEdge - cursor) >= keyW) {
7884
+ strip.append('rect').attr('x', cursor).attr('y', scaleTop)
7885
+ .attr('width', HEATMAP_SCALE_BAR_H).attr('height', HEATMAP_SCALE_BAR_H)
7886
+ .attr('fill', _state.backgroundColorDefault)
7887
+ .attr('stroke', ink).attr('stroke-opacity', 0.45).attr('stroke-width', 1);
7888
+ strip.append('text').attr('x', cursor + HEATMAP_SCALE_BAR_H + 4)
7889
+ .attr('y', scaleTop + HEATMAP_SCALE_BAR_H)
7890
+ .attr('text-anchor', 'start')
7891
+ .style('font', capFont).style('fill', ink).style('fill-opacity', 0.85)
7892
+ .text(HEATMAP_NOT_ASSESSED);
7893
+ cursor += keyW + 10;
7894
+ }
7895
+ }
7896
+ if ((rightEdge - cursor) > 60) {
7897
+ strip.append('text').attr('x', cursor).attr('y', scaleTop + HEATMAP_SCALE_BAR_H)
7898
+ .attr('text-anchor', 'start')
7899
+ .style('font', capFont).style('fill', ink).style('fill-opacity', 0.85)
7900
+ .text(clipTextToWidth(caption, capFont, rightEdge - cursor));
7901
+ }
7902
+
7903
+ // hover + wheel surface over the rows
7904
+ g.append('rect').attr('class', 'aptx-heatmap-hover')
7905
+ .attr('x', Math.round(originX)).attr('y', rowsTop)
7906
+ .attr('width', trackW).attr('height', Math.max(1, rowsBottom - rowsTop))
7907
+ .attr('fill', 'transparent')
7908
+ .on('mousemove', heatmapHoverMove)
7909
+ .on('mouseout', heatmapHoverOut)
7910
+ .on('wheel', function (event) {
7911
+ if (visible >= total) {
7912
+ return; // nothing to scroll: leave the wheel to the page
7913
+ }
7914
+ event.preventDefault();
7915
+ event.stopPropagation();
7916
+ let delta = Math.abs(event.deltaY) >= Math.abs(event.deltaX) ? event.deltaY : event.deltaX;
7917
+ if (delta === 0) {
7918
+ return;
7919
+ }
7920
+ let step = Math.max(1, Math.round(visible / 10));
7921
+ heatmapScrollTo(_heatmapColOffset + (delta > 0 ? step : -step));
7922
+ });
7923
+ _heatmapGeom = {
7924
+ originX: originX, cw: cw, offset: offset, visible: visible, total: total,
7925
+ bounds: bounds, tips: tips, refs: columns, min: model.min, max: model.max
7926
+ };
7927
+ placeFloatingOverlays();
7928
+ }
7929
+
7930
+
7931
+ // ===================== the matrix, as rings =====================
7932
+ // The circular layout's analogue of the track: one concentric RING per
7933
+ // column, just past the tips and their labels, each tip's cell an arc over
7934
+ // that tip's own angular slice. The desktop's paintAnnotationColumnsCircular,
7935
+ // in polar coordinates rather than along an axis.
7936
+ //
7937
+ // Circular only, never unrooted: a column has to be a ring, and an unrooted
7938
+ // fan ends every tip at a different radius, so there is no ring for one to
7939
+ // sit on. (The domain tracks ride each tip's own spoke there, which works
7940
+ // for a bar and not for a matrix.)
7941
+
7942
+ // _radial is set by the circular layout and nulled by every other, so it
7943
+ // IS the test; heatmapShown() has already refused the unrooted one. A
7944
+ // separate circularDisplay check here looked like a third lock and could
7945
+ // not fire -- sabotaging it away changed nothing anywhere.
7946
+ function heatmapCircular() {
7947
+ return heatmapShown() && !!_radial;
7948
+ }
7949
+
7950
+ // A ring is as thick as the others, and the stack is capped against the
7951
+ // tree's own radius: a wide matrix must not turn the tree into a dot at
7952
+ // the middle of a dartboard.
7953
+ function heatmapRingWidth() {
7954
+ let m = heatmapModel();
7955
+ if (!m || m.refs.length < 1 || !_radial) {
7956
+ return HEATMAP_PREF_COL_W;
7957
+ }
7958
+ let budget = _radial.maxRad * HEATMAP_RADIAL_MAX_FRACTION;
7959
+ return Math.max(HEATMAP_RADIAL_MIN_RING,
7960
+ Math.min(HEATMAP_PREF_COL_W, budget / m.refs.length));
7961
+ }
7962
+
7963
+ // How far past the tip ring the matrix reaches, for the fit to allow for.
7964
+ function heatmapRadialExtent() {
7965
+ if (!heatmapCircular()) {
7966
+ return 0;
7967
+ }
7968
+ return HEATMAP_RADIAL_GAP + (heatmapModel().refs.length * heatmapRingWidth())
7969
+ + HEATMAP_RADIAL_END_GAP;
7970
+ }
7971
+
7972
+ // one cell: the ring between r0 and r1, over the angles a0 to a1
7973
+ function heatmapSector(a0, a1, r0, r1) {
7974
+ let p = function (a, r) {
7975
+ let xy = polarXY(a, r);
7976
+ return xy[0].toFixed(1) + ',' + xy[1].toFixed(1);
7977
+ };
7978
+ let large = (a1 - a0) > Math.PI ? 1 : 0;
7979
+ return 'M' + p(a0, r0)
7980
+ + 'A' + r0.toFixed(1) + ',' + r0.toFixed(1) + ' 0 ' + large + ' 1 ' + p(a1, r0)
7981
+ + 'L' + p(a1, r1)
7982
+ + 'A' + r1.toFixed(1) + ',' + r1.toFixed(1) + ' 0 ' + large + ' 0 ' + p(a0, r1)
7983
+ + 'Z';
7984
+ }
7985
+
7986
+ function drawHeatmapRings() {
7987
+ let model = heatmapModel();
7988
+ let ordered = heatmapOrdered();
7989
+ let columns = ordered ? ordered.columns : model.refs;
7990
+ let tips = displayedTips().filter(function (d) {
7991
+ return d.x !== undefined;
7992
+ }).sort(function (p, q) {
7993
+ return p.x - q.x;
7994
+ });
7995
+ let n = tips.length;
7996
+ if (n < 1 || columns.length < 1) {
7997
+ _heatmapGeom = null;
7998
+ return;
7999
+ }
8000
+ let g = _svgGroup.append('g').attr('class', 'aptx-heatmap');
8001
+ let ink = _state.branchColorDefault;
8002
+ let ringW = heatmapRingWidth();
8003
+ let start = _radial.maxRad + tipLabelSpace() + HEATMAP_RADIAL_GAP;
8004
+
8005
+ // Angular boundaries, derived ONCE between neighbours, exactly as the
8006
+ // rectangular rows are: a per-tip half-step would round apart and leave
8007
+ // hairline seams all round the ring.
8008
+ let ang = tips.map(function (d) {
8009
+ return radialAngle(d.x);
8010
+ });
8011
+ let pad = n > 1 ? (ang[n - 1] - ang[0]) / (n - 1) / 2 : Math.PI / 8;
8012
+ let edge = new Array(n + 1);
8013
+ edge[0] = ang[0] - pad;
8014
+ for (let i = 1; i < n; ++i) {
8015
+ edge[i] = (ang[i - 1] + ang[i]) / 2;
8016
+ }
8017
+ edge[n] = ang[n - 1] + pad;
8018
+
8019
+ let blanks = 0;
8020
+ for (let c = 0; c < columns.length; ++c) {
8021
+ let r0 = start + (c * ringW);
8022
+ let r1 = r0 + ringW;
8023
+ let ref = columns[c].ref;
8024
+ // consecutive tips of the same colour merge into one sector, which
8025
+ // is what keeps a big circular tree drawable
8026
+ let runStart = -1;
8027
+ let runFill = null;
8028
+ let flush = function (endI) {
8029
+ if (runStart < 0) {
8030
+ return;
8031
+ }
8032
+ // Bled outward and clockwise only: the rings are drawn inner to
8033
+ // outer and each ring's cells in tip order, so the neighbour
8034
+ // that covers the overlap is always the one drawn next.
8035
+ // A BLANK is not bled -- it is background-coloured, so a seam
8036
+ // beside it shows nothing, and bleeding it would push its
8037
+ // outward OUTLINE under the next ring and cost it an edge.
8038
+ let bleed = (runFill === HEATMAP_BLANK) ? 0 : HEATMAP_RING_BLEED;
8039
+ let a1 = edge[endI] + (bleed / Math.max(1, r1));
8040
+ let path = g.append('path')
8041
+ .attr('d', heatmapSector(edge[runStart], a1, r0, r1 + bleed));
8042
+ if (runFill === HEATMAP_BLANK) {
8043
+ path.attr('fill', _state.backgroundColorDefault)
8044
+ .attr('stroke', ink).attr('stroke-opacity', 0.45).attr('stroke-width', 1);
8045
+ } else {
8046
+ path.attr('fill', runFill);
8047
+ }
8048
+ runStart = -1;
8049
+ runFill = null;
8050
+ };
8051
+ for (let i = 0; i < n; ++i) {
8052
+ let v = forester.heatmapValue(tips[i], ref);
8053
+ let fill = (v === null) ? HEATMAP_BLANK : heatmapColorOf(v);
8054
+ if (fill === HEATMAP_BLANK) {
8055
+ blanks++;
8056
+ }
8057
+ if (runFill === fill && (ringW < HEATMAP_LABEL_MIN_COL_W || fill !== HEATMAP_BLANK)) {
8058
+ continue;
8059
+ }
8060
+ flush(i);
8061
+ runStart = i;
8062
+ runFill = fill;
8063
+ }
8064
+ flush(n);
8065
+ }
8066
+
8067
+ // the stack's own edges, so a blank reads as a hole in a grid rather
8068
+ // than as a gap in the drawing
8069
+ [start, start + (columns.length * ringW)].forEach(function (r) {
8070
+ g.append('path')
8071
+ .attr('d', heatmapSector(edge[0], edge[n], r - 0.5, r + 0.5))
8072
+ .attr('fill', ink).attr('fill-opacity', 0.5);
8073
+ });
8074
+
8075
+ // Each ring's name, laid along the ring at the fan's seam -- the gap
8076
+ // the layout already leaves between the last tip and the first. It is
8077
+ // the one place a label can sit without covering a cell.
8078
+ if (ringW >= HEATMAP_RADIAL_MIN_NAMED_RING) {
8079
+ let seam = edge[n] + ((2 * Math.PI) - (edge[n] - edge[0])) / 2;
8080
+ let font = Math.max(7, Math.min(10, Math.round(ringW))) + 'px ' + FONT_DEFAULTS;
8081
+ columns.forEach(function (col, c) {
8082
+ let r = start + (c * ringW) + (ringW / 2);
8083
+ let xy = polarXY(seam, r);
8084
+ // TANGENTIAL: along the ring, not out along the spoke. polarXY
8085
+ // puts angle a at screen direction a - 90, so the tangent is a
8086
+ // itself -- rotating by a - 90 instead laid every name out
8087
+ // along the seam, where rings are one ring-width apart and the
8088
+ // names are several times that: measured, 44 overlapping pairs
8089
+ // out of 18 names. Flipped where it would otherwise read
8090
+ // upside down.
8091
+ let deg = ((seam * 180 / Math.PI) % 360 + 360) % 360;
8092
+ let flip = (deg > 90 && deg < 270);
8093
+ g.append('text')
8094
+ .attr('transform', 'translate(' + xy[0].toFixed(1) + ',' + xy[1].toFixed(1) + ') '
8095
+ + 'rotate(' + (deg + (flip ? 180 : 0)).toFixed(1) + ')')
8096
+ .attr('text-anchor', 'middle')
8097
+ .attr('dy', '0.32em')
8098
+ .style('font', font)
8099
+ .style('fill', _state.labelColorDefault)
8100
+ .text(clipTextToWidth(col.label, font, HEATMAP_LABEL_MAX_PX));
8101
+ });
8102
+ }
8103
+
8104
+ // the hover surface: the whole annulus, hit-tested in polar coordinates
8105
+ g.append('path')
8106
+ .attr('class', 'aptx-heatmap-hover')
8107
+ .attr('d', heatmapSector(edge[0], edge[n], start, start + (columns.length * ringW)))
8108
+ .attr('fill', 'transparent')
8109
+ .on('mousemove', heatmapHoverMove)
8110
+ .on('mouseout', heatmapHoverOut);
8111
+
8112
+ _heatmapGeom = {
8113
+ circular: true, start: start, ringW: ringW, edge: edge, tips: tips,
8114
+ refs: columns, min: model.min, max: model.max, blanks: blanks
8115
+ };
8116
+ }
8117
+
8118
+
8119
+ // The scale, for the circular layout. The rectangular track hangs its
8120
+ // legend under the matrix, which a ring has no "under": this is the same
8121
+ // content -- the ramp, its two ends, the blank key, the column count -- as
8122
+ // a card in the corner the tree leaves free. Drawn in screen coordinates
8123
+ // like the domain legend, so it neither turns nor scales with the fan.
8124
+ function drawHeatmapRingLegend() {
8125
+ if (!_baseSvg) {
8126
+ return;
8127
+ }
8128
+ _baseSvg.selectAll('g.aptx-heatmap-ringlegend').remove();
8129
+ let size = svgSize();
8130
+ let m = heatmapModel();
8131
+ if (!heatmapCircular() || !size || !m) {
8132
+ return;
8133
+ }
8134
+ const PAD = 9;
8135
+ const BAR_W = 128;
8136
+ const FS = 9;
8137
+ let ink = _state.labelColorDefault;
8138
+ let frame = _state.branchColorDefault;
8139
+ let capFont = FS + 'px ' + FONT_DEFAULTS;
8140
+ let blanks = _heatmapGeom && _heatmapGeom.blanks > 0;
8141
+ let caption = m.refs.length + ' columns, one scale';
8142
+ let width = (2 * PAD) + Math.max(BAR_W, legendTextWidth(caption, capFont),
8143
+ blanks ? (HEATMAP_SCALE_BAR_H + 4 + legendTextWidth(HEATMAP_NOT_ASSESSED, capFont)) : 0);
8144
+ let rows = 2 + (blanks ? 1 : 0);
8145
+ let height = (2 * PAD) + HEATMAP_SCALE_BAR_H + (rows * (FS + 4));
8146
+ let x = PAD;
8147
+ let y = size.h - height - PAD; // bottom-left: the overview box lives bottom-right
8148
+
8149
+ let g = _baseSvg.append('g').attr('class', 'aptx-heatmap-ringlegend')
8150
+ .style('pointer-events', 'none');
8151
+ g.append('rect').attr('x', x).attr('y', y).attr('width', width).attr('height', height)
8152
+ .attr('rx', 5)
8153
+ .style('fill', _state.backgroundColorDefault).style('fill-opacity', 0.92)
8154
+ .style('stroke', frame).style('stroke-opacity', 0.5);
8155
+
8156
+ let gradId = 'aptx-heatmap-ringramp';
8157
+ let grad = g.append('defs').append('linearGradient').attr('id', gradId)
8158
+ .attr('x1', 0).attr('y1', 0).attr('x2', 1).attr('y2', 0);
8159
+ VIS_COLOR_RAMP.forEach(function (c, i) {
8160
+ grad.append('stop')
8161
+ .attr('offset', ((i * 100) / (VIS_COLOR_RAMP.length - 1)) + '%')
8162
+ .attr('stop-color', c);
8163
+ });
8164
+ let barY = y + PAD;
8165
+ g.append('rect').attr('x', x + PAD).attr('y', barY)
8166
+ .attr('width', BAR_W).attr('height', HEATMAP_SCALE_BAR_H)
8167
+ .attr('fill', 'url(#' + gradId + ')')
8168
+ .attr('stroke', frame).attr('stroke-opacity', 0.5).attr('stroke-width', 1);
8169
+ let line = barY + HEATMAP_SCALE_BAR_H + FS + 2;
8170
+ g.append('text').attr('x', x + PAD).attr('y', line).attr('text-anchor', 'start')
8171
+ .style('font', capFont).style('fill', ink).text(heatmapNum(m.min));
8172
+ g.append('text').attr('x', x + PAD + BAR_W).attr('y', line).attr('text-anchor', 'end')
8173
+ .style('font', capFont).style('fill', ink).text(heatmapNum(m.max));
8174
+ if (blanks) {
8175
+ line += FS + 4;
8176
+ g.append('rect').attr('x', x + PAD).attr('y', line - HEATMAP_SCALE_BAR_H)
8177
+ .attr('width', HEATMAP_SCALE_BAR_H).attr('height', HEATMAP_SCALE_BAR_H)
8178
+ .attr('fill', _state.backgroundColorDefault)
8179
+ .attr('stroke', frame).attr('stroke-opacity', 0.45).attr('stroke-width', 1);
8180
+ g.append('text').attr('x', x + PAD + HEATMAP_SCALE_BAR_H + 4).attr('y', line)
8181
+ .attr('text-anchor', 'start')
8182
+ .style('font', capFont).style('fill', ink).style('fill-opacity', 0.85)
8183
+ .text(HEATMAP_NOT_ASSESSED);
8184
+ }
8185
+ line += FS + 4;
8186
+ g.append('text').attr('x', x + PAD).attr('y', line).attr('text-anchor', 'start')
8187
+ .style('font', capFont).style('fill', ink).style('fill-opacity', 0.85)
8188
+ .text(caption);
8189
+ }
8190
+
8191
+ function heatmapScrollTo(offset) {
8192
+ if (!_heatmapGeom) {
8193
+ return;
8194
+ }
8195
+ let max = Math.max(0, _heatmapGeom.total - _heatmapGeom.visible);
8196
+ let next = Math.max(0, Math.min(max, Math.round(offset)));
8197
+ if (next !== _heatmapColOffset) {
8198
+ _heatmapColOffset = next;
8199
+ drawHeatmapTrack();
8200
+ }
8201
+ }
8202
+
8203
+ // The hover readout, as on the desktop: the tip, the column and its
8204
+ // value, and the scale the colour was taken from -- so a colour can be
8205
+ // turned back into a number without reading the legend off the bottom.
8206
+ function heatmapHoverMove(event) {
8207
+ if (!_heatmapGeom) {
8208
+ return;
8209
+ }
8210
+ let p = d3.pointer(event, _svgGroup.node());
8211
+ let i;
8212
+ let r = -1;
8213
+ if (_heatmapGeom.circular) {
8214
+ // the same two questions in polar form: which ring, and which slice
8215
+ let rad = Math.sqrt((p[0] * p[0]) + (p[1] * p[1]));
8216
+ i = Math.floor((rad - _heatmapGeom.start) / _heatmapGeom.ringW);
8217
+ if (i < 0 || i >= _heatmapGeom.refs.length) {
8218
+ heatmapHoverOut();
8219
+ return;
8220
+ }
8221
+ // polarXY turns an angle a into (cos(a - pi/2), sin(a - pi/2)),
8222
+ // so the angle comes back the same way round
8223
+ let a = Math.atan2(p[1], p[0]) + (Math.PI / 2);
8224
+ let edge = _heatmapGeom.edge;
8225
+ let n = _heatmapGeom.tips.length;
8226
+ for (let k = 0; k < n; ++k) {
8227
+ // the fan can be laid anywhere on the circle (it rotates), so
8228
+ // the comparison is made modulo a full turn rather than on the
8229
+ // raw angle, which need not lie in the same period
8230
+ let lo = edge[k];
8231
+ let hi = edge[k + 1];
8232
+ let d = ((a - lo) % (2 * Math.PI) + (2 * Math.PI)) % (2 * Math.PI);
8233
+ if (d < (hi - lo)) {
8234
+ r = k;
8235
+ break;
8236
+ }
8237
+ }
8238
+ } else {
8239
+ i = Math.floor((p[0] - _heatmapGeom.originX) / _heatmapGeom.cw);
8240
+ if (i < 0 || i >= _heatmapGeom.visible) {
8241
+ heatmapHoverOut();
8242
+ return;
8243
+ }
8244
+ for (let k = 0; k < _heatmapGeom.tips.length; ++k) {
8245
+ if (p[1] >= _heatmapGeom.bounds[k] && p[1] < _heatmapGeom.bounds[k + 1]) {
8246
+ r = k;
8247
+ break;
8248
+ }
8249
+ }
8250
+ }
8251
+ if (r < 0) {
8252
+ heatmapHoverOut();
8253
+ return;
8254
+ }
8255
+ let tip = _heatmapGeom.tips[r];
8256
+ let col = _heatmapGeom.refs[(_heatmapGeom.offset || 0) + i];
8257
+ let v = forester.heatmapValue(tip, col.ref);
8258
+ let txt = 'Tip: ' + (displayNodeName(tip) || '?') + '<br>'
8259
+ + col.label + ': ' + (v === null ? HEATMAP_NOT_ASSESSED : heatmapNum(v)) + '<br>'
8260
+ + 'Scale: ' + heatmapNum(_heatmapGeom.min) + ' – ' + heatmapNum(_heatmapGeom.max);
8261
+ let tip_el = _node_mouseover_div.node();
8262
+ tip_el.classList.remove('aptx-light', 'aptx-dark');
8263
+ if (_panelTheme) {
8264
+ tip_el.classList.add('aptx-' + _panelTheme);
8265
+ }
8266
+ _node_mouseover_div.html(markUpDataLabels(escapeHtmlKeepBreaks(txt)));
8267
+ placeHoverReadout(tip_el, event);
8268
+ _node_mouseover_div.transition().duration(100).style('opacity', 0.95);
8269
+ }
8270
+
8271
+ function heatmapHoverOut() {
8272
+ _node_mouseover_div.transition().duration(300).style('opacity', 1e-6);
8273
+ }
8274
+
8275
+
8276
+ // The reader's own column order, as the desktop's Annotation Fields arrows
8277
+ // set it. Theirs also chooses WHICH fields are columns and of what type;
8278
+ // ours takes every numeric field automatically, so this dialog is about
8279
+ // order and nothing else.
8280
+ //
8281
+ // Moving anything here makes the order Manual, and Manual never re-sorts --
8282
+ // a sorting mode that quietly undid a move would make the move pointless.
8283
+ // The same rule as the desktop's resolveEdited, minus its "did the order
8284
+ // actually change" test, which it needs only because its dialog does other
8285
+ // things too.
8286
+ function showHeatmapReorderDialog() {
8287
+ let ordered = heatmapOrdered();
8288
+ if (!ordered || ordered.columns.length < 2) {
8289
+ return;
8290
+ }
8291
+ let shell = makeDialogShell(HEATMAP_REORDER_DIALOG, 'Order columns', 320);
8292
+ let body = shell.body;
8293
+ repsElement(body, 'div', 'aptx-reps-lead',
8294
+ 'Drag a column, or use the arrows. Your order is kept until you choose another.');
8295
+ let list = repsElement(body, 'div', 'aptx-reorder');
8296
+ let working = ordered.columns.slice();
8297
+
8298
+ function rowAt(y) {
8299
+ let rows = Array.prototype.slice.call(list.children);
8300
+ for (let i = 0; i < rows.length; ++i) {
8301
+ let b = rows[i].getBoundingClientRect();
8302
+ if (y < b.top + (b.height / 2)) {
8303
+ return i;
8304
+ }
8305
+ }
8306
+ return rows.length;
8307
+ }
8308
+
8309
+ function move(from, to) {
8310
+ if (to < 0 || to >= working.length || from === to) {
8311
+ return false;
8312
+ }
8313
+ working.splice(to, 0, working.splice(from, 1)[0]);
8314
+ draw();
8315
+ return true;
8316
+ }
8317
+
8318
+ function draw() {
8319
+ list.textContent = '';
8320
+ working.forEach(function (col, i) {
8321
+ let row = repsElement(list, 'div', 'aptx-reorder-row');
8322
+ row.tabIndex = 0;
8323
+ repsElement(row, 'span', 'aptx-reorder-grip', '≡');
8324
+ repsElement(row, 'span', 'aptx-reorder-name', col.label || col.ref).title = col.ref;
8325
+ let up = repsElement(row, 'button', 'aptx-reorder-move', '↑');
8326
+ let down = repsElement(row, 'button', 'aptx-reorder-move', '↓');
8327
+ up.type = 'button';
8328
+ down.type = 'button';
8329
+ up.title = 'Move up';
8330
+ down.title = 'Move down';
8331
+ up.disabled = (i === 0);
8332
+ down.disabled = (i === working.length - 1);
8333
+ up.addEventListener('click', function () {
8334
+ if (move(i, i - 1)) {
8335
+ list.children[i - 1].focus();
8336
+ }
8337
+ });
8338
+ down.addEventListener('click', function () {
8339
+ if (move(i, i + 1)) {
8340
+ list.children[i + 1].focus();
8341
+ }
8342
+ });
8343
+ // the arrows reach the same places from the keyboard, so a row
8344
+ // can be moved without a pointer at all
8345
+ row.addEventListener('keydown', function (event) {
8346
+ let d = (event.key === 'ArrowUp') ? -1 : ((event.key === 'ArrowDown') ? 1 : 0);
8347
+ if (d === 0) {
8348
+ return;
8349
+ }
8350
+ event.preventDefault();
8351
+ if (move(i, i + d)) {
8352
+ list.children[i + d].focus();
8353
+ }
8354
+ });
8355
+ row.addEventListener('pointerdown', function (event) {
8356
+ if (event.target.classList.contains('aptx-reorder-move')) {
8357
+ return; // the arrows are not a drag handle
8358
+ }
8359
+ event.preventDefault();
8360
+ row.classList.add('aptx-reorder-dragging');
8361
+ // The LIVE node is moved rather than the list redrawn: a
8362
+ // redraw would replace the element this drag is following.
8363
+ // Its position is read back from the DOM each time instead
8364
+ // of being tracked in a variable -- the first cut kept an
8365
+ // index, updated it, and then used it to work out where to
8366
+ // insert, by which point it no longer meant what the
8367
+ // calculation assumed.
8368
+ let onMove = function (ev) {
8369
+ let kids = Array.prototype.slice.call(list.children);
8370
+ let from = kids.indexOf(row);
8371
+ let to = rowAt(ev.clientY);
8372
+ if (to > from) {
8373
+ to -= 1; // taking the row out shifts everything after it up one
8374
+ }
8375
+ if (to === from || to < 0 || to >= kids.length) {
8376
+ return;
8377
+ }
8378
+ working.splice(to, 0, working.splice(from, 1)[0]);
8379
+ let others = kids.filter(function (k) {
8380
+ return k !== row;
8381
+ });
8382
+ list.insertBefore(row, others[to] || null);
8383
+ Array.prototype.slice.call(list.children).forEach(function (r, k) {
8384
+ let btns = r.querySelectorAll('.aptx-reorder-move');
8385
+ btns[0].disabled = (k === 0);
8386
+ btns[1].disabled = (k === list.children.length - 1);
8387
+ });
8388
+ };
8389
+ let onUp = function () {
8390
+ row.classList.remove('aptx-reorder-dragging');
8391
+ window.removeEventListener('pointermove', onMove);
8392
+ window.removeEventListener('pointerup', onUp);
8393
+ draw();
8394
+ };
8395
+ window.addEventListener('pointermove', onMove);
8396
+ window.addEventListener('pointerup', onUp);
8397
+ });
8398
+ });
8399
+ }
8400
+ draw();
8401
+
8402
+ let actions = repsElement(body, 'div', 'aptx-reps-actions');
8403
+ repsButton(actions, 'Automatic', false, function () {
8404
+ // back to whatever the data asks for: the arrangement is dropped,
8405
+ // not merely overridden
8406
+ _state.heatmapManualOrder = null;
8407
+ _state.heatmapColumnOrder = null;
8408
+ shell.dialog.close();
8409
+ syncHeatmapControls();
8410
+ scheduleUpdate(null, 0);
8411
+ });
8412
+ repsButton(actions, 'Cancel', false, function () {
8413
+ shell.dialog.close();
8414
+ });
8415
+ repsButton(actions, 'Apply', true, function () {
8416
+ _state.heatmapManualOrder = working.map(function (c) {
8417
+ return c.ref;
8418
+ });
8419
+ _state.heatmapColumnOrder = 'manual';
8420
+ shell.dialog.close();
8421
+ syncHeatmapControls();
8422
+ scheduleUpdate(null, 0);
8423
+ });
8424
+ shell.dialog.showModal();
8425
+ // showModal() would otherwise land on the close button: focus belongs
8426
+ // on the list, where the arrow keys already move a row
8427
+ if (list.firstChild) {
8428
+ list.firstChild.focus();
8429
+ }
8430
+ }
8431
+
8432
+ function heatmapCbClicked() {
8433
+ _state.showHeatmap = getCheckboxValue(HEATMAP_CB);
8434
+ syncHeatmapControls();
8435
+ scheduleUpdate(null, 0);
8436
+ }
8437
+
7236
8438
  // ===================== Collapsed clades =====================
7237
8439
  // Collapsing is DISPLAY state on the node (d.collapsed) and nothing
7238
8440
  // else: forester never sees it, the writers never see it, and the data
@@ -7486,7 +8688,7 @@ function (root, d3, forester, phyloXml) {
7486
8688
  // The wedge's reach: the clade's nearest and farthest tips, in the
7487
8689
  // layout's x. One edge runs to the nearest tip and the other to the
7488
8690
  // farthest, so the shape itself shows how uneven the clade's branch
7489
- // lengths are (as iTOL draws it). Christian chose this over the
8691
+ // lengths are. Christian chose this over the
7490
8692
  // desktop's symmetric triangle on an average base (2026-09-14). A
7491
8693
  // phylogram measures the tips; a cladogram, where every leaf sits on
7492
8694
  // the tip column, gives the wedge one depth step.
@@ -7729,6 +8931,13 @@ function (root, d3, forester, phyloXml) {
7729
8931
  if (_basicTreeProperties.alignedMolSeqs === true && _basicTreeProperties.maxMolSeqLength > 0) {
7730
8932
  s.msa = _state.showMsa === true;
7731
8933
  }
8934
+ if (heatmapAvailable()) {
8935
+ s.heatmap = _state.showHeatmap === true;
8936
+ s.heatmapOrder = heatmapMode();
8937
+ if (_state.heatmapManualOrder) {
8938
+ s.heatmapManual = _state.heatmapManualOrder.slice();
8939
+ }
8940
+ }
7732
8941
  if (_basicTreeProperties.domainArchitectures === true) {
7733
8942
  s.domains = _state.showDomainArchitectures === true;
7734
8943
  s.domainLabels = _state.domainLabels;
@@ -7849,6 +9058,17 @@ function (root, d3, forester, phyloXml) {
7849
9058
  if (typeof s.msa === 'boolean') {
7850
9059
  _state.showMsa = s.msa;
7851
9060
  }
9061
+ if (typeof s.heatmap === 'boolean') {
9062
+ _state.showHeatmap = s.heatmap;
9063
+ }
9064
+ if (Array.isArray(s.heatmapManual) && s.heatmapManual.every(function (r) {
9065
+ return typeof r === 'string';
9066
+ })) {
9067
+ _state.heatmapManualOrder = s.heatmapManual.slice();
9068
+ }
9069
+ if (forester.HEATMAP_ORDER_MODES.indexOf(s.heatmapOrder) >= 0) {
9070
+ _state.heatmapColumnOrder = s.heatmapOrder;
9071
+ }
7852
9072
  if (typeof s.domains === 'boolean') {
7853
9073
  _state.showDomainArchitectures = s.domains;
7854
9074
  }
@@ -7948,6 +9168,8 @@ function (root, d3, forester, phyloXml) {
7948
9168
  }
7949
9169
  });
7950
9170
  setCheckboxValue(MSA_CB, _state.showMsa === true);
9171
+ setCheckboxValue(HEATMAP_CB, _state.showHeatmap === true);
9172
+ syncHeatmapControls();
7951
9173
  setCheckboxValue(TIME_AXIS_CB, _state.showTimeAxis === true);
7952
9174
  setCheckboxValue(TIME_GRID_CB, _state.timeAxisGrid === true);
7953
9175
  setCheckboxValue(DOMAIN_GLOW_CB, _state.domainGlow === true);
@@ -8439,8 +9661,10 @@ function (root, d3, forester, phyloXml) {
8439
9661
  }
8440
9662
 
8441
9663
  // The room the longest tip label takes: the same estimate the layout is
8442
- // sized against (there are no font metrics in the model).
8443
- function domainLabelSpace() {
9664
+ // sized against (there are no font metrics in the model). Used by the
9665
+ // domain tracks and by the heat map's rings, which both start past it --
9666
+ // hence the name, which used to say "domain".
9667
+ function tipLabelSpace() {
8444
9668
  return (_maxLabelLength * _state.externalNodeFontSize * LABEL_SIZE_CALC_FACTOR) + LABEL_SIZE_CALC_ADDITION;
8445
9669
  }
8446
9670
 
@@ -8604,7 +9828,7 @@ function (root, d3, forester, phyloXml) {
8604
9828
  // Where each tip's architecture goes: its start along the track, the
8605
9829
  // top and height of its boxes, and in the radial layouts the
8606
9830
  // transform that turns (and for unrooted moves) it onto its spoke.
8607
- let labelSpace = domainLabelSpace();
9831
+ let labelSpace = tipLabelSpace();
8608
9832
  let rows = [];
8609
9833
  if (_state.circularDisplay) {
8610
9834
  // every bar starts on one ring past the labels and rides its
@@ -8910,6 +10134,60 @@ function (root, d3, forester, phyloXml) {
8910
10134
  setCheckboxValue(DOMAIN_GLOW_CB, _state.domainGlow === true);
8911
10135
  }
8912
10136
 
10137
+ // The control follows the toggle: hidden without it, and hidden in the
10138
+ // radial layouts, where no matrix is drawn to order.
10139
+ function syncHeatmapControls() {
10140
+ let fs = byId(HEATMAP_CONTROLS);
10141
+ if (!fs) {
10142
+ return;
10143
+ }
10144
+ fs.style.display = heatmapShown() ? '' : 'none';
10145
+ if (!heatmapAvailable()) {
10146
+ return;
10147
+ }
10148
+ setValue(HEATMAP_ORDER_SELECT, heatmapMode());
10149
+ let reorder = byId(HEATMAP_REORDER_BUTTON);
10150
+ if (reorder) {
10151
+ // two columns can only be in one order up to a flip, which the
10152
+ // arrows would make look like a choice
10153
+ reorder.disabled = !heatmapShown() || heatmapModel().refs.length < 2;
10154
+ }
10155
+ // phyloXML gives every node its own property list, so tips CAN list the
10156
+ // same columns in different orders -- and then "as in the input" is a
10157
+ // consensus rather than a transcription. Say which, for THIS tree:
10158
+ // nobody can tell by looking at the matrix.
10159
+ let sel = byId(HEATMAP_ORDER_SELECT);
10160
+ let opt = sel ? sel.querySelector('option[value="document"]') : null;
10161
+ if (opt) {
10162
+ let a = heatmapInputAgreement();
10163
+ opt.title = 'the order the input lists the columns in'
10164
+ + ((!a || a.tips < 1) ? ''
10165
+ : (a.conflicting === 0)
10166
+ ? ' \u2014 every tip here lists them the same way, so this is exactly that order'
10167
+ : ' \u2014 ' + a.conflicting + ' of ' + a.tips
10168
+ + ' tips here list them in a different order, so this is the order most of them agree on');
10169
+ }
10170
+ }
10171
+
10172
+ function heatmapOrderChanged() {
10173
+ let v = getValue(HEATMAP_ORDER_SELECT);
10174
+ if (forester.HEATMAP_ORDER_MODES.indexOf(v) < 0 || v === heatmapMode()) {
10175
+ return;
10176
+ }
10177
+ if (v === 'manual' && !_state.heatmapManualOrder) {
10178
+ // Manual with nothing arranged yet freezes what is on screen. The
10179
+ // alternative -- an empty list, which means "no opinion" -- would
10180
+ // silently re-derive the order the reader just asked to keep.
10181
+ let o = heatmapOrdered();
10182
+ _state.heatmapManualOrder = o ? o.columns.map(function (c) {
10183
+ return c.ref;
10184
+ }) : null;
10185
+ }
10186
+ _state.heatmapColumnOrder = v;
10187
+ _heatmapColOffset = 0; // a different order: the old window means nothing
10188
+ scheduleUpdate(null, 0);
10189
+ }
10190
+
8913
10191
  function domainsCbClicked() {
8914
10192
  _state.showDomainArchitectures = getCheckboxValue(DOMAINS_CB);
8915
10193
  if (_state.showDomainArchitectures && radialDisplay()) {
@@ -9047,6 +10325,7 @@ function (root, d3, forester, phyloXml) {
9047
10325
  // or the bar covers them -- the old bare slider did exactly that.
9048
10326
  // The scale bar sits in the same band, under the tree's last row.
9049
10327
  return Math.max(msaShown() ? MSA_BOTTOM_RESERVE + MSA_NAV_RESERVE : 0, timeAxisBottomReserve(),
10328
+ heatmapBottomReserve(),
9050
10329
  scaleBarShown() && !radialDisplay() ? SCALE_BAR_RESERVE : 0);
9051
10330
  }
9052
10331
 
@@ -9430,7 +10709,7 @@ function (root, d3, forester, phyloXml) {
9430
10709
  return;
9431
10710
  }
9432
10711
  let labelSpace = (_maxLabelLength * _state.externalNodeFontSize * LABEL_SIZE_CALC_FACTOR) + LABEL_SIZE_CALC_ADDITION;
9433
- let outer = maxRad + labelSpace + domainRadialExtent();
10712
+ let outer = maxRad + labelSpace + domainRadialExtent() + heatmapRadialExtent();
9434
10713
  let W = +_baseSvg.attr('width'), H = +_baseSvg.attr('height');
9435
10714
  let scale = 0.9 * (Math.min(W, H) / (2 * outer));
9436
10715
  if (!isFinite(scale) || scale <= 0) {
@@ -9947,6 +11226,7 @@ function (root, d3, forester, phyloXml) {
9947
11226
  _radialRotation = 0;
9948
11227
  _radialLabelsHorizontal = false;
9949
11228
  _msaColOffset = 0;
11229
+ _heatmapColOffset = 0;
9950
11230
  syncZoomRowButtons();
9951
11231
  refreshVisualizations(false);
9952
11232
  // Esc resets to the launch state -- the auto-applied colour, when its
@@ -10513,6 +11793,12 @@ function (root, d3, forester, phyloXml) {
10513
11793
  if (msaCb) {
10514
11794
  msaCb.disabled = radialDisplay();
10515
11795
  }
11796
+ let heatCb = byId(HEATMAP_CB);
11797
+ if (heatCb) {
11798
+ // circular draws the columns as rings; only unrooted cannot
11799
+ heatCb.disabled = _state.unrootedDisplay === true;
11800
+ }
11801
+ syncHeatmapControls();
10516
11802
  let timeCb = byId(TIME_AXIS_CB);
10517
11803
  if (timeCb) {
10518
11804
  timeCb.disabled = radialDisplay();
@@ -11619,6 +12905,30 @@ function (root, d3, forester, phyloXml) {
11619
12905
  + '.aptx-reps-error:empty { display:none; }'
11620
12906
  + '.aptx-reps-line { padding:1px 0; }'
11621
12907
  + '.aptx-reps-question { margin-top:9px; font-weight:600; }'
12908
+ + '.aptx-reorder { margin-top:8px; max-height:300px; overflow:auto; border:1px solid var(--p-line);'
12909
+ + ' border-radius:7px; padding:3px; background:var(--p-surface2); touch-action:none; }'
12910
+ // the panel's own scrollbar treatment: a native one comes out bright
12911
+ // white inside a dark dialog
12912
+ + '.aptx-reorder::-webkit-scrollbar { width:9px; }'
12913
+ + '.aptx-reorder::-webkit-scrollbar-thumb { background:var(--p-line-strong); border-radius:9px;'
12914
+ + ' border:2px solid var(--p-surface2); }'
12915
+ + '.aptx-reorder::-webkit-scrollbar-track { background:transparent; }'
12916
+ + '.aptx-reorder-row { display:flex; align-items:center; gap:7px; padding:3px 5px; border-radius:5px;'
12917
+ + ' cursor:grab; user-select:none; border:1px solid transparent; }'
12918
+ + '.aptx-reorder-row:hover { background:var(--p-accent-weak); }'
12919
+ + '.aptx-reorder-row:focus-visible { outline:none; border-color:var(--p-accent);'
12920
+ + ' box-shadow:0 0 0 2px var(--p-accent-weak); }'
12921
+ + '.aptx-reorder-dragging { cursor:grabbing; background:var(--p-accent-weak);'
12922
+ + ' border-color:var(--p-accent); opacity:0.9; }'
12923
+ + '.aptx-reorder-grip { flex:none; color:var(--p-faint); font-size:12px; line-height:1; }'
12924
+ + '.aptx-reorder-name { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis;'
12925
+ + ' white-space:nowrap; font-size:11px; color:var(--p-ink); }'
12926
+ + '.aptx-reorder-move { flex:none; width:20px; height:20px; padding:0; line-height:1; font:inherit;'
12927
+ + ' font-size:11px; color:var(--p-muted); background:transparent; border:1px solid transparent;'
12928
+ + ' border-radius:5px; cursor:pointer; }'
12929
+ + '.aptx-reorder-move:hover:not(:disabled) { background:var(--p-surface); color:var(--p-accent-ink);'
12930
+ + ' border-color:var(--p-accent); }'
12931
+ + '.aptx-reorder-move:disabled { opacity:0.3; cursor:default; }'
11622
12932
  + '.aptx-reps-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:12px; }'
11623
12933
  + '.aptx-reps-button { font:inherit; font-size:11px; height:26px; padding:0 12px; color:var(--p-ink);'
11624
12934
  + ' background:var(--p-surface2); border:1px solid var(--p-line-strong); border-radius:6px; cursor:pointer;'
@@ -11782,6 +13092,10 @@ function (root, d3, forester, phyloXml) {
11782
13092
  + '.aptx-panel .aptx-domrow { display:flex; align-items:center; gap:4px; margin:3px 0; }'
11783
13093
  + '.aptx-panel .aptx-domrow .aptx-domlabel { flex:0 0 64px; font-size:10px; color:var(--p-muted); }'
11784
13094
  + '.aptx-panel .aptx-domrow input[type=button] { width:30px; padding:0; margin:0; }'
13095
+ // ... except one that has a WORD on it: the rule above sizes the
13096
+ // domain track's tiny plus and minus, and it clipped this to "Reorc"
13097
+ + '.aptx-panel .aptx-domrow input[type=button].aptx-widebtn { width:auto; flex:1 1 auto;'
13098
+ + ' min-width:0; padding:0 8px; }'
11785
13099
  + '.aptx-panel .aptx-domrow select { flex:1 1 auto; min-width:0; }'
11786
13100
  + '.aptx-panel .aptx-domreadout { flex:1 1 auto; text-align:center; font-size:12px; font-variant-numeric:tabular-nums; color:var(--p-ink); }'
11787
13101
  + '.aptx-panel .aptx-glyph { height:14px; width:auto; display:block; overflow:visible; }'
@@ -12664,6 +13978,8 @@ function (root, d3, forester, phyloXml) {
12664
13978
 
12665
13979
  c0.insertAdjacentHTML('beforeend',makeDomainControls());
12666
13980
 
13981
+ c0.insertAdjacentHTML('beforeend',makeHeatmapControls());
13982
+
12667
13983
  c0.insertAdjacentHTML('beforeend',makeZoomControl());
12668
13984
 
12669
13985
 
@@ -12777,6 +14093,9 @@ function (root, d3, forester, phyloXml) {
12777
14093
 
12778
14094
  on(DYNAHIDE_CB, 'click', dynaHideCbClicked);
12779
14095
  on(MSA_CB, 'click', msaCbClicked);
14096
+ on(HEATMAP_CB, 'click', heatmapCbClicked);
14097
+ on(HEATMAP_ORDER_SELECT, 'change', heatmapOrderChanged);
14098
+ on(HEATMAP_REORDER_BUTTON, 'click', showHeatmapReorderDialog);
12780
14099
  on(DOMAINS_CB, 'click', domainsCbClicked);
12781
14100
  on(UNCOLLAPSE_ALL_BUTTON, 'click', uncollapseAll);
12782
14101
  on(TREE_PREV_BUTTON, 'click', function () {
@@ -13322,6 +14641,9 @@ function (root, d3, forester, phyloXml) {
13322
14641
  if (_basicTreeProperties.domainArchitectures) {
13323
14642
  opts.push(makeCheckboxItem('Domain Architectures', DOMAINS_CB, 'to show/hide the protein domain architectures beside the tips', true));
13324
14643
  }
14644
+ if (heatmapAvailable()) {
14645
+ opts.push(makeCheckboxItem('Heat Map', HEATMAP_CB, 'to show/hide a cell per tip and numeric field beside the tree, every column on one color scale; concentric rings in the circular layout (not in the unrooted one, where a column has no ring to be)'));
14646
+ }
13325
14647
  if (_timeInfo && _timeInfo.type) {
13326
14648
  opts.push(makeCheckboxItem('Time Axis', TIME_AXIS_CB, 'to show/hide the '
13327
14649
  + (_timeInfo.type === 'geologic' ? 'geologic (ICS) time axis' : 'calendar time axis')
@@ -13430,6 +14752,45 @@ function (root, d3, forester, phyloXml) {
13430
14752
  return h;
13431
14753
  }
13432
14754
 
14755
+ // Shown only while the Heat Map toggle is on (syncHeatmapControls).
14756
+ // The desktop's View > Order Matrix Columns, minus its Manual mode --
14757
+ // that one means "the order you dragged the rows into", and there is
14758
+ // no drag-to-reorder dialog here to drag them in.
14759
+ function makeHeatmapControls() {
14760
+ let h = '<fieldset id="' + HEATMAP_CONTROLS + '" style="display:none">';
14761
+ h = h.concat('<legend>Heat Map</legend>');
14762
+ h = h.concat('<div class="aptx-domrow"><label class="aptx-domlabel" for="' + HEATMAP_ORDER_SELECT
14763
+ + '">Order columns</label>');
14764
+ h = h.concat('<select name="' + HEATMAP_ORDER_SELECT + '" id="' + HEATMAP_ORDER_SELECT
14765
+ + '" title="how the matrix columns are ordered; a clustered order also draws the clustering above the matrix">');
14766
+ // "input", not "file": the columns can come from a pasted tree or
14767
+ // a joined table as easily as from a file, and a reader looking at
14768
+ // a tree they dropped in would fairly ask "what file?". The title
14769
+ // is filled in per tree by syncHeatmapControls, because whether
14770
+ // this really IS the input's order depends on the input.
14771
+ h = h.concat('<option value="document">As in the input</option>');
14772
+ h = h.concat('<option value="clustered" title="columns whose values agree across the tips sit together:'
14773
+ + ' complete-linkage clustering on Euclidean distance, the default of R\'s heat maps. A tip missing'
14774
+ + ' either value is left out of that pair, never read as 0.">Clustered (co-occurrence)</option>');
14775
+ h = h.concat('<option value="clustered-presence" title="columns found in the same tips sit together: a tip'
14776
+ + ' where BOTH columns are 0 is left out of that pair, so two rare genes no longer look alike merely'
14777
+ + ' for being rare. Complete-linkage clustering on the Bray-Curtis dissimilarity. For values that are'
14778
+ + ' 0 or more.">Clustered (ignoring shared absence)</option>');
14779
+ h = h.concat('<option value="alphabetical" title="by column name, ignoring case">Alphabetical</option>');
14780
+ h = h.concat('<option value="frequency" title="highest mean value first, over the tips that have a value'
14781
+ + ' -- on 0/1 data, the fraction of tips carrying it">Frequency</option>');
14782
+ h = h.concat('<option value="manual" title="your own order, set with Reorder columns below. Nothing'
14783
+ + ' re-sorts it.">Manual</option>');
14784
+ h = h.concat('</select></div>');
14785
+ h = h.concat('<div class="aptx-domrow"><span class="aptx-domlabel"></span>');
14786
+ h = h.concat('<input type="button" class="aptx-widebtn" value="Reorder columns…" name="'
14787
+ + HEATMAP_REORDER_BUTTON + '" id="' + HEATMAP_REORDER_BUTTON
14788
+ + '" title="drag the columns into the order you want; doing so sets Manual">');
14789
+ h = h.concat('</div>');
14790
+ h = h.concat('</fieldset>');
14791
+ return h;
14792
+ }
14793
+
13433
14794
  function makeSliders() {
13434
14795
  let h = "";
13435
14796
  h = h.concat('<fieldset>');
@@ -13610,7 +14971,9 @@ function (root, d3, forester, phyloXml) {
13610
14971
  setCheckboxValue(VIS_CB, _state.showVisualizations);
13611
14972
  setCheckboxValue(DYNAHIDE_CB, _state.dynahide);
13612
14973
  setCheckboxValue(MSA_CB, _state.showMsa);
14974
+ setCheckboxValue(HEATMAP_CB, _state.showHeatmap);
13613
14975
  syncDomainControls();
14976
+ syncHeatmapControls();
13614
14977
  setCheckboxValue(TIME_AXIS_CB, _state.showTimeAxis);
13615
14978
  setCheckboxValue(TIME_GRID_CB, _state.timeAxisGrid);
13616
14979
  setCheckboxValue(SHORTEN_NODE_NAME_CB, _state.shortenNodeNames);