archaeopteryx 3.9.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.9.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.9.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';
@@ -404,6 +409,9 @@ function (root, d3, forester, phyloXml) {
404
409
  const PHYLOGRAM_ALIGNED_BUTTON = 'phya_b';
405
410
  const PHYLOGRAM_BUTTON = 'phy_b';
406
411
  const PHYLOGRAM_CLADOGRAM_CONTROLGROUP = 'phy_cla_g';
412
+ const BRANCH_SCALE_CONTROLGROUP = 'branch_scale_g';
413
+ const BRANCH_SCALE_TIME_BUTTON = 'branch_scale_time_b';
414
+ const BRANCH_SCALE_DIV_BUTTON = 'branch_scale_div_b';
407
415
  const ABOUT_DIALOG = 'aptx_about';
408
416
  const PROG_NAME = 'progname';
409
417
  const PROGNAMELINK = 'prognamelink';
@@ -569,14 +577,51 @@ function (root, d3, forester, phyloXml) {
569
577
  const DOMAIN_LABEL_MODES = ['none', 'domains', 'legend'];
570
578
  let _domain = null; // {width, palette, next, legendFrac}, set per launch
571
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
572
616
  // ------ time axis (the desktop's geologic / calendar overlays) ------
573
617
  const TIME_GEO_RESERVE = 52; // two ICS band rows + the Ma ruler
574
618
  const TIME_CAL_RESERVE = 26; // the calendar year ruler
575
619
  const TIME_BAND_ROW_H = 13;
576
- 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
577
621
  const FOSSIL_BAR_COLOR = 'rgba(150,100,55,0.86)'; // opaque-ish sepia
578
622
  let _timeInfo = null; // forester.timeAxisInfo, recomputed per render
579
623
  let _timeTree = false; // forester.isTimeTree: never re-rooted
624
+ let _branchScaleAvailable = false; // the tree states time AND divergence, so the switch is offered
580
625
  let _clusterH = 0; // the cluster layout's vertical extent, set per render
581
626
  let _docListenersBound = false; // page-level key/wheel handlers bind once, not per launch
582
627
  let _docListeners = []; // ...and destroy() can take every one of them down again
@@ -1098,6 +1143,7 @@ function (root, d3, forester, phyloXml) {
1098
1143
  // view=[-254,-10 1497x800] with rootOffset=254, so "does it fit" said
1099
1144
  // no by precisely 254px and the overview appeared after every Fit.
1100
1145
  maxX += calcMaxTreeLengthForDisplay() - _settings.rootOffset + _domainReserve;
1146
+ minY -= heatmapTopReserve(); // the dendrogram band stands above the first row
1101
1147
  if (scaleBarShown()) {
1102
1148
  maxY += SCALE_BAR_RESERVE; // the bar sits under the last row
1103
1149
  }
@@ -1422,6 +1468,30 @@ function (root, d3, forester, phyloXml) {
1422
1468
  .style('opacity', 0.95); // only the fade -- the rest is set at creation
1423
1469
  }
1424
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
+
1425
1495
  function mousemove(event, d) {
1426
1496
 
1427
1497
  let mo_text = nodeDataText(d);
@@ -1432,10 +1502,8 @@ function (root, d3, forester, phyloXml) {
1432
1502
  if (_panelTheme) {
1433
1503
  tip.classList.add('aptx-' + _panelTheme);
1434
1504
  }
1435
- _node_mouseover_div
1436
- .html(markUpDataLabels(escapeHtmlKeepBreaks(mo_text)))
1437
- .style('left', (event.pageX + 14) + 'px')
1438
- .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
1439
1507
  }
1440
1508
 
1441
1509
  // ----------------------------
@@ -2727,6 +2795,22 @@ function (root, d3, forester, phyloXml) {
2727
2795
  _msaReserve = 0;
2728
2796
  // and the domain tracks reserve their column past the labels
2729
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
+ }
2730
2814
  if (msaShown()) {
2731
2815
  let fullPx = _basicTreeProperties.maxMolSeqLength * MSA_COL_WIDTH;
2732
2816
  // budgeted from the VIEWPORT: sizing it from the zoomed layout
@@ -2737,11 +2821,12 @@ function (root, d3, forester, phyloXml) {
2737
2821
  let band = Math.max(MSA_MIN_BAND_PX, Math.round(vw * MSA_MAX_VIEWPORT_FRACTION));
2738
2822
  // a wide alignment must not squeeze the tree itself away: the
2739
2823
  // band yields until the tree keeps its minimum share
2740
- 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;
2741
2826
  band = Math.max(MSA_MIN_BAND_PX, Math.min(band, maxBand));
2742
2827
  _msaReserve = MSA_TRACK_GAP + Math.min(fullPx, band);
2743
2828
  }
2744
- _w = _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve - _domainReserve;
2829
+ _w = _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve - _domainReserve - _heatmapReserve;
2745
2830
  if (_w < 1) {
2746
2831
  _w = 1;
2747
2832
  }
@@ -2753,7 +2838,8 @@ function (root, d3, forester, phyloXml) {
2753
2838
 
2754
2839
  _clusterH = Math.max(40, _displayHeight - (2 * TOP_AND_BOTTOM_BORDER_HEIGHT)
2755
2840
  - bottomOverlayReserve());
2756
- _treeFn = _treeFn.size([_clusterH, _w]);
2841
+ let _topReserve = topOverlayReserve();
2842
+ _treeFn = _treeFn.size([Math.max(20, _clusterH - _topReserve), _w]);
2757
2843
 
2758
2844
  // A collapsed clade is a leaf that asks for more than one row: the
2759
2845
  // separation between neighbouring leaves is the mean of their
@@ -2776,7 +2862,7 @@ function (root, d3, forester, phyloXml) {
2776
2862
  });
2777
2863
  _treeFn(hierarchy);
2778
2864
  hierarchy.each(function (hn) {
2779
- hn.data.x = hn.x;
2865
+ hn.data.x = hn.x + _topReserve;
2780
2866
  hn.data.y = hn.y;
2781
2867
  hn.data.depth = hn.depth;
2782
2868
  });
@@ -3266,7 +3352,7 @@ function (root, d3, forester, phyloXml) {
3266
3352
  }
3267
3353
 
3268
3354
  // circular: a thin dashed connector from each external node out to the
3269
- // 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.
3270
3356
  _svgGroup.selectAll('g.aptx-radial-conn').remove();
3271
3357
  if (_state.circularDisplay) {
3272
3358
  // tips while their labels show; a collapsed clade always (its label
@@ -3312,6 +3398,7 @@ function (root, d3, forester, phyloXml) {
3312
3398
  drawDomainArchitectures();
3313
3399
  drawDomainLegend();
3314
3400
  drawMsaTrack();
3401
+ drawHeatmapTrack();
3315
3402
  drawTimeOverlays();
3316
3403
  rebuildOverview(); // measured AFTER the overlays, so the bbox is this frame's
3317
3404
  updateSearchHitNavigation();
@@ -4840,6 +4927,9 @@ function (root, d3, forester, phyloXml) {
4840
4927
  const STATE_KEYS = [
4841
4928
  'layout',
4842
4929
  'showMsa',
4930
+ 'showHeatmap',
4931
+ 'heatmapColumnOrder',
4932
+ 'heatmapManualOrder',
4843
4933
  'showDomainArchitectures',
4844
4934
  'domainLabels',
4845
4935
  'domainGlow',
@@ -5072,6 +5162,39 @@ function (root, d3, forester, phyloXml) {
5072
5162
  _state.showMsa = _basicTreeProperties.alignedMolSeqs === true
5073
5163
  && _basicTreeProperties.maxMolSeqLength > 0;
5074
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
+ }
5075
5198
  // A tree with protein domain architectures on its tips draws them
5076
5199
  // from the start (the desktop switches its toggle on at load), again
5077
5200
  // unless the caller decided. The track width is set at the first
@@ -5111,6 +5234,12 @@ function (root, d3, forester, phyloXml) {
5111
5234
  // showTimeAxis explicitly.
5112
5235
  _timeInfo = _treeData ? forester.timeAxisInfo(forester.getTreeRoot(_treeData)) : null;
5113
5236
  _timeTree = _treeData ? forester.isTimeTree(_treeData) : false;
5237
+ // Whether this tree says two different things -- when its nodes sit in
5238
+ // time AND its branches measure something else -- and which of the two
5239
+ // it arrived showing. An Auspice build arrives in the time view; a
5240
+ // BEAST or Newick file arrives stating its own branch lengths.
5241
+ _branchScaleAvailable = _treeData ? forester.hasTimeAndDivergence(_treeData) : false;
5242
+ _state.branchScale = _treeData ? forester.branchLengthScale(_treeData) : 'divergence';
5114
5243
  if (_state.showTimeAxis === undefined) {
5115
5244
  _state.showTimeAxis = !!(_timeInfo && _timeInfo.type);
5116
5245
  }
@@ -5651,6 +5780,14 @@ function (root, d3, forester, phyloXml) {
5651
5780
  // and a tree whose dates carry a unit is never touched. The desktop
5652
5781
  // converts at the same point, next to its internal-label policy
5653
5782
  // (their HeightDateConverter, 0.11.151).
5783
+ // Each node's cumulative divergence is recorded from the branch
5784
+ // lengths AS LOADED, before anything rewrites them, so the time <->
5785
+ // divergence switch is reversible rather than one-way. Auspice states
5786
+ // divergence outright (nextstrain:div) and is unaffected; every other
5787
+ // tree states it as its branch lengths, which the time view overwrites.
5788
+ trees.forEach(function (t) {
5789
+ forester.captureDivergence(t);
5790
+ });
5654
5791
  forester.convertLoadedHeightsToDates(trees);
5655
5792
  // the two view keys are checked up here with the other arguments,
5656
5793
  // before anything is touched (and where Node can test it)
@@ -5711,6 +5848,10 @@ function (root, d3, forester, phyloXml) {
5711
5848
  _radialRotation = 0;
5712
5849
  _radialLabelsHorizontal = false;
5713
5850
  _msaColOffset = 0;
5851
+ _heatmapColOffset = 0;
5852
+ _heatmapModel = null; // the columns and the scale belong to THIS tree
5853
+ _heatmapColor = null;
5854
+ _heatmapGeom = null;
5714
5855
  // ---- synchronous: everything that can throw, and the cheap setup --
5715
5856
  //
5716
5857
  // initializeSettings validates values and throws on a bad one, so it
@@ -6445,6 +6586,10 @@ function (root, d3, forester, phyloXml) {
6445
6586
  if (edited && _domain) {
6446
6587
  _domain.palette = null; // the tree changed: the domain names are dealt their colours again
6447
6588
  }
6589
+ if (edited) {
6590
+ _heatmapModel = null; // ... and the heat map's columns and scale are derived from it again
6591
+ _heatmapColor = null;
6592
+ }
6448
6593
  if (!_vis) {
6449
6594
  return;
6450
6595
  }
@@ -6483,8 +6628,10 @@ function (root, d3, forester, phyloXml) {
6483
6628
  // part of the real spans (an anchor ratio taken from anything else
6484
6629
  // once flung the whole tree off-screen after three X+ presses).
6485
6630
  return {
6486
- horizontal: Math.max(1, _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve - _domainReserve),
6487
- 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())
6488
6635
  };
6489
6636
  }
6490
6637
 
@@ -7196,13 +7343,8 @@ function (root, d3, forester, phyloXml) {
7196
7343
  if (_panelTheme) {
7197
7344
  tip_el.classList.add('aptx-' + _panelTheme);
7198
7345
  }
7199
- // the track hugs the right edge, so the readout flips to the left
7200
- // of the pointer when it would otherwise run off the window
7201
- let left = (event.pageX + 290) > window.innerWidth ? (event.pageX - 280) : (event.pageX + 14);
7202
- _node_mouseover_div
7203
- .html(markUpDataLabels(escapeHtmlKeepBreaks(txt)))
7204
- .style('left', left + 'px')
7205
- .style('top', (event.pageY + 14) + 'px');
7346
+ _node_mouseover_div.html(markUpDataLabels(escapeHtmlKeepBreaks(txt)));
7347
+ placeHoverReadout(tip_el, event);
7206
7348
  _node_mouseover_div.transition().duration(100).style('opacity', 0.95);
7207
7349
  }
7208
7350
 
@@ -7215,6 +7357,1084 @@ function (root, d3, forester, phyloXml) {
7215
7357
  scheduleUpdate(null, 0);
7216
7358
  }
7217
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
+
7218
8438
  // ===================== Collapsed clades =====================
7219
8439
  // Collapsing is DISPLAY state on the node (d.collapsed) and nothing
7220
8440
  // else: forester never sees it, the writers never see it, and the data
@@ -7468,7 +8688,7 @@ function (root, d3, forester, phyloXml) {
7468
8688
  // The wedge's reach: the clade's nearest and farthest tips, in the
7469
8689
  // layout's x. One edge runs to the nearest tip and the other to the
7470
8690
  // farthest, so the shape itself shows how uneven the clade's branch
7471
- // lengths are (as iTOL draws it). Christian chose this over the
8691
+ // lengths are. Christian chose this over the
7472
8692
  // desktop's symmetric triangle on an average base (2026-09-14). A
7473
8693
  // phylogram measures the tips; a cladogram, where every leaf sits on
7474
8694
  // the tip column, gives the wedge one depth step.
@@ -7711,6 +8931,13 @@ function (root, d3, forester, phyloXml) {
7711
8931
  if (_basicTreeProperties.alignedMolSeqs === true && _basicTreeProperties.maxMolSeqLength > 0) {
7712
8932
  s.msa = _state.showMsa === true;
7713
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
+ }
7714
8941
  if (_basicTreeProperties.domainArchitectures === true) {
7715
8942
  s.domains = _state.showDomainArchitectures === true;
7716
8943
  s.domainLabels = _state.domainLabels;
@@ -7831,6 +9058,17 @@ function (root, d3, forester, phyloXml) {
7831
9058
  if (typeof s.msa === 'boolean') {
7832
9059
  _state.showMsa = s.msa;
7833
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
+ }
7834
9072
  if (typeof s.domains === 'boolean') {
7835
9073
  _state.showDomainArchitectures = s.domains;
7836
9074
  }
@@ -7930,6 +9168,8 @@ function (root, d3, forester, phyloXml) {
7930
9168
  }
7931
9169
  });
7932
9170
  setCheckboxValue(MSA_CB, _state.showMsa === true);
9171
+ setCheckboxValue(HEATMAP_CB, _state.showHeatmap === true);
9172
+ syncHeatmapControls();
7933
9173
  setCheckboxValue(TIME_AXIS_CB, _state.showTimeAxis === true);
7934
9174
  setCheckboxValue(TIME_GRID_CB, _state.timeAxisGrid === true);
7935
9175
  setCheckboxValue(DOMAIN_GLOW_CB, _state.domainGlow === true);
@@ -8152,7 +9392,7 @@ function (root, d3, forester, phyloXml) {
8152
9392
  {key: 'e', label: 'E', shift: true, what: 'Expand vertically until the labels fit', run: function () { zoomToExpandY(); }},
8153
9393
  {key: 'l', label: 'L', shift: true, what: 'Next layout: rectangular, circular, unrooted', run: cycleLayout},
8154
9394
  {key: 'd', label: 'D', shift: true, what: 'Next display type: phylogram, aligned, cladogram', run: cycleDisplayType},
8155
- {key: 'x', label: 'X', shift: true, what: 'Time axis on / off', run: toggleTimeAxis},
9395
+ {key: 'x', label: 'X', shift: true, what: 'Time / divergence scale, or the time axis', run: toggleTimeAxis},
8156
9396
  {key: 'o', label: 'O', shift: true, what: 'Ladderize (order the tree)', run: function () { ladderizeButtonPressed(); }},
8157
9397
  {key: 'u', label: 'U', shift: true, what: 'Uncollapse every clade', run: function () { uncollapseAll(); }},
8158
9398
  {key: 'f', label: 'F', what: 'Go to the search box', run: focusSearch, typing: true},
@@ -8222,9 +9462,45 @@ function (root, d3, forester, phyloXml) {
8222
9462
  }
8223
9463
  }
8224
9464
 
8225
- // The Time Axis checkbox's toggle; when the distance / time tree switch
8226
- // arrives it takes over this key.
9465
+ /**
9466
+ * Redraws the tree with its branch lengths taken from the chosen metric:
9467
+ * TIME (the gaps between the nodes' dates) or DIVERGENCE (what the file's
9468
+ * own branch lengths measured, recorded at load). Both metrics stay on the
9469
+ * tree, so this is reversible and lossless -- a round trip restores the
9470
+ * loaded branch lengths exactly.
9471
+ *
9472
+ * @param scale 'time' or 'divergence'
9473
+ */
9474
+ function setBranchScale(scale) {
9475
+ if (!_branchScaleAvailable || !_treeData || _state.branchScale === scale) {
9476
+ return;
9477
+ }
9478
+ _state.branchScale = scale;
9479
+ if (scale === 'time') {
9480
+ forester.applyTimeBranchLengths(_treeData);
9481
+ } else {
9482
+ forester.applyDivergenceBranchLengths(_treeData);
9483
+ }
9484
+ // the branch lengths ARE the layout, so everything measured from them
9485
+ // has to be measured again
9486
+ _basicTreeProperties = forester.collectBasicTreeProperties(_treeData);
9487
+ setCheckboxValue(BRANCH_SCALE_TIME_BUTTON, scale === 'time');
9488
+ setCheckboxValue(BRANCH_SCALE_DIV_BUTTON, scale !== 'time');
9489
+ zoomToFit();
9490
+ }
9491
+
9492
+ function branchScaleButtonClicked() {
9493
+ setBranchScale(getCheckboxValue(BRANCH_SCALE_TIME_BUTTON) ? 'time' : 'divergence');
9494
+ }
9495
+
9496
+ // Shift+X. The switch takes this key over where the tree offers it, as was
9497
+ // always intended; on every other tree it still works the Time Axis
9498
+ // checkbox, so the key never does nothing.
8227
9499
  function toggleTimeAxis() {
9500
+ if (_branchScaleAvailable) {
9501
+ setBranchScale(_state.branchScale === 'time' ? 'divergence' : 'time');
9502
+ return;
9503
+ }
8228
9504
  let cb = byId(TIME_AXIS_CB);
8229
9505
  if (!cb || cb.disabled) {
8230
9506
  return;
@@ -8385,8 +9661,10 @@ function (root, d3, forester, phyloXml) {
8385
9661
  }
8386
9662
 
8387
9663
  // The room the longest tip label takes: the same estimate the layout is
8388
- // sized against (there are no font metrics in the model).
8389
- 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() {
8390
9668
  return (_maxLabelLength * _state.externalNodeFontSize * LABEL_SIZE_CALC_FACTOR) + LABEL_SIZE_CALC_ADDITION;
8391
9669
  }
8392
9670
 
@@ -8550,7 +9828,7 @@ function (root, d3, forester, phyloXml) {
8550
9828
  // Where each tip's architecture goes: its start along the track, the
8551
9829
  // top and height of its boxes, and in the radial layouts the
8552
9830
  // transform that turns (and for unrooted moves) it onto its spoke.
8553
- let labelSpace = domainLabelSpace();
9831
+ let labelSpace = tipLabelSpace();
8554
9832
  let rows = [];
8555
9833
  if (_state.circularDisplay) {
8556
9834
  // every bar starts on one ring past the labels and rides its
@@ -8856,6 +10134,60 @@ function (root, d3, forester, phyloXml) {
8856
10134
  setCheckboxValue(DOMAIN_GLOW_CB, _state.domainGlow === true);
8857
10135
  }
8858
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
+
8859
10191
  function domainsCbClicked() {
8860
10192
  _state.showDomainArchitectures = getCheckboxValue(DOMAINS_CB);
8861
10193
  if (_state.showDomainArchitectures && radialDisplay()) {
@@ -8966,7 +10298,15 @@ function (root, d3, forester, phyloXml) {
8966
10298
  return _state.showTimeAxis === true && !radialDisplay()
8967
10299
  && _state.phylogram === true
8968
10300
  && _timeInfo !== null && _timeInfo.type !== null
8969
- && _basicTreeProperties.branchLengths === true;
10301
+ && _basicTreeProperties.branchLengths === true
10302
+ // The axis is an overlay calibrated to the tree's own branch scale,
10303
+ // so it must be hidden when the branches are showing DIVERGENCE
10304
+ // and divergence is a different measure -- calendar years against
10305
+ // substitutions reads as a confident lie. Only then: where the two
10306
+ // metrics agree (a BEAST time tree, whose branch lengths ARE time)
10307
+ // there is no switch and the axis is calibrated correctly, which
10308
+ // is what makes a converted BEAST tree show its years at all.
10309
+ && !(_branchScaleAvailable && _state.branchScale === 'divergence');
8970
10310
  }
8971
10311
 
8972
10312
  function timeAxisBottomReserve() {
@@ -8985,6 +10325,7 @@ function (root, d3, forester, phyloXml) {
8985
10325
  // or the bar covers them -- the old bare slider did exactly that.
8986
10326
  // The scale bar sits in the same band, under the tree's last row.
8987
10327
  return Math.max(msaShown() ? MSA_BOTTOM_RESERVE + MSA_NAV_RESERVE : 0, timeAxisBottomReserve(),
10328
+ heatmapBottomReserve(),
8988
10329
  scaleBarShown() && !radialDisplay() ? SCALE_BAR_RESERVE : 0);
8989
10330
  }
8990
10331
 
@@ -9368,7 +10709,7 @@ function (root, d3, forester, phyloXml) {
9368
10709
  return;
9369
10710
  }
9370
10711
  let labelSpace = (_maxLabelLength * _state.externalNodeFontSize * LABEL_SIZE_CALC_FACTOR) + LABEL_SIZE_CALC_ADDITION;
9371
- let outer = maxRad + labelSpace + domainRadialExtent();
10712
+ let outer = maxRad + labelSpace + domainRadialExtent() + heatmapRadialExtent();
9372
10713
  let W = +_baseSvg.attr('width'), H = +_baseSvg.attr('height');
9373
10714
  let scale = 0.9 * (Math.min(W, H) / (2 * outer));
9374
10715
  if (!isFinite(scale) || scale <= 0) {
@@ -9885,6 +11226,7 @@ function (root, d3, forester, phyloXml) {
9885
11226
  _radialRotation = 0;
9886
11227
  _radialLabelsHorizontal = false;
9887
11228
  _msaColOffset = 0;
11229
+ _heatmapColOffset = 0;
9888
11230
  syncZoomRowButtons();
9889
11231
  refreshVisualizations(false);
9890
11232
  // Esc resets to the launch state -- the auto-applied colour, when its
@@ -10451,6 +11793,12 @@ function (root, d3, forester, phyloXml) {
10451
11793
  if (msaCb) {
10452
11794
  msaCb.disabled = radialDisplay();
10453
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();
10454
11802
  let timeCb = byId(TIME_AXIS_CB);
10455
11803
  if (timeCb) {
10456
11804
  timeCb.disabled = radialDisplay();
@@ -11557,6 +12905,30 @@ function (root, d3, forester, phyloXml) {
11557
12905
  + '.aptx-reps-error:empty { display:none; }'
11558
12906
  + '.aptx-reps-line { padding:1px 0; }'
11559
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; }'
11560
12932
  + '.aptx-reps-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:12px; }'
11561
12933
  + '.aptx-reps-button { font:inherit; font-size:11px; height:26px; padding:0 12px; color:var(--p-ink);'
11562
12934
  + ' background:var(--p-surface2); border:1px solid var(--p-line-strong); border-radius:6px; cursor:pointer;'
@@ -11720,6 +13092,10 @@ function (root, d3, forester, phyloXml) {
11720
13092
  + '.aptx-panel .aptx-domrow { display:flex; align-items:center; gap:4px; margin:3px 0; }'
11721
13093
  + '.aptx-panel .aptx-domrow .aptx-domlabel { flex:0 0 64px; font-size:10px; color:var(--p-muted); }'
11722
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; }'
11723
13099
  + '.aptx-panel .aptx-domrow select { flex:1 1 auto; min-width:0; }'
11724
13100
  + '.aptx-panel .aptx-domreadout { flex:1 1 auto; text-align:center; font-size:12px; font-variant-numeric:tabular-nums; color:var(--p-ink); }'
11725
13101
  + '.aptx-panel .aptx-glyph { height:14px; width:auto; display:block; overflow:visible; }'
@@ -12602,6 +13978,8 @@ function (root, d3, forester, phyloXml) {
12602
13978
 
12603
13979
  c0.insertAdjacentHTML('beforeend',makeDomainControls());
12604
13980
 
13981
+ c0.insertAdjacentHTML('beforeend',makeHeatmapControls());
13982
+
12605
13983
  c0.insertAdjacentHTML('beforeend',makeZoomControl());
12606
13984
 
12607
13985
 
@@ -12715,6 +14093,9 @@ function (root, d3, forester, phyloXml) {
12715
14093
 
12716
14094
  on(DYNAHIDE_CB, 'click', dynaHideCbClicked);
12717
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);
12718
14099
  on(DOMAINS_CB, 'click', domainsCbClicked);
12719
14100
  on(UNCOLLAPSE_ALL_BUTTON, 'click', uncollapseAll);
12720
14101
  on(TREE_PREV_BUTTON, 'click', function () {
@@ -12736,6 +14117,8 @@ function (root, d3, forester, phyloXml) {
12736
14117
  on(TIME_GRID_CB, 'click', timeGridCbClicked);
12737
14118
 
12738
14119
  on(LAYOUT_RECT_BUTTON, 'click', layoutButtonClicked);
14120
+ on(BRANCH_SCALE_TIME_BUTTON, 'click', branchScaleButtonClicked);
14121
+ on(BRANCH_SCALE_DIV_BUTTON, 'click', branchScaleButtonClicked);
12739
14122
 
12740
14123
  on(LAYOUT_CIRC_BUTTON, 'click', layoutButtonClicked);
12741
14124
  on(LAYOUT_UNROOTED_BUTTON, 'click', layoutButtonClicked);
@@ -13156,6 +14539,15 @@ function (root, d3, forester, phyloXml) {
13156
14539
  h = h.concat(makeSegment(makeGlyph('aligned_phylogram'), PHYLOGRAM_ALIGNED_BUTTON, radioGroup, 'phylogram display (uses branch length values) with aligned labels'));
13157
14540
  h = h.concat(makeSegment(makeGlyph('cladogram'), CLADOGRAM_BUTTON, radioGroup, ' cladogram display (ignores branch length values)'));
13158
14541
  h = h.concat('</div>');
14542
+ // The branch SCALE, when the tree states two different things: its
14543
+ // nodes' dates, and its branches' divergence. Hidden otherwise,
14544
+ // which is most trees.
14545
+ h = h.concat('<div class="' + BRANCH_SCALE_CONTROLGROUP + ' aptx-segmented">');
14546
+ h = h.concat(makeSegment('Time', BRANCH_SCALE_TIME_BUTTON, 'branch_scale_radio',
14547
+ 'branch lengths measure TIME (the nodes\' dates)'));
14548
+ h = h.concat(makeSegment('Div', BRANCH_SCALE_DIV_BUTTON, 'branch_scale_radio',
14549
+ 'branch lengths measure DIVERGENCE (substitutions, as the file states them)'));
14550
+ h = h.concat('</div>');
13159
14551
  h = h.concat('</div>');
13160
14552
  h = h.concat('</fieldset>');
13161
14553
  return h;
@@ -13249,6 +14641,9 @@ function (root, d3, forester, phyloXml) {
13249
14641
  if (_basicTreeProperties.domainArchitectures) {
13250
14642
  opts.push(makeCheckboxItem('Domain Architectures', DOMAINS_CB, 'to show/hide the protein domain architectures beside the tips', true));
13251
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
+ }
13252
14647
  if (_timeInfo && _timeInfo.type) {
13253
14648
  opts.push(makeCheckboxItem('Time Axis', TIME_AXIS_CB, 'to show/hide the '
13254
14649
  + (_timeInfo.type === 'geologic' ? 'geologic (ICS) time axis' : 'calendar time axis')
@@ -13357,6 +14752,45 @@ function (root, d3, forester, phyloXml) {
13357
14752
  return h;
13358
14753
  }
13359
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
+
13360
14794
  function makeSliders() {
13361
14795
  let h = "";
13362
14796
  h = h.concat('<fieldset>');
@@ -13537,7 +14971,9 @@ function (root, d3, forester, phyloXml) {
13537
14971
  setCheckboxValue(VIS_CB, _state.showVisualizations);
13538
14972
  setCheckboxValue(DYNAHIDE_CB, _state.dynahide);
13539
14973
  setCheckboxValue(MSA_CB, _state.showMsa);
14974
+ setCheckboxValue(HEATMAP_CB, _state.showHeatmap);
13540
14975
  syncDomainControls();
14976
+ syncHeatmapControls();
13541
14977
  setCheckboxValue(TIME_AXIS_CB, _state.showTimeAxis);
13542
14978
  setCheckboxValue(TIME_GRID_CB, _state.timeAxisGrid);
13543
14979
  setCheckboxValue(SHORTEN_NODE_NAME_CB, _state.shortenNodeNames);
@@ -13631,6 +15067,14 @@ function (root, d3, forester, phyloXml) {
13631
15067
  disableCheckbox('#' + PHYLOGRAM_BUTTON);
13632
15068
  disableCheckbox('#' + PHYLOGRAM_ALIGNED_BUTTON);
13633
15069
  }
15070
+ setCheckboxValue(BRANCH_SCALE_TIME_BUTTON, _state.branchScale === 'time');
15071
+ setCheckboxValue(BRANCH_SCALE_DIV_BUTTON, _state.branchScale !== 'time');
15072
+ // most trees state only one thing, and a control offering a choice
15073
+ // that does not exist is worse than no control
15074
+ let scaleGroup = document.querySelector('.' + BRANCH_SCALE_CONTROLGROUP);
15075
+ if (scaleGroup) {
15076
+ scaleGroup.style.display = _branchScaleAvailable ? '' : 'none';
15077
+ }
13634
15078
  }
13635
15079
 
13636
15080