archaeopteryx 3.10.0 → 3.12.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.12.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.12.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,12 @@ 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 MSA_LOGO_CB = 'msa_logo_cb';
367
+ const HEATMAP_CB = 'heatmap_cb';
368
+ const HEATMAP_CONTROLS = 'heatmap_controls';
369
+ const HEATMAP_ORDER_SELECT = 'heatmap_order';
370
+ const HEATMAP_REORDER_BUTTON = 'heatmap_reorder';
371
+ const HEATMAP_REORDER_DIALOG = 'heatmap_reorder_dialog';
366
372
  const DOMAINS_CB = 'domains_cb';
367
373
  const DOMAIN_CONTROLS = 'domain_controls';
368
374
  const DOMAIN_WIDTH_DEC = 'domain_width_dec';
@@ -542,7 +548,11 @@ function (root, d3, forester, phyloXml) {
542
548
  const MSA_MAX_VIEWPORT_FRACTION = 0.6;// nor grows past this share of the display
543
549
  const MSA_CONS_BAR_H = 22; // conservation bar band height
544
550
  const MSA_CONS_TOP_GAP = 4;
545
- const MSA_BOTTOM_RESERVE = 56; // vertical room under the rows (conservation + ruler)
551
+ const MSA_CONS_ROW_H = 13; // the consensus letter row
552
+ const MSA_RULER_H = 17; // the column ruler under it
553
+ const MSA_LOGO_H = 46; // the sequence logo's stack, full height
554
+ const MSA_LOGO_CAP_H = 11; // ... and the caption line above it
555
+ const MSA_LOGO_FONT_PX = 24; // the size logo glyphs are measured at, then scaled
546
556
  const MSA_NAV_RESERVE = 46; // ... plus the navigation bar floating at the viewport bottom
547
557
  const MSA_MIN_TREE_PX = 220; // the tree itself never shrinks below this
548
558
  let _msaColOffset = 0; // first shown alignment column (0-based)
@@ -572,11 +582,47 @@ function (root, d3, forester, phyloXml) {
572
582
  const DOMAIN_LABEL_MODES = ['none', 'domains', 'legend'];
573
583
  let _domain = null; // {width, palette, next, legendFrac}, set per launch
574
584
  let _domainReserve = 0; // horizontal px reserved for the tracks, set with _w
585
+ // ------ heat map (the desktop's MATRIX annotation columns) ------
586
+ const HEATMAP_TRACK_GAP = 8; // px between what is left of the tree and the matrix
587
+ const HEATMAP_PREF_COL_W = 14; // a column never grows past this
588
+ const HEATMAP_MIN_COL_W = 3; // ... nor shrinks below it: past that the matrix is windowed
589
+ const HEATMAP_MIN_BAND_PX = 60; // the window never shrinks below this
590
+ const HEATMAP_MAX_VIEWPORT_FRACTION = 0.45; // nor grows past this share of the display
591
+ const HEATMAP_MIN_TREE_PX = 220; // the tree itself never shrinks below this
592
+ const HEATMAP_MIN_COLUMNS = 2; // one column is a stripe, not a matrix: the toggle is not offered
593
+ const HEATMAP_LABEL_MAX_PX = 96; // a turned column label is clipped to this
594
+ const HEATMAP_LABEL_MIN_COL_W = 7; // ... and dropped where the columns are thinner than this
595
+ const HEATMAP_SCALE_BAR_W = 148; // the gradient in the legend
596
+ const HEATMAP_SCALE_BAR_H = 8;
597
+ const HEATMAP_SCALE_RESERVE = 30; // vertical room for that legend and its numbers
598
+ const HEATMAP_HEAD_GAP = 5; // between the last row and the turned labels
599
+ const HEATMAP_NOT_ASSESSED = 'not assessed'; // what a blank cell says -- it does NOT say zero
600
+ const HEATMAP_RADIAL_GAP = 7; // px from the label ring to the first column ring
601
+ const HEATMAP_RADIAL_END_GAP = 8; // ... and kept free past the last one
602
+ const HEATMAP_RADIAL_MAX_FRACTION = 1.2; // the ring stack, against the tree's own radius
603
+ const HEATMAP_RADIAL_MIN_RING = 1; // a ring never thinner than this
604
+ const HEATMAP_RADIAL_MIN_NAMED_RING = 7; // ... nor named when it is thinner than this
605
+ // Neighbouring sectors OVERLAP by this much rather than meeting exactly.
606
+ // Two antialiased edges that share a coordinate each cover about half the
607
+ // boundary pixel, and a half over a half does not composite back to a
608
+ // solid edge -- it leaves the background showing through as a dotted
609
+ // line all round the rings. Measured on the sparse demo: 6987 pixels
610
+ // within 1.2px of a ring boundary came out background-coloured.
611
+ const HEATMAP_RING_BLEED = 1.2;
612
+ const HEATMAP_BLANK = 'blank'; // the run-merger's stand-in for a cell nobody filled in
613
+ const HEATMAP_DENDRO_GAP = 5; // px between the dendrogram's leaves and the first row
614
+ const HEATMAP_DENDRO_MIN_BAND = 24; // the band it is drawn in, from the label font
615
+ const HEATMAP_DENDRO_MAX_BAND = 72;
616
+ let _heatmapReserve = 0; // horizontal px reserved for the matrix, set with _w
617
+ let _heatmapModel = null; // forester.heatmapColumns of the WHOLE tree, cached per launch
618
+ let _heatmapColor = null; // the one scale every column is painted on
619
+ let _heatmapColOffset = 0; // first shown column, while the matrix is windowed
620
+ let _heatmapGeom = null; // the last draw's geometry, for the hover readout
575
621
  // ------ time axis (the desktop's geologic / calendar overlays) ------
576
622
  const TIME_GEO_RESERVE = 52; // two ICS band rows + the Ma ruler
577
623
  const TIME_CAL_RESERVE = 26; // the calendar year ruler
578
624
  const TIME_BAND_ROW_H = 13;
579
- const HPD_BAR_COLOR = 'rgba(70,130,220,0.35)'; // translucent blue, FigTree-like
625
+ const HPD_BAR_COLOR = 'rgba(70,130,220,0.35)'; // translucent blue, the usual HPD tint
580
626
  const FOSSIL_BAR_COLOR = 'rgba(150,100,55,0.86)'; // opaque-ish sepia
581
627
  let _timeInfo = null; // forester.timeAxisInfo, recomputed per render
582
628
  let _timeTree = false; // forester.isTimeTree: never re-rooted
@@ -1102,6 +1148,7 @@ function (root, d3, forester, phyloXml) {
1102
1148
  // view=[-254,-10 1497x800] with rootOffset=254, so "does it fit" said
1103
1149
  // no by precisely 254px and the overview appeared after every Fit.
1104
1150
  maxX += calcMaxTreeLengthForDisplay() - _settings.rootOffset + _domainReserve;
1151
+ minY -= heatmapTopReserve(); // the dendrogram band stands above the first row
1105
1152
  if (scaleBarShown()) {
1106
1153
  maxY += SCALE_BAR_RESERVE; // the bar sits under the last row
1107
1154
  }
@@ -1426,6 +1473,30 @@ function (root, d3, forester, phyloXml) {
1426
1473
  .style('opacity', 0.95); // only the fade -- the rest is set at creation
1427
1474
  }
1428
1475
 
1476
+ // Puts a hover readout beside the pointer -- to its right and below --
1477
+ // flipping to the other side when that would leave the window. The readout's
1478
+ // OWN measured size decides, because .aptx-tip is width:max-content and so
1479
+ // is as wide as whatever it happens to be saying. Guessing instead, with a
1480
+ // fixed shift, left a 181px readout floating a hundred pixels clear of the
1481
+ // cell it described; a matrix that hugs the right edge flips on every cell,
1482
+ // so the guess was what the reader saw every time.
1483
+ const HOVER_READOUT_GAP = 14;
1484
+
1485
+ function placeHoverReadout(el, event) {
1486
+ let w = el.offsetWidth;
1487
+ let h = el.offsetHeight;
1488
+ let left = event.pageX + HOVER_READOUT_GAP;
1489
+ if ((left + w) > (window.scrollX + window.innerWidth)) {
1490
+ left = Math.max(window.scrollX + 2, event.pageX - HOVER_READOUT_GAP - w);
1491
+ }
1492
+ let top = event.pageY + HOVER_READOUT_GAP;
1493
+ if ((top + h) > (window.scrollY + window.innerHeight)) {
1494
+ top = Math.max(window.scrollY + 2, event.pageY - HOVER_READOUT_GAP - h);
1495
+ }
1496
+ el.style.left = left + 'px';
1497
+ el.style.top = top + 'px';
1498
+ }
1499
+
1429
1500
  function mousemove(event, d) {
1430
1501
 
1431
1502
  let mo_text = nodeDataText(d);
@@ -1436,10 +1507,8 @@ function (root, d3, forester, phyloXml) {
1436
1507
  if (_panelTheme) {
1437
1508
  tip.classList.add('aptx-' + _panelTheme);
1438
1509
  }
1439
- _node_mouseover_div
1440
- .html(markUpDataLabels(escapeHtmlKeepBreaks(mo_text)))
1441
- .style('left', (event.pageX + 14) + 'px')
1442
- .style('top', (event.pageY + 14) + 'px');
1510
+ _node_mouseover_div.html(markUpDataLabels(escapeHtmlKeepBreaks(mo_text)));
1511
+ placeHoverReadout(tip, event); // this one used to run off the edge instead
1443
1512
  }
1444
1513
 
1445
1514
  // ----------------------------
@@ -2731,6 +2800,22 @@ function (root, d3, forester, phyloXml) {
2731
2800
  _msaReserve = 0;
2732
2801
  // and the domain tracks reserve their column past the labels
2733
2802
  _domainReserve = domainReserve();
2803
+ // The heat map is budgeted FIRST of the two right-hand tracks: it
2804
+ // wants a fixed, finite width (its columns at their preferred size
2805
+ // and no more), while the alignment's band is a WINDOW that scrolls
2806
+ // and so can give way without losing anything. Budgeting the
2807
+ // alignment first left an 18-column matrix a 60px sliver.
2808
+ _heatmapReserve = 0;
2809
+ if (heatmapShown() && !radialDisplay()) {
2810
+ let cols = heatmapModel().refs.length;
2811
+ let vp0 = svgSize();
2812
+ let vw0 = Math.min(_displayWidth, (vp0 && vp0.w) ? vp0.w : _displayWidth);
2813
+ let hband = Math.max(HEATMAP_MIN_BAND_PX, Math.round(vw0 * HEATMAP_MAX_VIEWPORT_FRACTION));
2814
+ let hmax = _displayWidth - calcMaxTreeLengthForDisplay() - _domainReserve
2815
+ - HEATMAP_TRACK_GAP - HEATMAP_MIN_TREE_PX;
2816
+ hband = Math.max(HEATMAP_MIN_BAND_PX, Math.min(hband, hmax));
2817
+ _heatmapReserve = HEATMAP_TRACK_GAP + Math.min(cols * HEATMAP_PREF_COL_W, hband);
2818
+ }
2734
2819
  if (msaShown()) {
2735
2820
  let fullPx = _basicTreeProperties.maxMolSeqLength * MSA_COL_WIDTH;
2736
2821
  // budgeted from the VIEWPORT: sizing it from the zoomed layout
@@ -2741,11 +2826,12 @@ function (root, d3, forester, phyloXml) {
2741
2826
  let band = Math.max(MSA_MIN_BAND_PX, Math.round(vw * MSA_MAX_VIEWPORT_FRACTION));
2742
2827
  // a wide alignment must not squeeze the tree itself away: the
2743
2828
  // band yields until the tree keeps its minimum share
2744
- let maxBand = _displayWidth - calcMaxTreeLengthForDisplay() - _domainReserve - MSA_TRACK_GAP - MSA_MIN_TREE_PX;
2829
+ let maxBand = _displayWidth - calcMaxTreeLengthForDisplay() - _domainReserve
2830
+ - _heatmapReserve - MSA_TRACK_GAP - MSA_MIN_TREE_PX;
2745
2831
  band = Math.max(MSA_MIN_BAND_PX, Math.min(band, maxBand));
2746
2832
  _msaReserve = MSA_TRACK_GAP + Math.min(fullPx, band);
2747
2833
  }
2748
- _w = _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve - _domainReserve;
2834
+ _w = _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve - _domainReserve - _heatmapReserve;
2749
2835
  if (_w < 1) {
2750
2836
  _w = 1;
2751
2837
  }
@@ -2757,7 +2843,8 @@ function (root, d3, forester, phyloXml) {
2757
2843
 
2758
2844
  _clusterH = Math.max(40, _displayHeight - (2 * TOP_AND_BOTTOM_BORDER_HEIGHT)
2759
2845
  - bottomOverlayReserve());
2760
- _treeFn = _treeFn.size([_clusterH, _w]);
2846
+ let _topReserve = topOverlayReserve();
2847
+ _treeFn = _treeFn.size([Math.max(20, _clusterH - _topReserve), _w]);
2761
2848
 
2762
2849
  // A collapsed clade is a leaf that asks for more than one row: the
2763
2850
  // separation between neighbouring leaves is the mean of their
@@ -2780,7 +2867,7 @@ function (root, d3, forester, phyloXml) {
2780
2867
  });
2781
2868
  _treeFn(hierarchy);
2782
2869
  hierarchy.each(function (hn) {
2783
- hn.data.x = hn.x;
2870
+ hn.data.x = hn.x + _topReserve;
2784
2871
  hn.data.y = hn.y;
2785
2872
  hn.data.depth = hn.depth;
2786
2873
  });
@@ -3270,7 +3357,7 @@ function (root, d3, forester, phyloXml) {
3270
3357
  }
3271
3358
 
3272
3359
  // 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).
3360
+ // common label ring, so every label lines up at the same radius.
3274
3361
  _svgGroup.selectAll('g.aptx-radial-conn').remove();
3275
3362
  if (_state.circularDisplay) {
3276
3363
  // tips while their labels show; a collapsed clade always (its label
@@ -3316,6 +3403,7 @@ function (root, d3, forester, phyloXml) {
3316
3403
  drawDomainArchitectures();
3317
3404
  drawDomainLegend();
3318
3405
  drawMsaTrack();
3406
+ drawHeatmapTrack();
3319
3407
  drawTimeOverlays();
3320
3408
  rebuildOverview(); // measured AFTER the overlays, so the bbox is this frame's
3321
3409
  updateSearchHitNavigation();
@@ -4844,6 +4932,10 @@ function (root, d3, forester, phyloXml) {
4844
4932
  const STATE_KEYS = [
4845
4933
  'layout',
4846
4934
  'showMsa',
4935
+ 'showMsaLogo',
4936
+ 'showHeatmap',
4937
+ 'heatmapColumnOrder',
4938
+ 'heatmapManualOrder',
4847
4939
  'showDomainArchitectures',
4848
4940
  'domainLabels',
4849
4941
  'domainGlow',
@@ -5076,6 +5168,45 @@ function (root, d3, forester, phyloXml) {
5076
5168
  _state.showMsa = _basicTreeProperties.alignedMolSeqs === true
5077
5169
  && _basicTreeProperties.maxMolSeqLength > 0;
5078
5170
  }
5171
+ // The heat map is offered whenever the tree carries two or more
5172
+ // numeric per-tip fields, which is common enough that turning it on
5173
+ // by itself would be an opinion about the tree rather than a service
5174
+ // -- the alignment track, by contrast, is on when there IS one.
5175
+ if (_state.showHeatmap === undefined) {
5176
+ _state.showHeatmap = false;
5177
+ }
5178
+ // The logo is a summary of the alignment, not a second view of the
5179
+ // tree, so it waits to be asked for: an alignment opens showing its
5180
+ // residues, which is what a reader came for.
5181
+ if (_state.showMsaLogo === undefined) {
5182
+ _state.showMsaLogo = false;
5183
+ }
5184
+ // The columns' order. Left unset it is resolved from the data on first
5185
+ // use (heatmapMode / forester.heatmapDefaultOrder): a clustered order,
5186
+ // because reading block structure is what a heat map beside a tree is
5187
+ // FOR, with the distance chosen by what the values are. An explicit
5188
+ // value always wins and is never re-derived -- including 'document',
5189
+ // which keeps the order the producer wrote the fields in.
5190
+ if (_state.heatmapColumnOrder === undefined) {
5191
+ _state.heatmapColumnOrder = null;
5192
+ } else if (_state.heatmapColumnOrder !== null
5193
+ && forester.HEATMAP_ORDER_MODES.indexOf(_state.heatmapColumnOrder) < 0) {
5194
+ throw new Error(ERROR + '"heatmapColumnOrder" must be one of '
5195
+ + forester.HEATMAP_ORDER_MODES.join(', '));
5196
+ }
5197
+ // The reader's own arrangement, as a list of refs. It is only read
5198
+ // while the order is 'manual', and a ref it names that the tree has
5199
+ // not got is ignored rather than fatal -- an order outliving one of
5200
+ // its columns is ordinary (see forester.heatmapManualOrder).
5201
+ if (_state.heatmapManualOrder === undefined) {
5202
+ _state.heatmapManualOrder = null;
5203
+ } else if (_state.heatmapManualOrder !== null
5204
+ && !(Array.isArray(_state.heatmapManualOrder)
5205
+ && _state.heatmapManualOrder.every(function (r) {
5206
+ return typeof r === 'string';
5207
+ }))) {
5208
+ throw new Error(ERROR + '"heatmapManualOrder" must be an array of property refs');
5209
+ }
5079
5210
  // A tree with protein domain architectures on its tips draws them
5080
5211
  // from the start (the desktop switches its toggle on at load), again
5081
5212
  // unless the caller decided. The track width is set at the first
@@ -5729,6 +5860,10 @@ function (root, d3, forester, phyloXml) {
5729
5860
  _radialRotation = 0;
5730
5861
  _radialLabelsHorizontal = false;
5731
5862
  _msaColOffset = 0;
5863
+ _heatmapColOffset = 0;
5864
+ _heatmapModel = null; // the columns and the scale belong to THIS tree
5865
+ _heatmapColor = null;
5866
+ _heatmapGeom = null;
5732
5867
  // ---- synchronous: everything that can throw, and the cheap setup --
5733
5868
  //
5734
5869
  // initializeSettings validates values and throws on a bad one, so it
@@ -6463,6 +6598,10 @@ function (root, d3, forester, phyloXml) {
6463
6598
  if (edited && _domain) {
6464
6599
  _domain.palette = null; // the tree changed: the domain names are dealt their colours again
6465
6600
  }
6601
+ if (edited) {
6602
+ _heatmapModel = null; // ... and the heat map's columns and scale are derived from it again
6603
+ _heatmapColor = null;
6604
+ }
6466
6605
  if (!_vis) {
6467
6606
  return;
6468
6607
  }
@@ -6501,8 +6640,10 @@ function (root, d3, forester, phyloXml) {
6501
6640
  // part of the real spans (an anchor ratio taken from anything else
6502
6641
  // once flung the whole tree off-screen after three X+ presses).
6503
6642
  return {
6504
- horizontal: Math.max(1, _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve - _domainReserve),
6505
- vertical: Math.max(40, _displayHeight - (2 * TOP_AND_BOTTOM_BORDER_HEIGHT) - bottomOverlayReserve())
6643
+ horizontal: Math.max(1, _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve
6644
+ - _domainReserve - _heatmapReserve),
6645
+ vertical: Math.max(40, _displayHeight - (2 * TOP_AND_BOTTOM_BORDER_HEIGHT)
6646
+ - bottomOverlayReserve() - topOverlayReserve())
6506
6647
  };
6507
6648
  }
6508
6649
 
@@ -6695,6 +6836,64 @@ function (root, d3, forester, phyloXml) {
6695
6836
  && _basicTreeProperties.maxMolSeqLength > 0;
6696
6837
  }
6697
6838
 
6839
+ // The logo replaces the conservation bar and the consensus row rather
6840
+ // than joining them: a stack's height IS the column's conservation and its
6841
+ // top letter IS the consensus, so drawing all three would say the same
6842
+ // thing three times and take three times the room.
6843
+ function msaLogoShown() {
6844
+ return _state.showMsaLogo === true && msaShown();
6845
+ }
6846
+
6847
+ // What the strip under the alignment needs. The conservation form is the
6848
+ // 56 px this was a constant for; the logo form is taller and drops the
6849
+ // consensus row it subsumes.
6850
+ function msaBottomReserve() {
6851
+ return MSA_CONS_TOP_GAP
6852
+ + (msaLogoShown() ? MSA_LOGO_CAP_H + MSA_LOGO_H : MSA_CONS_BAR_H + MSA_CONS_ROW_H)
6853
+ + MSA_RULER_H;
6854
+ }
6855
+
6856
+ // Logo letters are drawn at a fixed size and then scaled to their cell and
6857
+ // to their share of the stack, so what is needed is how much ink this face
6858
+ // actually puts on the page for THIS letter -- measured, not assumed: a
6859
+ // 0.6 advance and a 0.72 cap height are true of some monospace faces and
6860
+ // not others, and a letter scaled by the wrong constant overflows its
6861
+ // column.
6862
+ //
6863
+ // Per letter, because a logo's letters must each fill their slice exactly:
6864
+ // measured off one glyph, Q and G hung their descenders through the
6865
+ // baseline and across the ruler, and every letter without a descender sat
6866
+ // a little short. What is scaled to the slice is the glyph's INK BOX
6867
+ // (ascent + descent), and the baseline is then placed so that box lands
6868
+ // inside the slice.
6869
+ let _logoGlyphs = {};
6870
+
6871
+ function logoGlyphMetrics(ch) {
6872
+ if (_logoGlyphs[ch]) {
6873
+ return _logoGlyphs[ch];
6874
+ }
6875
+ if (!_legendMeasureCtx) {
6876
+ _legendMeasureCtx = document.createElement('canvas').getContext('2d');
6877
+ }
6878
+ _legendMeasureCtx.font = MSA_LOGO_FONT_PX + 'px monospace';
6879
+ let m = _legendMeasureCtx.measureText(ch);
6880
+ let ascent = m.actualBoundingBoxAscent;
6881
+ let descent = m.actualBoundingBoxDescent;
6882
+ if (!(ascent > 0)) {
6883
+ ascent = MSA_LOGO_FONT_PX * 0.72; // a face that will not say
6884
+ descent = 0;
6885
+ }
6886
+ if (!(descent > 0)) {
6887
+ descent = 0; // no descender, or none reported
6888
+ }
6889
+ _logoGlyphs[ch] = {
6890
+ width: m.width || (MSA_LOGO_FONT_PX * 0.6),
6891
+ ascent: ascent,
6892
+ descent: descent
6893
+ };
6894
+ return _logoGlyphs[ch];
6895
+ }
6896
+
6698
6897
  function msaRowSeq(d) {
6699
6898
  if (d.sequences && d.sequences.length > 0) {
6700
6899
  let s = d.sequences[0];
@@ -6885,12 +7084,15 @@ function (root, d3, forester, phyloXml) {
6885
7084
  .attr('stroke', ink).attr('stroke-opacity', 0.7).attr('stroke-width', 1);
6886
7085
  }
6887
7086
 
6888
- // conservation band (consensus identity) + consensus letters
6889
- // scored over the visible WINDOW only -- the whole alignment would
6890
- // cost rows x total per redraw; indices below are window-relative
6891
- let cons = forester.msaConservation(tips.map(function (t) {
7087
+ // Scored over the visible WINDOW only -- the whole alignment would
7088
+ // cost rows x total per redraw; indices below are window-relative.
7089
+ // The rows are the tips ON SCREEN: enter a clade and the summary is
7090
+ // that clade's, which is the whole point of putting it next to a tree.
7091
+ let windowRows = tips.map(function (t) {
6892
7092
  return msaRowSeq(t).slice(offset, offset + visible);
6893
- }), visible, 'identity', isNuc);
7093
+ });
7094
+ let logo = msaLogoShown() ? forester.msaLogo(windowRows, visible, isNuc) : null;
7095
+ let cons = logo ? null : forester.msaConservation(windowRows, visible, 'identity', isNuc);
6894
7096
  // A faint dashed guide from each tip -- from the end of its label, or
6895
7097
  // from the node itself when the label is hidden -- across to its row
6896
7098
  // in the track, so a row can be read back to its sequence without
@@ -6926,34 +7128,91 @@ function (root, d3, forester, phyloXml) {
6926
7128
  }
6927
7129
 
6928
7130
  // ---- conservation, consensus, ruler: a FLOATING strip ----
6929
- let strip = floatStripGroup('aptx-msa-strip', _clusterH, MSA_BOTTOM_RESERVE);
7131
+ let stripH = msaBottomReserve();
7132
+ let strip = floatStripGroup('aptx-msa-strip', _clusterH, stripH);
6930
7133
  strip.append('rect').attr('x', Math.round(originX) - 2).attr('y', _clusterH)
6931
- .attr('width', trackW + 4).attr('height', MSA_BOTTOM_RESERVE)
7134
+ .attr('width', trackW + 4).attr('height', stripH)
6932
7135
  .attr('fill', _state.backgroundColorDefault);
6933
7136
  let consTop = _clusterH + MSA_CONS_TOP_GAP;
6934
- strip.append('rect').attr('x', Math.round(originX)).attr('y', consTop)
6935
- .attr('width', trackW).attr('height', MSA_CONS_BAR_H)
6936
- .attr('fill', ink).attr('fill-opacity', 0.08);
6937
- for (let i = 0; i < visible; ++i) {
6938
- let score = cons.scores[i] || 0;
6939
- if (score <= 0) {
6940
- continue;
7137
+ // A caption on the panel's own background, so it stays readable over
7138
+ // whatever the band happens to draw underneath it.
7139
+ let caption = function (txt, y) {
7140
+ if (trackW <= 170) {
7141
+ return;
6941
7142
  }
6942
- let bh = Math.max(1, Math.round(score * MSA_CONS_BAR_H));
6943
- let x0 = Math.round(originX + (i * cw));
6944
- let x1 = Math.round(originX + ((i + 1) * cw));
6945
- strip.append('rect').attr('x', x0).attr('y', (consTop + MSA_CONS_BAR_H) - bh)
6946
- .attr('width', Math.max(1, x1 - x0)).attr('height', bh)
6947
- .attr('fill', ink).attr('fill-opacity', 0.7);
6948
- }
6949
- if (trackW > 170) {
6950
- strip.append('text').attr('x', Math.round(originX) + trackW - 3).attr('y', consTop + 9)
7143
+ let font = '8px ' + FONT_DEFAULTS;
7144
+ let w = legendTextWidth(txt, font) + 6;
7145
+ let xe = Math.round(originX) + trackW - 3;
7146
+ strip.append('rect').attr('x', xe - w).attr('y', y - 8)
7147
+ .attr('width', w).attr('height', 11)
7148
+ .attr('fill', _state.backgroundColorDefault).attr('fill-opacity', 0.85);
7149
+ strip.append('text').attr('x', xe - 3).attr('y', y)
6951
7150
  .attr('text-anchor', 'end')
6952
7151
  .style('font-size', '8px').style('fill', ink).style('fill-opacity', 0.9)
6953
- .text('Consensus identity (n = ' + n + ')');
6954
- }
6955
- let consensusRow = cw >= 7;
6956
- if (consensusRow) {
7152
+ .text(txt);
7153
+ };
7154
+ if (logo) {
7155
+ // Each column a stack of letters: total height the column's
7156
+ // information content in bits, each letter's share its frequency.
7157
+ // Least frequent at the BOTTOM, so the eye reads the consensus
7158
+ // off the top of the stack.
7159
+ //
7160
+ // The caption gets a line of its own above the stacks (hence the
7161
+ // CAP_H here). Laid over them it washed out the very letters it
7162
+ // was labelling, which is a poor trade for eleven pixels.
7163
+ let floor = consTop + MSA_LOGO_CAP_H + MSA_LOGO_H;
7164
+ strip.append('line')
7165
+ .attr('x1', Math.round(originX)).attr('x2', Math.round(originX) + trackW)
7166
+ .attr('y1', floor).attr('y2', floor)
7167
+ .attr('stroke', ink).attr('stroke-opacity', 0.5).attr('stroke-width', 1);
7168
+ for (let i = 0; i < visible; ++i) {
7169
+ let col = logo.columns[i];
7170
+ if (!col || !(col.height > 0)) {
7171
+ continue;
7172
+ }
7173
+ let x0 = Math.round(originX + (i * cw));
7174
+ let x1 = Math.round(originX + ((i + 1) * cw));
7175
+ let y = floor;
7176
+ for (let k = col.letters.length - 1; k >= 0; --k) {
7177
+ let letter = col.letters[k];
7178
+ let hpx = (letter.h / logo.maxBits) * MSA_LOGO_H;
7179
+ if (hpx < 0.5) {
7180
+ continue; // under half a pixel of ink: nothing to draw
7181
+ }
7182
+ let glyph = logoGlyphMetrics(letter.ch);
7183
+ let sy = hpx / (glyph.ascent + glyph.descent);
7184
+ let rgb = forester.msaResidueRgb(letter.ch, isNuc);
7185
+ strip.append('text')
7186
+ .attr('transform', 'translate(' + (x0 + ((x1 - x0) / 2)) + ','
7187
+ + (y - (glyph.descent * sy)) + ')'
7188
+ + ' scale(' + (Math.max(1, x1 - x0) / glyph.width) + ',' + sy + ')')
7189
+ .attr('text-anchor', 'middle')
7190
+ .style('font-family', 'monospace')
7191
+ .style('font-size', MSA_LOGO_FONT_PX + 'px')
7192
+ .style('fill', rgb === null ? ink : 'rgb(' + rgb.join(',') + ')')
7193
+ .text(letter.ch);
7194
+ y -= hpx;
7195
+ }
7196
+ }
7197
+ caption('Sequence logo \u2014 0 to ' + logo.maxBits.toFixed(1)
7198
+ + ' bits (n = ' + n + ')', consTop + 8);
7199
+ } else {
7200
+ strip.append('rect').attr('x', Math.round(originX)).attr('y', consTop)
7201
+ .attr('width', trackW).attr('height', MSA_CONS_BAR_H)
7202
+ .attr('fill', ink).attr('fill-opacity', 0.08);
7203
+ for (let i = 0; i < visible; ++i) {
7204
+ let score = cons.scores[i] || 0;
7205
+ if (score <= 0) {
7206
+ continue;
7207
+ }
7208
+ let bh = Math.max(1, Math.round(score * MSA_CONS_BAR_H));
7209
+ let x0 = Math.round(originX + (i * cw));
7210
+ let x1 = Math.round(originX + ((i + 1) * cw));
7211
+ strip.append('rect').attr('x', x0).attr('y', (consTop + MSA_CONS_BAR_H) - bh)
7212
+ .attr('width', Math.max(1, x1 - x0)).attr('height', bh)
7213
+ .attr('fill', ink).attr('fill-opacity', 0.7);
7214
+ }
7215
+ caption('Consensus identity (n = ' + n + ')', consTop + 9);
6957
7216
  for (let i = 0; i < visible; ++i) {
6958
7217
  let cc = cons.consensus[i];
6959
7218
  if (!cc) {
@@ -6970,7 +7229,7 @@ function (root, d3, forester, phyloXml) {
6970
7229
 
6971
7230
  // the 1-based column ruler: absolute column numbers at nice steps,
6972
7231
  // and always the first and last column when their edge is in view
6973
- let rulerY = consTop + MSA_CONS_BAR_H + (consensusRow ? 13 : 3);
7232
+ let rulerY = consTop + (logo ? MSA_LOGO_CAP_H + MSA_LOGO_H : MSA_CONS_BAR_H + MSA_CONS_ROW_H);
6974
7233
  strip.append('line').attr('x1', Math.round(originX)).attr('x2', Math.round(originX) + trackW)
6975
7234
  .attr('y1', rulerY).attr('y2', rulerY)
6976
7235
  .attr('stroke', ink).attr('stroke-opacity', 0.8).attr('stroke-width', 1);
@@ -7214,13 +7473,8 @@ function (root, d3, forester, phyloXml) {
7214
7473
  if (_panelTheme) {
7215
7474
  tip_el.classList.add('aptx-' + _panelTheme);
7216
7475
  }
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');
7476
+ _node_mouseover_div.html(markUpDataLabels(escapeHtmlKeepBreaks(txt)));
7477
+ placeHoverReadout(tip_el, event);
7224
7478
  _node_mouseover_div.transition().duration(100).style('opacity', 0.95);
7225
7479
  }
7226
7480
 
@@ -7233,6 +7487,1089 @@ function (root, d3, forester, phyloXml) {
7233
7487
  scheduleUpdate(null, 0);
7234
7488
  }
7235
7489
 
7490
+ function msaLogoCbClicked() {
7491
+ _state.showMsaLogo = getCheckboxValue(MSA_LOGO_CB);
7492
+ scheduleUpdate(null, 0);
7493
+ }
7494
+
7495
+ // ===================== Heat map =====================
7496
+ // The desktop's MATRIX annotation columns: one cell per (tip, numeric
7497
+ // per-tip property) to the right of the labels, every column painted on
7498
+ // ONE colour scale so that a cell can be read across columns -- the
7499
+ // matrix half of a clustergram.
7500
+ //
7501
+ // Which refs are columns, the order they take and what the scale spans
7502
+ // are forester.heatmapColumns, derived once over the WHOLE tree:
7503
+ // candidacy is decided on the tree, never per view, so diving into a
7504
+ // subtree narrows the rows and leaves the columns and the scale alone.
7505
+ // A colour therefore means the same number in every view.
7506
+ //
7507
+ // A cell nobody filled in is never painted as the scale's low end: on a
7508
+ // presence/absence matrix that would state the opposite of what the file
7509
+ // says. It is drawn as an OUTLINED empty box, with a key beside the scale
7510
+ // -- leaving it bare is not enough, see the measurement at the draw.
7511
+ //
7512
+ // Rectangular and CIRCULAR: the columns stand on the tips' common edge,
7513
+ // which a circular fan has as a ring (drawHeatmapRings) and an unrooted one
7514
+ // has not got at all -- every tip there ends at its own radius.
7515
+
7516
+ function heatmapModel() {
7517
+ if (!_heatmapModel && _treeData) {
7518
+ _heatmapModel = forester.heatmapColumns(_treeData);
7519
+ }
7520
+ return _heatmapModel;
7521
+ }
7522
+
7523
+ // Offered when the tree holds at least two numeric per-tip refs that
7524
+ // actually carry values: one column is a stripe rather than a matrix,
7525
+ // and a scale needs something to span.
7526
+ function heatmapAvailable() {
7527
+ let m = heatmapModel();
7528
+ return !!m && m.refs.length >= HEATMAP_MIN_COLUMNS && m.min !== null && m.max !== null;
7529
+ }
7530
+
7531
+ // Circular draws the columns as RINGS (drawHeatmapRings); unrooted cannot,
7532
+ // because its tips end at different radii so a column has no ring to be.
7533
+ function heatmapShown() {
7534
+ return _state.showHeatmap === true && _state.unrootedDisplay !== true && heatmapAvailable();
7535
+ }
7536
+
7537
+ // The one scale, spanning the whole tree's values. Built lazily and kept
7538
+ // with the model, so every redraw and every view paints the same colours.
7539
+ function heatmapColorOf(v) {
7540
+ let m = heatmapModel();
7541
+ if (!m || v === null) {
7542
+ return null;
7543
+ }
7544
+ if (!_heatmapColor) {
7545
+ _heatmapColor = (m.max > m.min)
7546
+ ? d3.scaleLinear().range(VIS_COLOR_RAMP).domain([m.min, (m.min + m.max) / 2, m.max])
7547
+ : function () {
7548
+ // one value everywhere: a flat matrix, not a gradient
7549
+ // over a zero-width domain (which d3 maps to its last stop)
7550
+ return VIS_COLOR_RAMP[1];
7551
+ };
7552
+ }
7553
+ return _heatmapColor(v);
7554
+ }
7555
+
7556
+ // Enough digits to tell two cells apart, without a tail of noise on data
7557
+ // that is plainly integral.
7558
+ function heatmapNum(v) {
7559
+ if (v === null || v === undefined) {
7560
+ return '';
7561
+ }
7562
+ if (v !== 0 && (Math.abs(v) >= 100000 || Math.abs(v) < 0.001)) {
7563
+ return v.toExponential(2);
7564
+ }
7565
+ return String(Math.round(v * 1000) / 1000);
7566
+ }
7567
+
7568
+ // A column is as wide as the band allows, never wider than is useful and
7569
+ // never thinner than is legible -- past that the matrix is windowed and
7570
+ // scrolled, as a long alignment is.
7571
+ function heatmapColWidth() {
7572
+ let m = heatmapModel();
7573
+ if (!m || m.refs.length < 1) {
7574
+ return HEATMAP_PREF_COL_W;
7575
+ }
7576
+ let band = Math.max(1, _heatmapReserve - HEATMAP_TRACK_GAP);
7577
+ return Math.max(HEATMAP_MIN_COL_W, Math.min(HEATMAP_PREF_COL_W, band / m.refs.length));
7578
+ }
7579
+
7580
+ function heatmapLabelsFit() {
7581
+ return heatmapColWidth() >= HEATMAP_LABEL_MIN_COL_W;
7582
+ }
7583
+
7584
+ function heatmapLabelFont() {
7585
+ return Math.max(7, Math.min(11, Math.round(heatmapColWidth()))) + 'px ' + FONT_DEFAULTS;
7586
+ }
7587
+
7588
+ // The turned column labels' band: the longest label, clipped. Asked for
7589
+ // before the draw (bottomOverlayReserve sizes the layout with it), so it
7590
+ // is derived from the model and the reserve, never from the drawing.
7591
+ function heatmapHeadHeight() {
7592
+ if (!heatmapLabelsFit()) {
7593
+ return 0;
7594
+ }
7595
+ let font = heatmapLabelFont();
7596
+ let w = 0;
7597
+ heatmapModel().refs.forEach(function (r) {
7598
+ w = Math.max(w, legendTextWidth(r.label, font));
7599
+ });
7600
+ return Math.ceil(Math.min(w, HEATMAP_LABEL_MAX_PX));
7601
+ }
7602
+
7603
+ // What the strip actually paints: the turned names and the scale.
7604
+ function heatmapStripHeight() {
7605
+ return HEATMAP_HEAD_GAP + heatmapHeadHeight() + HEATMAP_SCALE_RESERVE;
7606
+ }
7607
+
7608
+ // What the layout keeps clear for it. The alignment's navigation bar is
7609
+ // fixed at the viewport bottom and would cover whatever ends under it, so
7610
+ // when that bar is out the strip has to end above it -- as the alignment's
7611
+ // own strip does (MSA_NAV_RESERVE).
7612
+ function heatmapBottomReserve() {
7613
+ if (!heatmapShown() || radialDisplay()) {
7614
+ return 0; // the rings carry their own names and legend
7615
+ }
7616
+ return heatmapStripHeight() + (msaShown() ? MSA_NAV_RESERVE : 0);
7617
+ }
7618
+
7619
+ // The mode in force: what the caller or the reader chose, else the one the
7620
+ // data asks for.
7621
+ //
7622
+ // The resolved choice is cached on the MODEL, which is where it belongs --
7623
+ // it is a fact about this tree's values and it dies with them. It is not
7624
+ // written back into _state, but not because _state would leak it: every
7625
+ // launch AND every tree switch rebuilds _state from the original config
7626
+ // (switchToTree -> launchInto -> initializeState), so a write-back could
7627
+ // not reach the next tree. Checked, because the comment that used to sit
7628
+ // here claimed exactly that protection and it would have been fiction.
7629
+ function heatmapMode() {
7630
+ if (_state.heatmapColumnOrder) {
7631
+ return _state.heatmapColumnOrder;
7632
+ }
7633
+ let m = heatmapModel();
7634
+ if (!m) {
7635
+ return 'document';
7636
+ }
7637
+ if (!m._autoMode) {
7638
+ m._autoMode = forester.heatmapDefaultOrder(_treeData, m.refs);
7639
+ }
7640
+ return m._autoMode;
7641
+ }
7642
+
7643
+ // How far this tree's own tips agree about the column order (cached with
7644
+ // the model, since it is a fact about the same values).
7645
+ function heatmapInputAgreement() {
7646
+ let m = heatmapModel();
7647
+ if (!m) {
7648
+ return null;
7649
+ }
7650
+ if (!m._agreement) {
7651
+ m._agreement = forester.heatmapInputOrderAgreement(_treeData, m.refs);
7652
+ }
7653
+ return m._agreement;
7654
+ }
7655
+
7656
+ // The columns in the order the reader asked for, with the dendrogram
7657
+ // behind that order when the order came from a clustering.
7658
+ function heatmapOrdered() {
7659
+ let m = heatmapModel();
7660
+ if (!m) {
7661
+ return null;
7662
+ }
7663
+ let mode = heatmapMode();
7664
+ // The order is kept ON the model, not in a cache beside it. The
7665
+ // desktop's equivalent was keyed on (refs, mode) and a subtree, an
7666
+ // edit or a re-import changes the VALUES while leaving both alone, so
7667
+ // a hit handed back an order of the old data and a dendrogram drawn
7668
+ // over the new -- found by code review, fixed there by invalidating at
7669
+ // the one place every such path goes through. Hanging it on the model
7670
+ // makes that structural instead: whatever drops the model drops this
7671
+ // with it, and there is no second thing to remember to clear.
7672
+ // the manual list is part of the key: it changes while the mode
7673
+ // stays 'manual', and a cache blind to that would redraw the old
7674
+ // arrangement after every move
7675
+ let manual = _state.heatmapManualOrder;
7676
+ if (!m._order || m._order.mode !== mode || m._order.manual !== manual) {
7677
+ let o = forester.heatmapOrder(_treeData, m.refs, mode, manual);
7678
+ m._order = {mode: mode, manual: manual, columns: o.columns, dendrogram: o.dendrogram};
7679
+ }
7680
+ return m._order;
7681
+ }
7682
+
7683
+ // How many columns the band can hold: every column when they fit, a window
7684
+ // otherwise. Derived from the reserve, which update() sets before it asks
7685
+ // for either overlay reserve, so the draw and the layout agree.
7686
+ function heatmapVisibleColumns() {
7687
+ let m = heatmapModel();
7688
+ if (!m || m.refs.length < 1) {
7689
+ return 0;
7690
+ }
7691
+ let bandPx = Math.max(1, _heatmapReserve - HEATMAP_TRACK_GAP);
7692
+ let visible = Math.max(1, Math.min(m.refs.length, Math.floor(bandPx / heatmapColWidth())));
7693
+ return isFinite(visible) ? visible : 0;
7694
+ }
7695
+
7696
+ // The dendrogram to draw, or null. A WINDOWED matrix gets none: the tree
7697
+ // describes every column, and drawn over some of them its merges would
7698
+ // reach columns that are not on screen -- a picture asserting a grouping
7699
+ // the reader cannot see.
7700
+ function heatmapDendrogram() {
7701
+ if (!heatmapShown() || radialDisplay()) {
7702
+ return null; // a dendrogram over a ring would have to bend; it earns nothing
7703
+ }
7704
+ let o = heatmapOrdered();
7705
+ if (!o || !o.dendrogram || o.columns.length < 3) {
7706
+ return null;
7707
+ }
7708
+ return (heatmapVisibleColumns() === o.columns.length) ? o.dendrogram : null;
7709
+ }
7710
+
7711
+ // Deliberately short: a shape to read a grouping off, not a scale anyone
7712
+ // reads a number from.
7713
+ function heatmapDendroBand() {
7714
+ return Math.max(HEATMAP_DENDRO_MIN_BAND,
7715
+ Math.min(HEATMAP_DENDRO_MAX_BAND, 3 * (_state.externalNodeFontSize + 2)));
7716
+ }
7717
+
7718
+ // Vertical room kept ABOVE the first row for the dendrogram. The band is
7719
+ // drawn at negative y, with the tree's own group, so it zooms and pans
7720
+ // with the cells it belongs to; the layout just has to leave it the space
7721
+ // and the fit has to start below it.
7722
+ function heatmapTopReserve() {
7723
+ return heatmapDendrogram() ? (heatmapDendroBand() + HEATMAP_DENDRO_GAP) : 0;
7724
+ }
7725
+
7726
+ function topOverlayReserve() {
7727
+ return heatmapTopReserve();
7728
+ }
7729
+
7730
+ function drawHeatmapTrack() {
7731
+ if (!_svgGroup) {
7732
+ return;
7733
+ }
7734
+ _svgGroup.selectAll('g.aptx-heatmap').remove();
7735
+ if (_baseSvg) {
7736
+ _baseSvg.selectAll('g.aptx-heatmap-ringlegend').remove();
7737
+ }
7738
+ if (_floatGroup) {
7739
+ _floatGroup.selectAll('g.aptx-heatmap-strip').remove();
7740
+ delete _floatStrips['aptx-heatmap-strip'];
7741
+ }
7742
+ if (!heatmapShown() || !_root) {
7743
+ _heatmapGeom = null;
7744
+ return;
7745
+ }
7746
+ if (heatmapCircular()) {
7747
+ drawHeatmapRings();
7748
+ drawHeatmapRingLegend();
7749
+ return;
7750
+ }
7751
+ let model = heatmapModel();
7752
+ let ordered = heatmapOrdered();
7753
+ let columns = ordered ? ordered.columns : model.refs;
7754
+ let tips = displayedTips().filter(function (d) {
7755
+ return d.x !== undefined;
7756
+ }).sort(function (p, q) {
7757
+ return p.x - q.x;
7758
+ });
7759
+ let total = columns.length;
7760
+ if (tips.length < 1 || total < 1) {
7761
+ _heatmapGeom = null;
7762
+ return;
7763
+ }
7764
+ let cw = heatmapColWidth();
7765
+ let bandPx = Math.max(1, _heatmapReserve - HEATMAP_TRACK_GAP);
7766
+ let visible = Math.max(1, Math.min(total, Math.floor(bandPx / cw)));
7767
+ if (!isFinite(visible)) {
7768
+ // half-initialized globals (mid-launch) can make the window NaN;
7769
+ // draw nothing rather than feeding it downstream
7770
+ _heatmapGeom = null;
7771
+ return;
7772
+ }
7773
+ let maxOffset = Math.max(0, total - visible);
7774
+ if (_heatmapColOffset > maxOffset) {
7775
+ _heatmapColOffset = maxOffset;
7776
+ }
7777
+ if (_heatmapColOffset < 0) {
7778
+ _heatmapColOffset = 0;
7779
+ }
7780
+ let offset = _heatmapColOffset;
7781
+ // The matrix ends where the alignment track begins, or at the canvas
7782
+ // edge when there is none: the fitted view has shifted the layout
7783
+ // right by rootOffset, and both reserves were taken off _w already.
7784
+ let originX = _displayWidth - _settings.rootOffset - _msaReserve - _heatmapReserve + HEATMAP_TRACK_GAP;
7785
+ let g = _svgGroup.append('g').attr('class', 'aptx-heatmap');
7786
+ let ink = _state.branchColorDefault;
7787
+
7788
+ // Row bands: each shared boundary derived ONCE, as the midpoint
7789
+ // between adjacent tip rows -- deriving it per row from y +- half a
7790
+ // row height rounds a pixel apart and paints a seam across the grid.
7791
+ let n = tips.length;
7792
+ let pad = n > 1 ? ((tips[n - 1].x - tips[0].x) / (n - 1)) / 2
7793
+ : Math.max(4, _state.externalNodeFontSize / 2);
7794
+ let bounds = new Array(n + 1);
7795
+ bounds[0] = Math.max(0, tips[0].x - pad);
7796
+ for (let r = 1; r < n; ++r) {
7797
+ bounds[r] = (tips[r - 1].x + tips[r].x) / 2;
7798
+ }
7799
+ bounds[n] = Math.min(_clusterH, tips[n - 1].x + pad);
7800
+
7801
+ // A cell nobody filled in is drawn in the background colour and OUTLINED.
7802
+ // Leaving it bare is not enough: measured on the sparse demo, a bare
7803
+ // blank stands at a contrast ratio of 13.4 against the scale's low end
7804
+ // in the light theme but 1.08 in the dark one -- that is to say, in the
7805
+ // dark theme "not assessed" and "zero" were the same picture, which is
7806
+ // the one confusion this whole design exists to prevent.
7807
+ let blanks = 0;
7808
+ // Blanks merge into one outlined band only where a cell is too narrow
7809
+ // to outline anyway: at a readable column width a run of them must stay
7810
+ // a run of CELLS, or its top and bottom edges read as a drawn rule
7811
+ // across the matrix rather than as missing data.
7812
+ let mergeBlanks = cw < HEATMAP_LABEL_MIN_COL_W;
7813
+ for (let r = 0; r < n; ++r) {
7814
+ let d = tips[r];
7815
+ let cy = Math.round(bounds[r]);
7816
+ let rh = Math.max(1, Math.round(bounds[r + 1]) - cy);
7817
+ // consecutive cells of the SAME colour merge into one rect: on a
7818
+ // presence/absence matrix that is most of them, and it is what
7819
+ // keeps a wide matrix drawable
7820
+ let runStart = -1;
7821
+ let runFill = null;
7822
+ let flush = function (endI) {
7823
+ if (runStart < 0) {
7824
+ return;
7825
+ }
7826
+ let x0 = Math.round(originX + (runStart * cw));
7827
+ let x1 = Math.round(originX + (endI * cw));
7828
+ let rect = g.append('rect').attr('x', x0).attr('y', cy)
7829
+ .attr('width', Math.max(1, x1 - x0)).attr('height', rh);
7830
+ if (runFill === HEATMAP_BLANK) {
7831
+ rect.attr('fill', _state.backgroundColorDefault)
7832
+ .attr('stroke', ink).attr('stroke-opacity', 0.45).attr('stroke-width', 1);
7833
+ } else {
7834
+ rect.attr('fill', runFill);
7835
+ }
7836
+ runStart = -1;
7837
+ runFill = null;
7838
+ };
7839
+ for (let i = 0; i < visible; ++i) {
7840
+ let v = forester.heatmapValue(d, columns[offset + i].ref);
7841
+ let fill = (v === null) ? HEATMAP_BLANK : heatmapColorOf(v);
7842
+ if (fill === HEATMAP_BLANK) {
7843
+ blanks++;
7844
+ }
7845
+ if (runFill === fill && (mergeBlanks || fill !== HEATMAP_BLANK)) {
7846
+ continue; // the run extends
7847
+ }
7848
+ flush(i);
7849
+ runStart = i;
7850
+ runFill = fill;
7851
+ }
7852
+ flush(visible);
7853
+ }
7854
+
7855
+ let rowsTop = Math.round(bounds[0]);
7856
+ let rowsBottom = Math.round(bounds[n]);
7857
+ let trackW = Math.round(originX + (visible * cw)) - Math.round(originX);
7858
+ // The frame: without it a blank cell is indistinguishable from the
7859
+ // space beside the matrix, and "not assessed" stops being visible.
7860
+ g.append('rect')
7861
+ .attr('x', Math.round(originX)).attr('y', rowsTop)
7862
+ .attr('width', trackW).attr('height', Math.max(1, rowsBottom - rowsTop))
7863
+ .attr('fill', 'none')
7864
+ .attr('stroke', ink).attr('stroke-opacity', 0.55).attr('stroke-width', 1);
7865
+
7866
+ // ---- the clustering behind the column order, above the grid ----
7867
+ // Drawn with the cells, in the tree's own group, so it zooms, pans and
7868
+ // exports with them. Heights are LINEAR in the merge distance, so a
7869
+ // block that joins low really is drawn tighter than one that joins
7870
+ // high; spacing merges evenly by rank would read more clearly and
7871
+ // would say something false.
7872
+ let dendro = heatmapDendrogram();
7873
+ if (dendro && dendro.height.length >= 1 && dendro.order.length === visible && offset === 0) {
7874
+ let band = heatmapDendroBand();
7875
+ let baseline = rowsTop - HEATMAP_DENDRO_GAP;
7876
+ let maxH = 0;
7877
+ dendro.height.forEach(function (h) {
7878
+ if (isFinite(h) && h > maxH) {
7879
+ maxH = h;
7880
+ }
7881
+ });
7882
+ let slot = new Array(dendro.order.length);
7883
+ dendro.order.forEach(function (leaf, k) {
7884
+ slot[leaf] = k;
7885
+ });
7886
+ let clusterX = new Array(dendro.height.length);
7887
+ let clusterY = new Array(dendro.height.length);
7888
+ // R's node-id convention: negative = the singleton of that index,
7889
+ // positive = the cluster formed at that stage
7890
+ let nodeX = function (id) {
7891
+ return (id < 0) ? (originX + ((slot[-id - 1] + 0.5) * cw)) : clusterX[id - 1];
7892
+ };
7893
+ let nodeY = function (id) {
7894
+ return (id < 0) ? baseline : clusterY[id - 1];
7895
+ };
7896
+ let path = '';
7897
+ for (let s = 0; s < dendro.height.length; ++s) {
7898
+ let h = dendro.height[s];
7899
+ // a merge with no finite height -- two columns that share no
7900
+ // assessed tip, so no distance was ever computed -- goes to the
7901
+ // top of the band, above every measurable merge, which is where
7902
+ // it belongs: it joined last
7903
+ let ym = (!isFinite(h) || !(maxH > 0)) ? (baseline - band) : (baseline - ((h / maxH) * band));
7904
+ let x1 = nodeX(dendro.left[s]);
7905
+ let y1 = nodeY(dendro.left[s]);
7906
+ let x2 = nodeX(dendro.right[s]);
7907
+ let y2 = nodeY(dendro.right[s]);
7908
+ path += 'M' + x1.toFixed(1) + ',' + y1.toFixed(1)
7909
+ + 'L' + x1.toFixed(1) + ',' + ym.toFixed(1)
7910
+ + 'L' + x2.toFixed(1) + ',' + ym.toFixed(1)
7911
+ + 'L' + x2.toFixed(1) + ',' + y2.toFixed(1);
7912
+ clusterX[s] = (x1 + x2) / 2;
7913
+ clusterY[s] = ym;
7914
+ }
7915
+ g.append('path').attr('class', 'aptx-heatmap-dendro')
7916
+ .attr('d', path)
7917
+ .attr('fill', 'none')
7918
+ .attr('stroke', ink)
7919
+ .attr('stroke-width', Math.max(1, _state.branchWidthDefault))
7920
+ .style('pointer-events', 'none');
7921
+ }
7922
+
7923
+ // A faint dashed guide from each tip across to its row, as the
7924
+ // alignment track draws -- skipped when the domain tracks are in
7925
+ // between, where it would run straight through their boxes.
7926
+ if (_domainReserve === 0) {
7927
+ let guideFont = _state.externalNodeFontSize + 'px ' + _state.defaultFont;
7928
+ let guideGap = _state.nodeLabelGap;
7929
+ let guideEnd = originX - 3;
7930
+ for (let r = 0; r < n; ++r) {
7931
+ let d = tips[r];
7932
+ let label = d._extLabelText || '';
7933
+ let from;
7934
+ if (label) {
7935
+ let labelX = (_state.phylogram && _state.alignPhylogram && _yScale)
7936
+ ? d.y - _yScale(d.distToRoot) + _w + guideGap
7937
+ : d.y + guideGap;
7938
+ from = labelX + legendTextWidth(label, guideFont) + 5;
7939
+ } else {
7940
+ from = d.y + makeNodeSize(d) + 4;
7941
+ }
7942
+ if (guideEnd - from < 6) {
7943
+ continue;
7944
+ }
7945
+ g.append('line')
7946
+ .attr('x1', from).attr('x2', guideEnd)
7947
+ .attr('y1', d.x).attr('y2', d.x)
7948
+ .attr('stroke', ink)
7949
+ .attr('stroke-width', 1)
7950
+ .attr('stroke-dasharray', '2,3')
7951
+ .style('stroke-opacity', 0.35)
7952
+ .style('pointer-events', 'none');
7953
+ }
7954
+ }
7955
+
7956
+ // ---- column names and the shared scale: a FLOATING strip ----
7957
+ // the strip is placed against its FULL reserve, so that a stuck strip
7958
+ // clears the alignment's navigation bar, but paints only its content
7959
+ let strip = floatStripGroup('aptx-heatmap-strip', _clusterH, heatmapBottomReserve());
7960
+ strip.append('rect').attr('x', Math.round(originX) - 2).attr('y', _clusterH)
7961
+ .attr('width', trackW + 4).attr('height', heatmapStripHeight())
7962
+ .attr('fill', _state.backgroundColorDefault);
7963
+ let labelTop = _clusterH + HEATMAP_HEAD_GAP;
7964
+ let headH = heatmapHeadHeight();
7965
+ if (headH > 0) {
7966
+ let font = heatmapLabelFont();
7967
+ for (let i = 0; i < visible; ++i) {
7968
+ let col = columns[offset + i];
7969
+ strip.append('text')
7970
+ .attr('transform', 'translate(' + (originX + (i * cw) + (cw / 2)) + ','
7971
+ + labelTop + ') rotate(-90)')
7972
+ .attr('dy', '0.32em')
7973
+ .attr('text-anchor', 'end')
7974
+ .style('font', font)
7975
+ .style('fill', _state.labelColorDefault)
7976
+ .text(clipTextToWidth(col.label, font, HEATMAP_LABEL_MAX_PX));
7977
+ }
7978
+ }
7979
+
7980
+ // the scale itself: the ramp the cells were painted from, its two
7981
+ // ends named, and the blank cell shown for what it is
7982
+ let scaleTop = labelTop + headH + 6;
7983
+ let barW = Math.round(Math.max(40, Math.min(HEATMAP_SCALE_BAR_W, trackW * 0.45)));
7984
+ let gradId = 'aptx-heatmap-ramp';
7985
+ let grad = strip.append('defs').append('linearGradient').attr('id', gradId)
7986
+ .attr('x1', 0).attr('y1', 0).attr('x2', 1).attr('y2', 0);
7987
+ VIS_COLOR_RAMP.forEach(function (c, i) {
7988
+ grad.append('stop')
7989
+ .attr('offset', ((i * 100) / (VIS_COLOR_RAMP.length - 1)) + '%')
7990
+ .attr('stop-color', c);
7991
+ });
7992
+ strip.append('rect')
7993
+ .attr('x', Math.round(originX)).attr('y', scaleTop)
7994
+ .attr('width', barW).attr('height', HEATMAP_SCALE_BAR_H)
7995
+ .attr('fill', 'url(#' + gradId + ')')
7996
+ .attr('stroke', ink).attr('stroke-opacity', 0.5).attr('stroke-width', 1);
7997
+ let numY = scaleTop + HEATMAP_SCALE_BAR_H + 10;
7998
+ strip.append('text').attr('x', Math.round(originX)).attr('y', numY)
7999
+ .attr('text-anchor', 'start')
8000
+ .style('font-size', '9px').style('fill', ink)
8001
+ .text(heatmapNum(model.min));
8002
+ strip.append('text').attr('x', Math.round(originX) + barW).attr('y', numY)
8003
+ .attr('text-anchor', 'end')
8004
+ .style('font-size', '9px').style('fill', ink)
8005
+ .text(heatmapNum(model.max));
8006
+ // the caption: what the matrix is showing, and -- when it is windowed
8007
+ // -- that there is more of it than this
8008
+ let caption = (visible < total)
8009
+ ? 'Columns ' + (offset + 1) + '–' + (offset + visible) + ' of ' + total + ', one scale (scroll over the matrix)'
8010
+ : total + ' columns, one scale';
8011
+ let capFont = '9px ' + FONT_DEFAULTS;
8012
+ let cursor = Math.round(originX) + barW + 10;
8013
+ let rightEdge = Math.round(originX) + trackW;
8014
+ // the blank's own key comes FIRST: it names a convention the reader
8015
+ // cannot guess, where the caption only repeats what is on screen
8016
+ if (blanks > 0) {
8017
+ let keyW = HEATMAP_SCALE_BAR_H + 4 + legendTextWidth(HEATMAP_NOT_ASSESSED, capFont);
8018
+ if ((rightEdge - cursor) >= keyW) {
8019
+ strip.append('rect').attr('x', cursor).attr('y', scaleTop)
8020
+ .attr('width', HEATMAP_SCALE_BAR_H).attr('height', HEATMAP_SCALE_BAR_H)
8021
+ .attr('fill', _state.backgroundColorDefault)
8022
+ .attr('stroke', ink).attr('stroke-opacity', 0.45).attr('stroke-width', 1);
8023
+ strip.append('text').attr('x', cursor + HEATMAP_SCALE_BAR_H + 4)
8024
+ .attr('y', scaleTop + HEATMAP_SCALE_BAR_H)
8025
+ .attr('text-anchor', 'start')
8026
+ .style('font', capFont).style('fill', ink).style('fill-opacity', 0.85)
8027
+ .text(HEATMAP_NOT_ASSESSED);
8028
+ cursor += keyW + 10;
8029
+ }
8030
+ }
8031
+ if ((rightEdge - cursor) > 60) {
8032
+ strip.append('text').attr('x', cursor).attr('y', scaleTop + HEATMAP_SCALE_BAR_H)
8033
+ .attr('text-anchor', 'start')
8034
+ .style('font', capFont).style('fill', ink).style('fill-opacity', 0.85)
8035
+ .text(clipTextToWidth(caption, capFont, rightEdge - cursor));
8036
+ }
8037
+
8038
+ // hover + wheel surface over the rows
8039
+ g.append('rect').attr('class', 'aptx-heatmap-hover')
8040
+ .attr('x', Math.round(originX)).attr('y', rowsTop)
8041
+ .attr('width', trackW).attr('height', Math.max(1, rowsBottom - rowsTop))
8042
+ .attr('fill', 'transparent')
8043
+ .on('mousemove', heatmapHoverMove)
8044
+ .on('mouseout', heatmapHoverOut)
8045
+ .on('wheel', function (event) {
8046
+ if (visible >= total) {
8047
+ return; // nothing to scroll: leave the wheel to the page
8048
+ }
8049
+ event.preventDefault();
8050
+ event.stopPropagation();
8051
+ let delta = Math.abs(event.deltaY) >= Math.abs(event.deltaX) ? event.deltaY : event.deltaX;
8052
+ if (delta === 0) {
8053
+ return;
8054
+ }
8055
+ let step = Math.max(1, Math.round(visible / 10));
8056
+ heatmapScrollTo(_heatmapColOffset + (delta > 0 ? step : -step));
8057
+ });
8058
+ _heatmapGeom = {
8059
+ originX: originX, cw: cw, offset: offset, visible: visible, total: total,
8060
+ bounds: bounds, tips: tips, refs: columns, min: model.min, max: model.max
8061
+ };
8062
+ placeFloatingOverlays();
8063
+ }
8064
+
8065
+
8066
+ // ===================== the matrix, as rings =====================
8067
+ // The circular layout's analogue of the track: one concentric RING per
8068
+ // column, just past the tips and their labels, each tip's cell an arc over
8069
+ // that tip's own angular slice. The desktop's paintAnnotationColumnsCircular,
8070
+ // in polar coordinates rather than along an axis.
8071
+ //
8072
+ // Circular only, never unrooted: a column has to be a ring, and an unrooted
8073
+ // fan ends every tip at a different radius, so there is no ring for one to
8074
+ // sit on. (The domain tracks ride each tip's own spoke there, which works
8075
+ // for a bar and not for a matrix.)
8076
+
8077
+ // _radial is set by the circular layout and nulled by every other, so it
8078
+ // IS the test; heatmapShown() has already refused the unrooted one. A
8079
+ // separate circularDisplay check here looked like a third lock and could
8080
+ // not fire -- sabotaging it away changed nothing anywhere.
8081
+ function heatmapCircular() {
8082
+ return heatmapShown() && !!_radial;
8083
+ }
8084
+
8085
+ // A ring is as thick as the others, and the stack is capped against the
8086
+ // tree's own radius: a wide matrix must not turn the tree into a dot at
8087
+ // the middle of a dartboard.
8088
+ function heatmapRingWidth() {
8089
+ let m = heatmapModel();
8090
+ if (!m || m.refs.length < 1 || !_radial) {
8091
+ return HEATMAP_PREF_COL_W;
8092
+ }
8093
+ let budget = _radial.maxRad * HEATMAP_RADIAL_MAX_FRACTION;
8094
+ return Math.max(HEATMAP_RADIAL_MIN_RING,
8095
+ Math.min(HEATMAP_PREF_COL_W, budget / m.refs.length));
8096
+ }
8097
+
8098
+ // How far past the tip ring the matrix reaches, for the fit to allow for.
8099
+ function heatmapRadialExtent() {
8100
+ if (!heatmapCircular()) {
8101
+ return 0;
8102
+ }
8103
+ return HEATMAP_RADIAL_GAP + (heatmapModel().refs.length * heatmapRingWidth())
8104
+ + HEATMAP_RADIAL_END_GAP;
8105
+ }
8106
+
8107
+ // one cell: the ring between r0 and r1, over the angles a0 to a1
8108
+ function heatmapSector(a0, a1, r0, r1) {
8109
+ let p = function (a, r) {
8110
+ let xy = polarXY(a, r);
8111
+ return xy[0].toFixed(1) + ',' + xy[1].toFixed(1);
8112
+ };
8113
+ let large = (a1 - a0) > Math.PI ? 1 : 0;
8114
+ return 'M' + p(a0, r0)
8115
+ + 'A' + r0.toFixed(1) + ',' + r0.toFixed(1) + ' 0 ' + large + ' 1 ' + p(a1, r0)
8116
+ + 'L' + p(a1, r1)
8117
+ + 'A' + r1.toFixed(1) + ',' + r1.toFixed(1) + ' 0 ' + large + ' 0 ' + p(a0, r1)
8118
+ + 'Z';
8119
+ }
8120
+
8121
+ function drawHeatmapRings() {
8122
+ let model = heatmapModel();
8123
+ let ordered = heatmapOrdered();
8124
+ let columns = ordered ? ordered.columns : model.refs;
8125
+ let tips = displayedTips().filter(function (d) {
8126
+ return d.x !== undefined;
8127
+ }).sort(function (p, q) {
8128
+ return p.x - q.x;
8129
+ });
8130
+ let n = tips.length;
8131
+ if (n < 1 || columns.length < 1) {
8132
+ _heatmapGeom = null;
8133
+ return;
8134
+ }
8135
+ let g = _svgGroup.append('g').attr('class', 'aptx-heatmap');
8136
+ let ink = _state.branchColorDefault;
8137
+ let ringW = heatmapRingWidth();
8138
+ let start = _radial.maxRad + tipLabelSpace() + HEATMAP_RADIAL_GAP;
8139
+
8140
+ // Angular boundaries, derived ONCE between neighbours, exactly as the
8141
+ // rectangular rows are: a per-tip half-step would round apart and leave
8142
+ // hairline seams all round the ring.
8143
+ let ang = tips.map(function (d) {
8144
+ return radialAngle(d.x);
8145
+ });
8146
+ let pad = n > 1 ? (ang[n - 1] - ang[0]) / (n - 1) / 2 : Math.PI / 8;
8147
+ let edge = new Array(n + 1);
8148
+ edge[0] = ang[0] - pad;
8149
+ for (let i = 1; i < n; ++i) {
8150
+ edge[i] = (ang[i - 1] + ang[i]) / 2;
8151
+ }
8152
+ edge[n] = ang[n - 1] + pad;
8153
+
8154
+ let blanks = 0;
8155
+ for (let c = 0; c < columns.length; ++c) {
8156
+ let r0 = start + (c * ringW);
8157
+ let r1 = r0 + ringW;
8158
+ let ref = columns[c].ref;
8159
+ // consecutive tips of the same colour merge into one sector, which
8160
+ // is what keeps a big circular tree drawable
8161
+ let runStart = -1;
8162
+ let runFill = null;
8163
+ let flush = function (endI) {
8164
+ if (runStart < 0) {
8165
+ return;
8166
+ }
8167
+ // Bled outward and clockwise only: the rings are drawn inner to
8168
+ // outer and each ring's cells in tip order, so the neighbour
8169
+ // that covers the overlap is always the one drawn next.
8170
+ // A BLANK is not bled -- it is background-coloured, so a seam
8171
+ // beside it shows nothing, and bleeding it would push its
8172
+ // outward OUTLINE under the next ring and cost it an edge.
8173
+ let bleed = (runFill === HEATMAP_BLANK) ? 0 : HEATMAP_RING_BLEED;
8174
+ let a1 = edge[endI] + (bleed / Math.max(1, r1));
8175
+ let path = g.append('path')
8176
+ .attr('d', heatmapSector(edge[runStart], a1, r0, r1 + bleed));
8177
+ if (runFill === HEATMAP_BLANK) {
8178
+ path.attr('fill', _state.backgroundColorDefault)
8179
+ .attr('stroke', ink).attr('stroke-opacity', 0.45).attr('stroke-width', 1);
8180
+ } else {
8181
+ path.attr('fill', runFill);
8182
+ }
8183
+ runStart = -1;
8184
+ runFill = null;
8185
+ };
8186
+ for (let i = 0; i < n; ++i) {
8187
+ let v = forester.heatmapValue(tips[i], ref);
8188
+ let fill = (v === null) ? HEATMAP_BLANK : heatmapColorOf(v);
8189
+ if (fill === HEATMAP_BLANK) {
8190
+ blanks++;
8191
+ }
8192
+ if (runFill === fill && (ringW < HEATMAP_LABEL_MIN_COL_W || fill !== HEATMAP_BLANK)) {
8193
+ continue;
8194
+ }
8195
+ flush(i);
8196
+ runStart = i;
8197
+ runFill = fill;
8198
+ }
8199
+ flush(n);
8200
+ }
8201
+
8202
+ // the stack's own edges, so a blank reads as a hole in a grid rather
8203
+ // than as a gap in the drawing
8204
+ [start, start + (columns.length * ringW)].forEach(function (r) {
8205
+ g.append('path')
8206
+ .attr('d', heatmapSector(edge[0], edge[n], r - 0.5, r + 0.5))
8207
+ .attr('fill', ink).attr('fill-opacity', 0.5);
8208
+ });
8209
+
8210
+ // Each ring's name, laid along the ring at the fan's seam -- the gap
8211
+ // the layout already leaves between the last tip and the first. It is
8212
+ // the one place a label can sit without covering a cell.
8213
+ if (ringW >= HEATMAP_RADIAL_MIN_NAMED_RING) {
8214
+ let seam = edge[n] + ((2 * Math.PI) - (edge[n] - edge[0])) / 2;
8215
+ let font = Math.max(7, Math.min(10, Math.round(ringW))) + 'px ' + FONT_DEFAULTS;
8216
+ columns.forEach(function (col, c) {
8217
+ let r = start + (c * ringW) + (ringW / 2);
8218
+ let xy = polarXY(seam, r);
8219
+ // TANGENTIAL: along the ring, not out along the spoke. polarXY
8220
+ // puts angle a at screen direction a - 90, so the tangent is a
8221
+ // itself -- rotating by a - 90 instead laid every name out
8222
+ // along the seam, where rings are one ring-width apart and the
8223
+ // names are several times that: measured, 44 overlapping pairs
8224
+ // out of 18 names. Flipped where it would otherwise read
8225
+ // upside down.
8226
+ let deg = ((seam * 180 / Math.PI) % 360 + 360) % 360;
8227
+ let flip = (deg > 90 && deg < 270);
8228
+ g.append('text')
8229
+ .attr('transform', 'translate(' + xy[0].toFixed(1) + ',' + xy[1].toFixed(1) + ') '
8230
+ + 'rotate(' + (deg + (flip ? 180 : 0)).toFixed(1) + ')')
8231
+ .attr('text-anchor', 'middle')
8232
+ .attr('dy', '0.32em')
8233
+ .style('font', font)
8234
+ .style('fill', _state.labelColorDefault)
8235
+ .text(clipTextToWidth(col.label, font, HEATMAP_LABEL_MAX_PX));
8236
+ });
8237
+ }
8238
+
8239
+ // the hover surface: the whole annulus, hit-tested in polar coordinates
8240
+ g.append('path')
8241
+ .attr('class', 'aptx-heatmap-hover')
8242
+ .attr('d', heatmapSector(edge[0], edge[n], start, start + (columns.length * ringW)))
8243
+ .attr('fill', 'transparent')
8244
+ .on('mousemove', heatmapHoverMove)
8245
+ .on('mouseout', heatmapHoverOut);
8246
+
8247
+ _heatmapGeom = {
8248
+ circular: true, start: start, ringW: ringW, edge: edge, tips: tips,
8249
+ refs: columns, min: model.min, max: model.max, blanks: blanks
8250
+ };
8251
+ }
8252
+
8253
+
8254
+ // The scale, for the circular layout. The rectangular track hangs its
8255
+ // legend under the matrix, which a ring has no "under": this is the same
8256
+ // content -- the ramp, its two ends, the blank key, the column count -- as
8257
+ // a card in the corner the tree leaves free. Drawn in screen coordinates
8258
+ // like the domain legend, so it neither turns nor scales with the fan.
8259
+ function drawHeatmapRingLegend() {
8260
+ if (!_baseSvg) {
8261
+ return;
8262
+ }
8263
+ _baseSvg.selectAll('g.aptx-heatmap-ringlegend').remove();
8264
+ let size = svgSize();
8265
+ let m = heatmapModel();
8266
+ if (!heatmapCircular() || !size || !m) {
8267
+ return;
8268
+ }
8269
+ const PAD = 9;
8270
+ const BAR_W = 128;
8271
+ const FS = 9;
8272
+ let ink = _state.labelColorDefault;
8273
+ let frame = _state.branchColorDefault;
8274
+ let capFont = FS + 'px ' + FONT_DEFAULTS;
8275
+ let blanks = _heatmapGeom && _heatmapGeom.blanks > 0;
8276
+ let caption = m.refs.length + ' columns, one scale';
8277
+ let width = (2 * PAD) + Math.max(BAR_W, legendTextWidth(caption, capFont),
8278
+ blanks ? (HEATMAP_SCALE_BAR_H + 4 + legendTextWidth(HEATMAP_NOT_ASSESSED, capFont)) : 0);
8279
+ let rows = 2 + (blanks ? 1 : 0);
8280
+ let height = (2 * PAD) + HEATMAP_SCALE_BAR_H + (rows * (FS + 4));
8281
+ let x = PAD;
8282
+ let y = size.h - height - PAD; // bottom-left: the overview box lives bottom-right
8283
+
8284
+ let g = _baseSvg.append('g').attr('class', 'aptx-heatmap-ringlegend')
8285
+ .style('pointer-events', 'none');
8286
+ g.append('rect').attr('x', x).attr('y', y).attr('width', width).attr('height', height)
8287
+ .attr('rx', 5)
8288
+ .style('fill', _state.backgroundColorDefault).style('fill-opacity', 0.92)
8289
+ .style('stroke', frame).style('stroke-opacity', 0.5);
8290
+
8291
+ let gradId = 'aptx-heatmap-ringramp';
8292
+ let grad = g.append('defs').append('linearGradient').attr('id', gradId)
8293
+ .attr('x1', 0).attr('y1', 0).attr('x2', 1).attr('y2', 0);
8294
+ VIS_COLOR_RAMP.forEach(function (c, i) {
8295
+ grad.append('stop')
8296
+ .attr('offset', ((i * 100) / (VIS_COLOR_RAMP.length - 1)) + '%')
8297
+ .attr('stop-color', c);
8298
+ });
8299
+ let barY = y + PAD;
8300
+ g.append('rect').attr('x', x + PAD).attr('y', barY)
8301
+ .attr('width', BAR_W).attr('height', HEATMAP_SCALE_BAR_H)
8302
+ .attr('fill', 'url(#' + gradId + ')')
8303
+ .attr('stroke', frame).attr('stroke-opacity', 0.5).attr('stroke-width', 1);
8304
+ let line = barY + HEATMAP_SCALE_BAR_H + FS + 2;
8305
+ g.append('text').attr('x', x + PAD).attr('y', line).attr('text-anchor', 'start')
8306
+ .style('font', capFont).style('fill', ink).text(heatmapNum(m.min));
8307
+ g.append('text').attr('x', x + PAD + BAR_W).attr('y', line).attr('text-anchor', 'end')
8308
+ .style('font', capFont).style('fill', ink).text(heatmapNum(m.max));
8309
+ if (blanks) {
8310
+ line += FS + 4;
8311
+ g.append('rect').attr('x', x + PAD).attr('y', line - HEATMAP_SCALE_BAR_H)
8312
+ .attr('width', HEATMAP_SCALE_BAR_H).attr('height', HEATMAP_SCALE_BAR_H)
8313
+ .attr('fill', _state.backgroundColorDefault)
8314
+ .attr('stroke', frame).attr('stroke-opacity', 0.45).attr('stroke-width', 1);
8315
+ g.append('text').attr('x', x + PAD + HEATMAP_SCALE_BAR_H + 4).attr('y', line)
8316
+ .attr('text-anchor', 'start')
8317
+ .style('font', capFont).style('fill', ink).style('fill-opacity', 0.85)
8318
+ .text(HEATMAP_NOT_ASSESSED);
8319
+ }
8320
+ line += FS + 4;
8321
+ g.append('text').attr('x', x + PAD).attr('y', line).attr('text-anchor', 'start')
8322
+ .style('font', capFont).style('fill', ink).style('fill-opacity', 0.85)
8323
+ .text(caption);
8324
+ }
8325
+
8326
+ function heatmapScrollTo(offset) {
8327
+ if (!_heatmapGeom) {
8328
+ return;
8329
+ }
8330
+ let max = Math.max(0, _heatmapGeom.total - _heatmapGeom.visible);
8331
+ let next = Math.max(0, Math.min(max, Math.round(offset)));
8332
+ if (next !== _heatmapColOffset) {
8333
+ _heatmapColOffset = next;
8334
+ drawHeatmapTrack();
8335
+ }
8336
+ }
8337
+
8338
+ // The hover readout, as on the desktop: the tip, the column and its
8339
+ // value, and the scale the colour was taken from -- so a colour can be
8340
+ // turned back into a number without reading the legend off the bottom.
8341
+ function heatmapHoverMove(event) {
8342
+ if (!_heatmapGeom) {
8343
+ return;
8344
+ }
8345
+ let p = d3.pointer(event, _svgGroup.node());
8346
+ let i;
8347
+ let r = -1;
8348
+ if (_heatmapGeom.circular) {
8349
+ // the same two questions in polar form: which ring, and which slice
8350
+ let rad = Math.sqrt((p[0] * p[0]) + (p[1] * p[1]));
8351
+ i = Math.floor((rad - _heatmapGeom.start) / _heatmapGeom.ringW);
8352
+ if (i < 0 || i >= _heatmapGeom.refs.length) {
8353
+ heatmapHoverOut();
8354
+ return;
8355
+ }
8356
+ // polarXY turns an angle a into (cos(a - pi/2), sin(a - pi/2)),
8357
+ // so the angle comes back the same way round
8358
+ let a = Math.atan2(p[1], p[0]) + (Math.PI / 2);
8359
+ let edge = _heatmapGeom.edge;
8360
+ let n = _heatmapGeom.tips.length;
8361
+ for (let k = 0; k < n; ++k) {
8362
+ // the fan can be laid anywhere on the circle (it rotates), so
8363
+ // the comparison is made modulo a full turn rather than on the
8364
+ // raw angle, which need not lie in the same period
8365
+ let lo = edge[k];
8366
+ let hi = edge[k + 1];
8367
+ let d = ((a - lo) % (2 * Math.PI) + (2 * Math.PI)) % (2 * Math.PI);
8368
+ if (d < (hi - lo)) {
8369
+ r = k;
8370
+ break;
8371
+ }
8372
+ }
8373
+ } else {
8374
+ i = Math.floor((p[0] - _heatmapGeom.originX) / _heatmapGeom.cw);
8375
+ if (i < 0 || i >= _heatmapGeom.visible) {
8376
+ heatmapHoverOut();
8377
+ return;
8378
+ }
8379
+ for (let k = 0; k < _heatmapGeom.tips.length; ++k) {
8380
+ if (p[1] >= _heatmapGeom.bounds[k] && p[1] < _heatmapGeom.bounds[k + 1]) {
8381
+ r = k;
8382
+ break;
8383
+ }
8384
+ }
8385
+ }
8386
+ if (r < 0) {
8387
+ heatmapHoverOut();
8388
+ return;
8389
+ }
8390
+ let tip = _heatmapGeom.tips[r];
8391
+ let col = _heatmapGeom.refs[(_heatmapGeom.offset || 0) + i];
8392
+ let v = forester.heatmapValue(tip, col.ref);
8393
+ let txt = 'Tip: ' + (displayNodeName(tip) || '?') + '<br>'
8394
+ + col.label + ': ' + (v === null ? HEATMAP_NOT_ASSESSED : heatmapNum(v)) + '<br>'
8395
+ + 'Scale: ' + heatmapNum(_heatmapGeom.min) + ' – ' + heatmapNum(_heatmapGeom.max);
8396
+ let tip_el = _node_mouseover_div.node();
8397
+ tip_el.classList.remove('aptx-light', 'aptx-dark');
8398
+ if (_panelTheme) {
8399
+ tip_el.classList.add('aptx-' + _panelTheme);
8400
+ }
8401
+ _node_mouseover_div.html(markUpDataLabels(escapeHtmlKeepBreaks(txt)));
8402
+ placeHoverReadout(tip_el, event);
8403
+ _node_mouseover_div.transition().duration(100).style('opacity', 0.95);
8404
+ }
8405
+
8406
+ function heatmapHoverOut() {
8407
+ _node_mouseover_div.transition().duration(300).style('opacity', 1e-6);
8408
+ }
8409
+
8410
+
8411
+ // The reader's own column order, as the desktop's Annotation Fields arrows
8412
+ // set it. Theirs also chooses WHICH fields are columns and of what type;
8413
+ // ours takes every numeric field automatically, so this dialog is about
8414
+ // order and nothing else.
8415
+ //
8416
+ // Moving anything here makes the order Manual, and Manual never re-sorts --
8417
+ // a sorting mode that quietly undid a move would make the move pointless.
8418
+ // The same rule as the desktop's resolveEdited, minus its "did the order
8419
+ // actually change" test, which it needs only because its dialog does other
8420
+ // things too.
8421
+ function showHeatmapReorderDialog() {
8422
+ let ordered = heatmapOrdered();
8423
+ if (!ordered || ordered.columns.length < 2) {
8424
+ return;
8425
+ }
8426
+ let shell = makeDialogShell(HEATMAP_REORDER_DIALOG, 'Order columns', 320);
8427
+ let body = shell.body;
8428
+ repsElement(body, 'div', 'aptx-reps-lead',
8429
+ 'Drag a column, or use the arrows. Your order is kept until you choose another.');
8430
+ let list = repsElement(body, 'div', 'aptx-reorder');
8431
+ let working = ordered.columns.slice();
8432
+
8433
+ function rowAt(y) {
8434
+ let rows = Array.prototype.slice.call(list.children);
8435
+ for (let i = 0; i < rows.length; ++i) {
8436
+ let b = rows[i].getBoundingClientRect();
8437
+ if (y < b.top + (b.height / 2)) {
8438
+ return i;
8439
+ }
8440
+ }
8441
+ return rows.length;
8442
+ }
8443
+
8444
+ function move(from, to) {
8445
+ if (to < 0 || to >= working.length || from === to) {
8446
+ return false;
8447
+ }
8448
+ working.splice(to, 0, working.splice(from, 1)[0]);
8449
+ draw();
8450
+ return true;
8451
+ }
8452
+
8453
+ function draw() {
8454
+ list.textContent = '';
8455
+ working.forEach(function (col, i) {
8456
+ let row = repsElement(list, 'div', 'aptx-reorder-row');
8457
+ row.tabIndex = 0;
8458
+ repsElement(row, 'span', 'aptx-reorder-grip', '≡');
8459
+ repsElement(row, 'span', 'aptx-reorder-name', col.label || col.ref).title = col.ref;
8460
+ let up = repsElement(row, 'button', 'aptx-reorder-move', '↑');
8461
+ let down = repsElement(row, 'button', 'aptx-reorder-move', '↓');
8462
+ up.type = 'button';
8463
+ down.type = 'button';
8464
+ up.title = 'Move up';
8465
+ down.title = 'Move down';
8466
+ up.disabled = (i === 0);
8467
+ down.disabled = (i === working.length - 1);
8468
+ up.addEventListener('click', function () {
8469
+ if (move(i, i - 1)) {
8470
+ list.children[i - 1].focus();
8471
+ }
8472
+ });
8473
+ down.addEventListener('click', function () {
8474
+ if (move(i, i + 1)) {
8475
+ list.children[i + 1].focus();
8476
+ }
8477
+ });
8478
+ // the arrows reach the same places from the keyboard, so a row
8479
+ // can be moved without a pointer at all
8480
+ row.addEventListener('keydown', function (event) {
8481
+ let d = (event.key === 'ArrowUp') ? -1 : ((event.key === 'ArrowDown') ? 1 : 0);
8482
+ if (d === 0) {
8483
+ return;
8484
+ }
8485
+ event.preventDefault();
8486
+ if (move(i, i + d)) {
8487
+ list.children[i + d].focus();
8488
+ }
8489
+ });
8490
+ row.addEventListener('pointerdown', function (event) {
8491
+ if (event.target.classList.contains('aptx-reorder-move')) {
8492
+ return; // the arrows are not a drag handle
8493
+ }
8494
+ event.preventDefault();
8495
+ row.classList.add('aptx-reorder-dragging');
8496
+ // The LIVE node is moved rather than the list redrawn: a
8497
+ // redraw would replace the element this drag is following.
8498
+ // Its position is read back from the DOM each time instead
8499
+ // of being tracked in a variable -- the first cut kept an
8500
+ // index, updated it, and then used it to work out where to
8501
+ // insert, by which point it no longer meant what the
8502
+ // calculation assumed.
8503
+ let onMove = function (ev) {
8504
+ let kids = Array.prototype.slice.call(list.children);
8505
+ let from = kids.indexOf(row);
8506
+ let to = rowAt(ev.clientY);
8507
+ if (to > from) {
8508
+ to -= 1; // taking the row out shifts everything after it up one
8509
+ }
8510
+ if (to === from || to < 0 || to >= kids.length) {
8511
+ return;
8512
+ }
8513
+ working.splice(to, 0, working.splice(from, 1)[0]);
8514
+ let others = kids.filter(function (k) {
8515
+ return k !== row;
8516
+ });
8517
+ list.insertBefore(row, others[to] || null);
8518
+ Array.prototype.slice.call(list.children).forEach(function (r, k) {
8519
+ let btns = r.querySelectorAll('.aptx-reorder-move');
8520
+ btns[0].disabled = (k === 0);
8521
+ btns[1].disabled = (k === list.children.length - 1);
8522
+ });
8523
+ };
8524
+ let onUp = function () {
8525
+ row.classList.remove('aptx-reorder-dragging');
8526
+ window.removeEventListener('pointermove', onMove);
8527
+ window.removeEventListener('pointerup', onUp);
8528
+ draw();
8529
+ };
8530
+ window.addEventListener('pointermove', onMove);
8531
+ window.addEventListener('pointerup', onUp);
8532
+ });
8533
+ });
8534
+ }
8535
+ draw();
8536
+
8537
+ let actions = repsElement(body, 'div', 'aptx-reps-actions');
8538
+ repsButton(actions, 'Automatic', false, function () {
8539
+ // back to whatever the data asks for: the arrangement is dropped,
8540
+ // not merely overridden
8541
+ _state.heatmapManualOrder = null;
8542
+ _state.heatmapColumnOrder = null;
8543
+ shell.dialog.close();
8544
+ syncHeatmapControls();
8545
+ scheduleUpdate(null, 0);
8546
+ });
8547
+ repsButton(actions, 'Cancel', false, function () {
8548
+ shell.dialog.close();
8549
+ });
8550
+ repsButton(actions, 'Apply', true, function () {
8551
+ _state.heatmapManualOrder = working.map(function (c) {
8552
+ return c.ref;
8553
+ });
8554
+ _state.heatmapColumnOrder = 'manual';
8555
+ shell.dialog.close();
8556
+ syncHeatmapControls();
8557
+ scheduleUpdate(null, 0);
8558
+ });
8559
+ shell.dialog.showModal();
8560
+ // showModal() would otherwise land on the close button: focus belongs
8561
+ // on the list, where the arrow keys already move a row
8562
+ if (list.firstChild) {
8563
+ list.firstChild.focus();
8564
+ }
8565
+ }
8566
+
8567
+ function heatmapCbClicked() {
8568
+ _state.showHeatmap = getCheckboxValue(HEATMAP_CB);
8569
+ syncHeatmapControls();
8570
+ scheduleUpdate(null, 0);
8571
+ }
8572
+
7236
8573
  // ===================== Collapsed clades =====================
7237
8574
  // Collapsing is DISPLAY state on the node (d.collapsed) and nothing
7238
8575
  // else: forester never sees it, the writers never see it, and the data
@@ -7486,7 +8823,7 @@ function (root, d3, forester, phyloXml) {
7486
8823
  // The wedge's reach: the clade's nearest and farthest tips, in the
7487
8824
  // layout's x. One edge runs to the nearest tip and the other to the
7488
8825
  // farthest, so the shape itself shows how uneven the clade's branch
7489
- // lengths are (as iTOL draws it). Christian chose this over the
8826
+ // lengths are. Christian chose this over the
7490
8827
  // desktop's symmetric triangle on an average base (2026-09-14). A
7491
8828
  // phylogram measures the tips; a cladogram, where every leaf sits on
7492
8829
  // the tip column, gives the wedge one depth step.
@@ -7728,6 +9065,16 @@ function (root, d3, forester, phyloXml) {
7728
9065
  }
7729
9066
  if (_basicTreeProperties.alignedMolSeqs === true && _basicTreeProperties.maxMolSeqLength > 0) {
7730
9067
  s.msa = _state.showMsa === true;
9068
+ if (_state.showMsaLogo === true) {
9069
+ s.msaLogo = true;
9070
+ }
9071
+ }
9072
+ if (heatmapAvailable()) {
9073
+ s.heatmap = _state.showHeatmap === true;
9074
+ s.heatmapOrder = heatmapMode();
9075
+ if (_state.heatmapManualOrder) {
9076
+ s.heatmapManual = _state.heatmapManualOrder.slice();
9077
+ }
7731
9078
  }
7732
9079
  if (_basicTreeProperties.domainArchitectures === true) {
7733
9080
  s.domains = _state.showDomainArchitectures === true;
@@ -7849,6 +9196,20 @@ function (root, d3, forester, phyloXml) {
7849
9196
  if (typeof s.msa === 'boolean') {
7850
9197
  _state.showMsa = s.msa;
7851
9198
  }
9199
+ if (typeof s.msaLogo === 'boolean') {
9200
+ _state.showMsaLogo = s.msaLogo;
9201
+ }
9202
+ if (typeof s.heatmap === 'boolean') {
9203
+ _state.showHeatmap = s.heatmap;
9204
+ }
9205
+ if (Array.isArray(s.heatmapManual) && s.heatmapManual.every(function (r) {
9206
+ return typeof r === 'string';
9207
+ })) {
9208
+ _state.heatmapManualOrder = s.heatmapManual.slice();
9209
+ }
9210
+ if (forester.HEATMAP_ORDER_MODES.indexOf(s.heatmapOrder) >= 0) {
9211
+ _state.heatmapColumnOrder = s.heatmapOrder;
9212
+ }
7852
9213
  if (typeof s.domains === 'boolean') {
7853
9214
  _state.showDomainArchitectures = s.domains;
7854
9215
  }
@@ -7948,6 +9309,9 @@ function (root, d3, forester, phyloXml) {
7948
9309
  }
7949
9310
  });
7950
9311
  setCheckboxValue(MSA_CB, _state.showMsa === true);
9312
+ setCheckboxValue(MSA_LOGO_CB, _state.showMsaLogo === true);
9313
+ setCheckboxValue(HEATMAP_CB, _state.showHeatmap === true);
9314
+ syncHeatmapControls();
7951
9315
  setCheckboxValue(TIME_AXIS_CB, _state.showTimeAxis === true);
7952
9316
  setCheckboxValue(TIME_GRID_CB, _state.timeAxisGrid === true);
7953
9317
  setCheckboxValue(DOMAIN_GLOW_CB, _state.domainGlow === true);
@@ -8019,10 +9383,20 @@ function (root, d3, forester, phyloXml) {
8019
9383
  // Lists are comma-joined, booleans 1 / 0, the two searches flattened
8020
9384
  // to a / af / am / a2 and b / bf / bm / b2. Made for a URL hash, where
8021
9385
  // none of these characters needs escaping.
8022
- const VIEW_TEXT_KEYS = ['layout', 'display', 'order', 'root', 'colorBy', 'shapeBy', 'domainLabels', 'combine'];
9386
+ // Every key getViewState writes has to be here, or a view survives
9387
+ // getViewState/applyViewState (what an embedder uses) and is silently
9388
+ // dropped from the URL hash (what "Copy link to this view" produces).
9389
+ // The heat map's four keys and the alignment logo went in exactly that
9390
+ // way, while the README promised a link reproduced the figure.
9391
+ const VIEW_TEXT_KEYS = ['layout', 'display', 'order', 'root', 'colorBy', 'shapeBy', 'domainLabels', 'combine',
9392
+ 'heatmapOrder'];
8023
9393
  const VIEW_INT_KEYS = ['tree', 'subtree', 'rotation', 'domainEvalue'];
8024
9394
  const VIEW_NUMBER_KEYS = ['font', 'node', 'branch'];
8025
- const VIEW_BOOL_KEYS = ['horizontalLabels', 'msa', 'domains', 'domainGlow', 'timeAxis', 'timeGrid', 'matchCase', 'inverse'];
9395
+ const VIEW_BOOL_KEYS = ['horizontalLabels', 'msa', 'msaLogo', 'heatmap', 'domains', 'domainGlow',
9396
+ 'timeAxis', 'timeGrid', 'matchCase', 'inverse'];
9397
+ // Lists of strings, comma-joined. A property ref carries ':' but never a
9398
+ // comma, and encodeViewValue leaves both readable in a hash.
9399
+ const VIEW_LIST_KEYS = ['heatmapManual'];
8026
9400
  const VIEW_SEARCH_KEYS = [['searchA', 'a'], ['searchB', 'b']];
8027
9401
 
8028
9402
  function encodeViewValue(v) {
@@ -8056,6 +9430,12 @@ function (root, d3, forester, phyloXml) {
8056
9430
  if (Array.isArray(state.collapsed) && state.collapsed.length > 0) {
8057
9431
  put('collapsed', state.collapsed.join(','));
8058
9432
  }
9433
+ VIEW_LIST_KEYS.forEach(function (k) {
9434
+ let v = state[k];
9435
+ if (Array.isArray(v) && v.length > 0) {
9436
+ parts.push(k + '=' + v.map(encodeViewValue).join(','));
9437
+ }
9438
+ });
8059
9439
  VIEW_BOOL_KEYS.forEach(function (k) {
8060
9440
  if (typeof state[k] === 'boolean') {
8061
9441
  put(k, state[k] ? 1 : 0);
@@ -8122,6 +9502,16 @@ function (root, d3, forester, phyloXml) {
8122
9502
  return parseInt(x, 10);
8123
9503
  });
8124
9504
  }
9505
+ VIEW_LIST_KEYS.forEach(function (k) {
9506
+ if (raw[k] !== undefined) {
9507
+ let list = raw[k].split(',').filter(function (x) {
9508
+ return x.length > 0;
9509
+ });
9510
+ if (list.length > 0) {
9511
+ state[k] = list;
9512
+ }
9513
+ }
9514
+ });
8125
9515
  VIEW_SEARCH_KEYS.forEach(function (sk) {
8126
9516
  let p = sk[1];
8127
9517
  if (raw[p]) {
@@ -8439,8 +9829,10 @@ function (root, d3, forester, phyloXml) {
8439
9829
  }
8440
9830
 
8441
9831
  // 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() {
9832
+ // sized against (there are no font metrics in the model). Used by the
9833
+ // domain tracks and by the heat map's rings, which both start past it --
9834
+ // hence the name, which used to say "domain".
9835
+ function tipLabelSpace() {
8444
9836
  return (_maxLabelLength * _state.externalNodeFontSize * LABEL_SIZE_CALC_FACTOR) + LABEL_SIZE_CALC_ADDITION;
8445
9837
  }
8446
9838
 
@@ -8604,7 +9996,7 @@ function (root, d3, forester, phyloXml) {
8604
9996
  // Where each tip's architecture goes: its start along the track, the
8605
9997
  // top and height of its boxes, and in the radial layouts the
8606
9998
  // transform that turns (and for unrooted moves) it onto its spoke.
8607
- let labelSpace = domainLabelSpace();
9999
+ let labelSpace = tipLabelSpace();
8608
10000
  let rows = [];
8609
10001
  if (_state.circularDisplay) {
8610
10002
  // every bar starts on one ring past the labels and rides its
@@ -8910,6 +10302,60 @@ function (root, d3, forester, phyloXml) {
8910
10302
  setCheckboxValue(DOMAIN_GLOW_CB, _state.domainGlow === true);
8911
10303
  }
8912
10304
 
10305
+ // The control follows the toggle: hidden without it, and hidden in the
10306
+ // radial layouts, where no matrix is drawn to order.
10307
+ function syncHeatmapControls() {
10308
+ let fs = byId(HEATMAP_CONTROLS);
10309
+ if (!fs) {
10310
+ return;
10311
+ }
10312
+ fs.style.display = heatmapShown() ? '' : 'none';
10313
+ if (!heatmapAvailable()) {
10314
+ return;
10315
+ }
10316
+ setValue(HEATMAP_ORDER_SELECT, heatmapMode());
10317
+ let reorder = byId(HEATMAP_REORDER_BUTTON);
10318
+ if (reorder) {
10319
+ // two columns can only be in one order up to a flip, which the
10320
+ // arrows would make look like a choice
10321
+ reorder.disabled = !heatmapShown() || heatmapModel().refs.length < 2;
10322
+ }
10323
+ // phyloXML gives every node its own property list, so tips CAN list the
10324
+ // same columns in different orders -- and then "as in the input" is a
10325
+ // consensus rather than a transcription. Say which, for THIS tree:
10326
+ // nobody can tell by looking at the matrix.
10327
+ let sel = byId(HEATMAP_ORDER_SELECT);
10328
+ let opt = sel ? sel.querySelector('option[value="document"]') : null;
10329
+ if (opt) {
10330
+ let a = heatmapInputAgreement();
10331
+ opt.title = 'the order the input lists the columns in'
10332
+ + ((!a || a.tips < 1) ? ''
10333
+ : (a.conflicting === 0)
10334
+ ? ' \u2014 every tip here lists them the same way, so this is exactly that order'
10335
+ : ' \u2014 ' + a.conflicting + ' of ' + a.tips
10336
+ + ' tips here list them in a different order, so this is the order most of them agree on');
10337
+ }
10338
+ }
10339
+
10340
+ function heatmapOrderChanged() {
10341
+ let v = getValue(HEATMAP_ORDER_SELECT);
10342
+ if (forester.HEATMAP_ORDER_MODES.indexOf(v) < 0 || v === heatmapMode()) {
10343
+ return;
10344
+ }
10345
+ if (v === 'manual' && !_state.heatmapManualOrder) {
10346
+ // Manual with nothing arranged yet freezes what is on screen. The
10347
+ // alternative -- an empty list, which means "no opinion" -- would
10348
+ // silently re-derive the order the reader just asked to keep.
10349
+ let o = heatmapOrdered();
10350
+ _state.heatmapManualOrder = o ? o.columns.map(function (c) {
10351
+ return c.ref;
10352
+ }) : null;
10353
+ }
10354
+ _state.heatmapColumnOrder = v;
10355
+ _heatmapColOffset = 0; // a different order: the old window means nothing
10356
+ scheduleUpdate(null, 0);
10357
+ }
10358
+
8913
10359
  function domainsCbClicked() {
8914
10360
  _state.showDomainArchitectures = getCheckboxValue(DOMAINS_CB);
8915
10361
  if (_state.showDomainArchitectures && radialDisplay()) {
@@ -9046,7 +10492,8 @@ function (root, d3, forester, phyloXml) {
9046
10492
  // own bottom rows (conservation, consensus, ruler) must end above it
9047
10493
  // or the bar covers them -- the old bare slider did exactly that.
9048
10494
  // The scale bar sits in the same band, under the tree's last row.
9049
- return Math.max(msaShown() ? MSA_BOTTOM_RESERVE + MSA_NAV_RESERVE : 0, timeAxisBottomReserve(),
10495
+ return Math.max(msaShown() ? msaBottomReserve() + MSA_NAV_RESERVE : 0, timeAxisBottomReserve(),
10496
+ heatmapBottomReserve(),
9050
10497
  scaleBarShown() && !radialDisplay() ? SCALE_BAR_RESERVE : 0);
9051
10498
  }
9052
10499
 
@@ -9430,7 +10877,7 @@ function (root, d3, forester, phyloXml) {
9430
10877
  return;
9431
10878
  }
9432
10879
  let labelSpace = (_maxLabelLength * _state.externalNodeFontSize * LABEL_SIZE_CALC_FACTOR) + LABEL_SIZE_CALC_ADDITION;
9433
- let outer = maxRad + labelSpace + domainRadialExtent();
10880
+ let outer = maxRad + labelSpace + domainRadialExtent() + heatmapRadialExtent();
9434
10881
  let W = +_baseSvg.attr('width'), H = +_baseSvg.attr('height');
9435
10882
  let scale = 0.9 * (Math.min(W, H) / (2 * outer));
9436
10883
  if (!isFinite(scale) || scale <= 0) {
@@ -9947,6 +11394,7 @@ function (root, d3, forester, phyloXml) {
9947
11394
  _radialRotation = 0;
9948
11395
  _radialLabelsHorizontal = false;
9949
11396
  _msaColOffset = 0;
11397
+ _heatmapColOffset = 0;
9950
11398
  syncZoomRowButtons();
9951
11399
  refreshVisualizations(false);
9952
11400
  // Esc resets to the launch state -- the auto-applied colour, when its
@@ -10513,6 +11961,17 @@ function (root, d3, forester, phyloXml) {
10513
11961
  if (msaCb) {
10514
11962
  msaCb.disabled = radialDisplay();
10515
11963
  }
11964
+ let msaLogoCb = byId(MSA_LOGO_CB);
11965
+ if (msaLogoCb) {
11966
+ // nothing to summarize while the alignment itself is not drawn
11967
+ msaLogoCb.disabled = radialDisplay() || _state.showMsa !== true;
11968
+ }
11969
+ let heatCb = byId(HEATMAP_CB);
11970
+ if (heatCb) {
11971
+ // circular draws the columns as rings; only unrooted cannot
11972
+ heatCb.disabled = _state.unrootedDisplay === true;
11973
+ }
11974
+ syncHeatmapControls();
10516
11975
  let timeCb = byId(TIME_AXIS_CB);
10517
11976
  if (timeCb) {
10518
11977
  timeCb.disabled = radialDisplay();
@@ -11619,6 +13078,30 @@ function (root, d3, forester, phyloXml) {
11619
13078
  + '.aptx-reps-error:empty { display:none; }'
11620
13079
  + '.aptx-reps-line { padding:1px 0; }'
11621
13080
  + '.aptx-reps-question { margin-top:9px; font-weight:600; }'
13081
+ + '.aptx-reorder { margin-top:8px; max-height:300px; overflow:auto; border:1px solid var(--p-line);'
13082
+ + ' border-radius:7px; padding:3px; background:var(--p-surface2); touch-action:none; }'
13083
+ // the panel's own scrollbar treatment: a native one comes out bright
13084
+ // white inside a dark dialog
13085
+ + '.aptx-reorder::-webkit-scrollbar { width:9px; }'
13086
+ + '.aptx-reorder::-webkit-scrollbar-thumb { background:var(--p-line-strong); border-radius:9px;'
13087
+ + ' border:2px solid var(--p-surface2); }'
13088
+ + '.aptx-reorder::-webkit-scrollbar-track { background:transparent; }'
13089
+ + '.aptx-reorder-row { display:flex; align-items:center; gap:7px; padding:3px 5px; border-radius:5px;'
13090
+ + ' cursor:grab; user-select:none; border:1px solid transparent; }'
13091
+ + '.aptx-reorder-row:hover { background:var(--p-accent-weak); }'
13092
+ + '.aptx-reorder-row:focus-visible { outline:none; border-color:var(--p-accent);'
13093
+ + ' box-shadow:0 0 0 2px var(--p-accent-weak); }'
13094
+ + '.aptx-reorder-dragging { cursor:grabbing; background:var(--p-accent-weak);'
13095
+ + ' border-color:var(--p-accent); opacity:0.9; }'
13096
+ + '.aptx-reorder-grip { flex:none; color:var(--p-faint); font-size:12px; line-height:1; }'
13097
+ + '.aptx-reorder-name { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis;'
13098
+ + ' white-space:nowrap; font-size:11px; color:var(--p-ink); }'
13099
+ + '.aptx-reorder-move { flex:none; width:20px; height:20px; padding:0; line-height:1; font:inherit;'
13100
+ + ' font-size:11px; color:var(--p-muted); background:transparent; border:1px solid transparent;'
13101
+ + ' border-radius:5px; cursor:pointer; }'
13102
+ + '.aptx-reorder-move:hover:not(:disabled) { background:var(--p-surface); color:var(--p-accent-ink);'
13103
+ + ' border-color:var(--p-accent); }'
13104
+ + '.aptx-reorder-move:disabled { opacity:0.3; cursor:default; }'
11622
13105
  + '.aptx-reps-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:12px; }'
11623
13106
  + '.aptx-reps-button { font:inherit; font-size:11px; height:26px; padding:0 12px; color:var(--p-ink);'
11624
13107
  + ' background:var(--p-surface2); border:1px solid var(--p-line-strong); border-radius:6px; cursor:pointer;'
@@ -11782,6 +13265,10 @@ function (root, d3, forester, phyloXml) {
11782
13265
  + '.aptx-panel .aptx-domrow { display:flex; align-items:center; gap:4px; margin:3px 0; }'
11783
13266
  + '.aptx-panel .aptx-domrow .aptx-domlabel { flex:0 0 64px; font-size:10px; color:var(--p-muted); }'
11784
13267
  + '.aptx-panel .aptx-domrow input[type=button] { width:30px; padding:0; margin:0; }'
13268
+ // ... except one that has a WORD on it: the rule above sizes the
13269
+ // domain track's tiny plus and minus, and it clipped this to "Reorc"
13270
+ + '.aptx-panel .aptx-domrow input[type=button].aptx-widebtn { width:auto; flex:1 1 auto;'
13271
+ + ' min-width:0; padding:0 8px; }'
11785
13272
  + '.aptx-panel .aptx-domrow select { flex:1 1 auto; min-width:0; }'
11786
13273
  + '.aptx-panel .aptx-domreadout { flex:1 1 auto; text-align:center; font-size:12px; font-variant-numeric:tabular-nums; color:var(--p-ink); }'
11787
13274
  + '.aptx-panel .aptx-glyph { height:14px; width:auto; display:block; overflow:visible; }'
@@ -12664,6 +14151,8 @@ function (root, d3, forester, phyloXml) {
12664
14151
 
12665
14152
  c0.insertAdjacentHTML('beforeend',makeDomainControls());
12666
14153
 
14154
+ c0.insertAdjacentHTML('beforeend',makeHeatmapControls());
14155
+
12667
14156
  c0.insertAdjacentHTML('beforeend',makeZoomControl());
12668
14157
 
12669
14158
 
@@ -12777,6 +14266,10 @@ function (root, d3, forester, phyloXml) {
12777
14266
 
12778
14267
  on(DYNAHIDE_CB, 'click', dynaHideCbClicked);
12779
14268
  on(MSA_CB, 'click', msaCbClicked);
14269
+ on(MSA_LOGO_CB, 'click', msaLogoCbClicked);
14270
+ on(HEATMAP_CB, 'click', heatmapCbClicked);
14271
+ on(HEATMAP_ORDER_SELECT, 'change', heatmapOrderChanged);
14272
+ on(HEATMAP_REORDER_BUTTON, 'click', showHeatmapReorderDialog);
12780
14273
  on(DOMAINS_CB, 'click', domainsCbClicked);
12781
14274
  on(UNCOLLAPSE_ALL_BUTTON, 'click', uncollapseAll);
12782
14275
  on(TREE_PREV_BUTTON, 'click', function () {
@@ -13318,10 +14811,14 @@ function (root, d3, forester, phyloXml) {
13318
14811
  // labels -- they live here
13319
14812
  if (_basicTreeProperties.alignedMolSeqs && _basicTreeProperties.maxMolSeqLength > 0) {
13320
14813
  opts.push(makeCheckboxItem('Alignment', MSA_CB, 'to show/hide the sequence alignment beside the tree (rectangular layout only)'));
14814
+ opts.push(makeCheckboxItem('Sequence Logo', MSA_LOGO_CB, 'to summarize the alignment under it as a sequence logo: each column a stack of letters, as tall as that column\'s information content and shared out by residue frequency. Over the tips currently on screen, so entering a clade summarizes that clade'));
13321
14815
  }
13322
14816
  if (_basicTreeProperties.domainArchitectures) {
13323
14817
  opts.push(makeCheckboxItem('Domain Architectures', DOMAINS_CB, 'to show/hide the protein domain architectures beside the tips', true));
13324
14818
  }
14819
+ if (heatmapAvailable()) {
14820
+ 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)'));
14821
+ }
13325
14822
  if (_timeInfo && _timeInfo.type) {
13326
14823
  opts.push(makeCheckboxItem('Time Axis', TIME_AXIS_CB, 'to show/hide the '
13327
14824
  + (_timeInfo.type === 'geologic' ? 'geologic (ICS) time axis' : 'calendar time axis')
@@ -13430,6 +14927,45 @@ function (root, d3, forester, phyloXml) {
13430
14927
  return h;
13431
14928
  }
13432
14929
 
14930
+ // Shown only while the Heat Map toggle is on (syncHeatmapControls).
14931
+ // The desktop's View > Order Matrix Columns, minus its Manual mode --
14932
+ // that one means "the order you dragged the rows into", and there is
14933
+ // no drag-to-reorder dialog here to drag them in.
14934
+ function makeHeatmapControls() {
14935
+ let h = '<fieldset id="' + HEATMAP_CONTROLS + '" style="display:none">';
14936
+ h = h.concat('<legend>Heat Map</legend>');
14937
+ h = h.concat('<div class="aptx-domrow"><label class="aptx-domlabel" for="' + HEATMAP_ORDER_SELECT
14938
+ + '">Order columns</label>');
14939
+ h = h.concat('<select name="' + HEATMAP_ORDER_SELECT + '" id="' + HEATMAP_ORDER_SELECT
14940
+ + '" title="how the matrix columns are ordered; a clustered order also draws the clustering above the matrix">');
14941
+ // "input", not "file": the columns can come from a pasted tree or
14942
+ // a joined table as easily as from a file, and a reader looking at
14943
+ // a tree they dropped in would fairly ask "what file?". The title
14944
+ // is filled in per tree by syncHeatmapControls, because whether
14945
+ // this really IS the input's order depends on the input.
14946
+ h = h.concat('<option value="document">As in the input</option>');
14947
+ h = h.concat('<option value="clustered" title="columns whose values agree across the tips sit together:'
14948
+ + ' complete-linkage clustering on Euclidean distance, the default of R\'s heat maps. A tip missing'
14949
+ + ' either value is left out of that pair, never read as 0.">Clustered (co-occurrence)</option>');
14950
+ h = h.concat('<option value="clustered-presence" title="columns found in the same tips sit together: a tip'
14951
+ + ' where BOTH columns are 0 is left out of that pair, so two rare genes no longer look alike merely'
14952
+ + ' for being rare. Complete-linkage clustering on the Bray-Curtis dissimilarity. For values that are'
14953
+ + ' 0 or more.">Clustered (ignoring shared absence)</option>');
14954
+ h = h.concat('<option value="alphabetical" title="by column name, ignoring case">Alphabetical</option>');
14955
+ h = h.concat('<option value="frequency" title="highest mean value first, over the tips that have a value'
14956
+ + ' -- on 0/1 data, the fraction of tips carrying it">Frequency</option>');
14957
+ h = h.concat('<option value="manual" title="your own order, set with Reorder columns below. Nothing'
14958
+ + ' re-sorts it.">Manual</option>');
14959
+ h = h.concat('</select></div>');
14960
+ h = h.concat('<div class="aptx-domrow"><span class="aptx-domlabel"></span>');
14961
+ h = h.concat('<input type="button" class="aptx-widebtn" value="Reorder columns…" name="'
14962
+ + HEATMAP_REORDER_BUTTON + '" id="' + HEATMAP_REORDER_BUTTON
14963
+ + '" title="drag the columns into the order you want; doing so sets Manual">');
14964
+ h = h.concat('</div>');
14965
+ h = h.concat('</fieldset>');
14966
+ return h;
14967
+ }
14968
+
13433
14969
  function makeSliders() {
13434
14970
  let h = "";
13435
14971
  h = h.concat('<fieldset>');
@@ -13610,7 +15146,10 @@ function (root, d3, forester, phyloXml) {
13610
15146
  setCheckboxValue(VIS_CB, _state.showVisualizations);
13611
15147
  setCheckboxValue(DYNAHIDE_CB, _state.dynahide);
13612
15148
  setCheckboxValue(MSA_CB, _state.showMsa);
15149
+ setCheckboxValue(MSA_LOGO_CB, _state.showMsaLogo === true);
15150
+ setCheckboxValue(HEATMAP_CB, _state.showHeatmap);
13613
15151
  syncDomainControls();
15152
+ syncHeatmapControls();
13614
15153
  setCheckboxValue(TIME_AXIS_CB, _state.showTimeAxis);
13615
15154
  setCheckboxValue(TIME_GRID_CB, _state.timeAxisGrid);
13616
15155
  setCheckboxValue(SHORTEN_NODE_NAME_CB, _state.shortenNodeNames);