archaeopteryx 3.18.0 → 3.20.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.18.0
23
+ // v 3.20.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.18.0';
106
+ const VERSION = '3.20.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';
@@ -380,8 +380,8 @@ function (root, d3, forester, phyloXml) {
380
380
  const DOMAIN_EVALUE_READOUT = 'domain_evalue';
381
381
  const DOMAIN_LABELS_SELECT = 'domain_labels';
382
382
  const DOMAIN_GLOW_CB = 'domain_glow_cb';
383
- const TIME_AXIS_CB = 'timeaxis_cb';
384
- const TIME_GRID_CB = 'timegrid_cb';
383
+ const SCALE_AXIS_CB = 'scaleaxis_cb';
384
+ const SCALE_GRID_CB = 'scalegrid_cb';
385
385
  const MSA_SCROLL_ID = 'aptxmsascroll';
386
386
  const FONT_SIZE_SLIDER = 'fs_sl';
387
387
  const EXTERNAL_LABEL_CB = 'extl_cb';
@@ -456,6 +456,9 @@ function (root, d3, forester, phyloXml) {
456
456
  const SEARCH_COMBINE_SELECT = 'scmb';
457
457
  const SEARCH_COMBINE_ROW = 'scmb_row';
458
458
  const SEQUENCE_CB = 'seq_cb';
459
+ const PROPERTIES_CB = 'props_cb';
460
+ const LABEL_FIELDS_BUTTON = 'label_fields_b';
461
+ const LABEL_FIELDS_DIALOG = 'label_fields_dialog';
459
462
  const SHORTEN_NODE_NAME_CB = 'shortennodename_cb';
460
463
  const TAXONOMY_CB = 'tax_cb';
461
464
  const ZOOM_IN_X = 'zoomin_x';
@@ -630,17 +633,31 @@ function (root, d3, forester, phyloXml) {
630
633
  // within 1.2px of a ring boundary came out background-coloured.
631
634
  const HEATMAP_RING_BLEED = 1.2;
632
635
  const HEATMAP_BLANK = 'blank'; // the run-merger's stand-in for a cell nobody filled in
636
+ // Cell borders (Christian, 2026-09-30): each cell outlined in its own hue,
637
+ // darker, so a block of equal values still reads as a block while each
638
+ // cell can be counted. Only where a cell is big enough on SCREEN to carry
639
+ // one, and not on a matrix so large that a node per cell would drag.
640
+ const HEATMAP_BORDER_MIN_PX = 5; // the cell's smaller side on screen
641
+ const HEATMAP_BORDER_MAX_CELLS = 60000;
642
+ const HEATMAP_BORDER_W = 0.75; // screen px at any zoom (non-scaling stroke)
643
+ const HEATMAP_BORDER_DARKEN = 0.8; // d3's darker(k): each step x0.7
644
+ const HEATMAP_SEAL_W = 0.6; // a merged run's stroke in its own colour, over the seams
633
645
  const HEATMAP_DENDRO_GAP = 5; // px between the dendrogram's leaves and the first row
634
646
  const HEATMAP_DENDRO_MIN_BAND = 24; // the band it is drawn in, from the label font
635
647
  const HEATMAP_DENDRO_MAX_BAND = 72;
636
648
  let _heatmapReserve = 0; // horizontal px reserved for the matrix, set with _w
637
649
  let _heatmapModel = null; // forester.heatmapColumns of the WHOLE tree, cached per launch
650
+ let _heatmapCells = null; // what was drawn: {cellMin (tree units), count, bordered}
651
+ let _heatmapBorderTimer = null;
652
+ let _figureMatrix = null; // the MATRIX refs the file's aptx:figure names, when it draws a matrix
653
+ let _labelFields = null; // forester.labelPropertyRefs of the tree, cached until an edit
638
654
  let _heatmapColor = null; // the one scale every column is painted on
639
655
  let _heatmapColOffset = 0; // first shown column, while the matrix is windowed
640
656
  let _heatmapGeom = null; // the last draw's geometry, for the hover readout
641
657
  // ------ time axis (the desktop's geologic / calendar overlays) ------
642
658
  const TIME_GEO_RESERVE = 52; // two ICS band rows + the Ma ruler
643
659
  const TIME_CAL_RESERVE = 26; // the calendar year ruler
660
+ const DISTANCE_AXIS_RESERVE = 26; // the distance ruler, one label row
644
661
  const TIME_BAND_ROW_H = 13;
645
662
  const HPD_BAR_COLOR = 'rgba(70,130,220,0.35)'; // translucent blue, the usual HPD tint
646
663
  const FOSSIL_BAR_COLOR = 'rgba(150,100,55,0.86)'; // opaque-ish sepia
@@ -1330,6 +1347,7 @@ function (root, d3, forester, phyloXml) {
1330
1347
  _svgGroup.attr('transform', event.transform);
1331
1348
  placeFloatingOverlays();
1332
1349
  updateOverviewViewport();
1350
+ heatmapBordersFollowZoom();
1333
1351
  }
1334
1352
 
1335
1353
  // A strip in the floating layer. Drawn in TREE coordinates like everything
@@ -3390,7 +3408,7 @@ function (root, d3, forester, phyloXml) {
3390
3408
  _svgGroup.selectAll('g.aptx-align-ext').remove();
3391
3409
  let alignedRect = !radialDisplay() && _state.phylogram && _state.alignPhylogram;
3392
3410
  let tipGuides = alignedRect && _state.showExternalLabels
3393
- && (_state.showNodeName || _state.showTaxonomy || _state.showSequence);
3411
+ && (_state.showNodeName || _state.showTaxonomy || _state.showSequence || _state.showProperties);
3394
3412
  // a collapsed clade's label always shows, so its guide does too: from
3395
3413
  // past the wedge's farthest tip to the label column
3396
3414
  let collapsedGuides = alignedRect ? nodes.filter(function (d) {
@@ -4660,6 +4678,10 @@ function (root, d3, forester, phyloXml) {
4660
4678
  }
4661
4679
 
4662
4680
 
4681
+ if (_state.showProperties && phynode.properties) {
4682
+ l = append(l, labelPropertiesOf(phynode));
4683
+ }
4684
+
4663
4685
  if (_nodeLabels && phynode.properties) {
4664
4686
  const props_length = phynode.properties.length;
4665
4687
  if (props_length > 0) {
@@ -5440,6 +5462,23 @@ function (root, d3, forester, phyloXml) {
5440
5462
  return v === true ? 'confidence' : PARSE_DEFAULTS.internalNumericLabels;
5441
5463
  },
5442
5464
  note: 'use \'confidence\' for what this flag did (\'auto\' is the new default, and does not promote mixed trees)'
5465
+ },
5466
+ // Renamed in 3.19.0, when the axis stopped being time-only: a tree
5467
+ // whose branches measure divergence gets a distance axis from the
5468
+ // same switch. The meaning on a time tree is unchanged.
5469
+ showTimeAxis: {
5470
+ to: 'showScaleAxis',
5471
+ map: function (v) {
5472
+ return v;
5473
+ },
5474
+ note: 'the one switch now draws the time axis on a time tree and a distance axis on any other phylogram'
5475
+ },
5476
+ timeAxisGrid: {
5477
+ to: 'showScaleGrid',
5478
+ map: function (v) {
5479
+ return v;
5480
+ },
5481
+ note: 'the grid follows the scale axis, time or distance'
5443
5482
  }
5444
5483
  };
5445
5484
 
@@ -5603,6 +5642,8 @@ function (root, d3, forester, phyloXml) {
5603
5642
  'layout',
5604
5643
  'showMsa',
5605
5644
  'showMsaLogo',
5645
+ 'showProperties',
5646
+ 'labelProperties',
5606
5647
  'showHeatmap',
5607
5648
  'heatmapColumnOrder',
5608
5649
  'heatmapManualOrder',
@@ -5610,8 +5651,8 @@ function (root, d3, forester, phyloXml) {
5610
5651
  'domainLabels',
5611
5652
  'domainGlow',
5612
5653
  'domainEvalueExponent',
5613
- 'showTimeAxis',
5614
- 'timeAxisGrid',
5654
+ 'showScaleAxis',
5655
+ 'showScaleGrid',
5615
5656
  'showSupportDots',
5616
5657
  'searchAinitialValue',
5617
5658
  'searchBinitialValue',
@@ -5842,8 +5883,18 @@ function (root, d3, forester, phyloXml) {
5842
5883
  // numeric per-tip fields, which is common enough that turning it on
5843
5884
  // by itself would be an opinion about the tree rather than a service
5844
5885
  // -- the alignment track, by contrast, is on when there IS one.
5886
+ // The desktop's figure setting: its MATRIX columns open as the heat
5887
+ // map, only those, in the file's order. Only the phylogeny's own
5888
+ // aptx:figure counts; a copy on a clade (the old place) is dropped,
5889
+ // so a tree saved from here carries none. Here and not in launchInto:
5890
+ // telling whether the columns exist is a scan of every tip, which on
5891
+ // a big tree belongs behind the working card, not in front of it.
5892
+ if (_treeData) {
5893
+ forester.dropCladeFigures(_treeData);
5894
+ }
5895
+ _figureMatrix = _treeData ? figureMatrixOf(_treeData) : null;
5845
5896
  if (_state.showHeatmap === undefined) {
5846
- _state.showHeatmap = false;
5897
+ _state.showHeatmap = _figureMatrix !== null;
5847
5898
  }
5848
5899
  // The logo is a summary of the alignment, not a second view of the
5849
5900
  // tree, so it waits to be asked for: an alignment opens showing its
@@ -5858,7 +5909,7 @@ function (root, d3, forester, phyloXml) {
5858
5909
  // value always wins and is never re-derived -- including 'document',
5859
5910
  // which keeps the order the producer wrote the fields in.
5860
5911
  if (_state.heatmapColumnOrder === undefined) {
5861
- _state.heatmapColumnOrder = null;
5912
+ _state.heatmapColumnOrder = _figureMatrix ? 'manual' : null;
5862
5913
  } else if (_state.heatmapColumnOrder !== null
5863
5914
  && forester.HEATMAP_ORDER_MODES.indexOf(_state.heatmapColumnOrder) < 0) {
5864
5915
  throw new Error(ERROR + '"heatmapColumnOrder" must be one of '
@@ -5869,7 +5920,7 @@ function (root, d3, forester, phyloXml) {
5869
5920
  // not got is ignored rather than fatal -- an order outliving one of
5870
5921
  // its columns is ordinary (see forester.heatmapManualOrder).
5871
5922
  if (_state.heatmapManualOrder === undefined) {
5872
- _state.heatmapManualOrder = null;
5923
+ _state.heatmapManualOrder = _figureMatrix ? _figureMatrix.slice() : null;
5873
5924
  } else if (_state.heatmapManualOrder !== null
5874
5925
  && !(Array.isArray(_state.heatmapManualOrder)
5875
5926
  && _state.heatmapManualOrder.every(function (r) {
@@ -5910,10 +5961,11 @@ function (root, d3, forester, phyloXml) {
5910
5961
  + ' with a missing or impossible from / to / E-value ignored');
5911
5962
  }
5912
5963
  }
5913
- // Likewise a dated tree draws its time axis from the start --
5914
- // geologic ICS bands or calendar years, decided from the <date>
5915
- // elements by forester.timeAxisInfo -- again unless the caller set
5916
- // showTimeAxis explicitly.
5964
+ // Likewise a dated tree draws its scale axis from the start -- a
5965
+ // time axis of geologic ICS bands or calendar years, decided from the
5966
+ // <date> elements by forester.timeAxisInfo -- again unless the caller
5967
+ // set showScaleAxis explicitly. Any other tree opens without one, as
5968
+ // on the desktop: its distance axis is asked for.
5917
5969
  _timeInfo = _treeData ? forester.timeAxisInfo(forester.getTreeRoot(_treeData)) : null;
5918
5970
  _timeTree = _treeData ? forester.isTimeTree(_treeData) : false;
5919
5971
  // Whether this tree says two different things -- when its nodes sit in
@@ -5922,10 +5974,13 @@ function (root, d3, forester, phyloXml) {
5922
5974
  // BEAST or Newick file arrives stating its own branch lengths.
5923
5975
  _branchScaleAvailable = _treeData ? forester.hasTimeAndDivergence(_treeData) : false;
5924
5976
  _state.branchScale = _treeData ? forester.branchLengthScale(_treeData) : 'divergence';
5925
- if (_state.showTimeAxis === undefined) {
5926
- _state.showTimeAxis = !!(_timeInfo && _timeInfo.type);
5977
+ if (_state.showScaleAxis === undefined) {
5978
+ // on for a tree SHOWN in time; a dated tree that opens in
5979
+ // divergence (a build refused the switch) is a divergence tree
5980
+ // on screen, and opens without its distance axis like any other
5981
+ _state.showScaleAxis = !!(_timeInfo && _timeInfo.type) && !branchesShowDivergence();
5927
5982
  }
5928
- _state.timeAxisGrid = _state.timeAxisGrid === true; // desktop default: off
5983
+ _state.showScaleGrid = _state.showScaleGrid === true; // desktop default: off
5929
5984
  _state.showSupportDots = _state.showSupportDots === true;
5930
5985
  _state.showNodeEvents = _basicTreeProperties.nodeEvents === true;
5931
5986
  _state.showBranchEvents = _basicTreeProperties.branchEvents === true;
@@ -6038,6 +6093,27 @@ function (root, d3, forester, phyloXml) {
6038
6093
  + ' characters)');
6039
6094
  }
6040
6095
 
6096
+ // Properties in the tip label: off unless asked for, as on the desktop,
6097
+ // in every user-visible property's own order unless a list says which.
6098
+ // The file's figure setting (aptx:figure) can say both, and whether
6099
+ // the names show: labelprops, show.SHOW_PROPERTIES and
6100
+ // show.SHOW_NODE_NAMES, the desktop's own keys. The config wins.
6101
+ let figure = _treeData ? forester.readFigure(_treeData) : null;
6102
+ if (_state.showProperties === undefined) {
6103
+ _state.showProperties = !!(figure && figure.show.SHOW_PROPERTIES === true);
6104
+ }
6105
+ if (_state.labelProperties === undefined) {
6106
+ _state.labelProperties = (figure && figure.labelProps) ? figure.labelProps.slice() : null;
6107
+ } else if (_state.labelProperties !== null && !(Array.isArray(_state.labelProperties)
6108
+ && _state.labelProperties.every(function (r) {
6109
+ return typeof r === 'string';
6110
+ }))) {
6111
+ throw new Error(ERROR + '"labelProperties" must be an array of property refs');
6112
+ }
6113
+ if (figure && typeof figure.show.SHOW_NODE_NAMES === 'boolean' && _basicTreeProperties.nodeNames) {
6114
+ _state.showNodeName = figure.show.SHOW_NODE_NAMES;
6115
+ }
6116
+
6041
6117
  // A small tree is drawn with a heavier stroke; hairlines are for trees
6042
6118
  // dense enough to need them.
6043
6119
  _state.branchWidthDefault = _basicTreeProperties.externalNodesCount <= SMALL_TREE_MAX_EXT_NODES
@@ -6496,6 +6572,8 @@ function (root, d3, forester, phyloXml) {
6496
6572
  _treeData = phylo;
6497
6573
  _trees = trees;
6498
6574
  _treeIndex = index;
6575
+ _figureMatrix = null; // read by initializeState, behind the working card
6576
+ _labelFields = null;
6499
6577
  _launchConfig = config;
6500
6578
  _container = containerEl;
6501
6579
  assignViewIds(phylo);
@@ -7253,6 +7331,7 @@ function (root, d3, forester, phyloXml) {
7253
7331
  _domain.palette = null; // the tree changed: the domain names are dealt their colours again
7254
7332
  }
7255
7333
  if (edited) {
7334
+ _labelFields = null; // the fields the labels can show, too
7256
7335
  _heatmapModel = null; // ... and the heat map's columns and scale are derived from it again
7257
7336
  _heatmapColor = null;
7258
7337
  }
@@ -8170,11 +8249,28 @@ function (root, d3, forester, phyloXml) {
8170
8249
 
8171
8250
  function heatmapModel() {
8172
8251
  if (!_heatmapModel && _treeData) {
8173
- _heatmapModel = forester.heatmapColumns(_treeData);
8252
+ _heatmapModel = forester.heatmapColumns(_treeData, _figureMatrix);
8174
8253
  }
8175
8254
  return _heatmapModel;
8176
8255
  }
8177
8256
 
8257
+ // The file's MATRIX columns, when at least a heat map's worth of them are
8258
+ // numeric fields this tree has; otherwise null, and the heat map offers
8259
+ // every numeric field as it would without a figure.
8260
+ // The model it builds is kept as the heat map's own, so the scan runs once.
8261
+ function figureMatrixOf(tree) {
8262
+ let refs = forester.figureMatrixRefs(tree);
8263
+ if (refs.length < HEATMAP_MIN_COLUMNS) {
8264
+ return null;
8265
+ }
8266
+ let model = forester.heatmapColumns(tree, refs);
8267
+ if (model.refs.length < HEATMAP_MIN_COLUMNS) {
8268
+ return null;
8269
+ }
8270
+ _heatmapModel = model;
8271
+ return refs;
8272
+ }
8273
+
8178
8274
  // Offered when the tree holds at least two numeric per-tip refs that
8179
8275
  // actually carry values: one column is a stripe rather than a matrix,
8180
8276
  // and a scale needs something to span.
@@ -8382,7 +8478,53 @@ function (root, d3, forester, phyloXml) {
8382
8478
  return heatmapTopReserve();
8383
8479
  }
8384
8480
 
8481
+ // Whether cells of this size (tree units) and number are drawn one by one
8482
+ // with borders at zoom scale k, or merged into runs.
8483
+ function heatmapBordered(cellMin, count, k) {
8484
+ return count <= HEATMAP_BORDER_MAX_CELLS && (cellMin * k) >= HEATMAP_BORDER_MIN_PX;
8485
+ }
8486
+
8487
+ function heatmapBorderColor(fill) {
8488
+ let c = d3.color(fill);
8489
+ return c ? c.darker(HEATMAP_BORDER_DARKEN).formatHex() : fill;
8490
+ }
8491
+
8492
+ // A cell (or a merged run) of colour `fill`: bordered, its edge a darker
8493
+ // shade of itself; or sealed, stroked in its own colour so that no seam of
8494
+ // background shows between two runs at a fractional zoom -- the faint lines
8495
+ // that came and went with the zoom level. Both strokes are a constant
8496
+ // screen width, so a zoom neither fattens nor loses them.
8497
+ function paintHeatmapCell(el, fill, bordered) {
8498
+ el.attr('fill', fill)
8499
+ .attr('stroke', bordered ? heatmapBorderColor(fill) : fill)
8500
+ .attr('stroke-width', bordered ? HEATMAP_BORDER_W : HEATMAP_SEAL_W)
8501
+ .attr('vector-effect', 'non-scaling-stroke');
8502
+ return el;
8503
+ }
8504
+
8505
+ // Called on every zoom event: when the zoom has carried the cells across
8506
+ // the border threshold, the heat map alone is drawn again once the zoom
8507
+ // settles.
8508
+ function heatmapBordersFollowZoom() {
8509
+ if (!_heatmapCells || !_baseSvg) {
8510
+ return;
8511
+ }
8512
+ let want = heatmapBordered(_heatmapCells.cellMin, _heatmapCells.count, currentZoomScale());
8513
+ if (want === _heatmapCells.bordered) {
8514
+ return;
8515
+ }
8516
+ if (_heatmapBorderTimer) {
8517
+ clearTimeout(_heatmapBorderTimer);
8518
+ }
8519
+ _heatmapBorderTimer = setTimeout(function () {
8520
+ _heatmapBorderTimer = null;
8521
+ drawHeatmapTrack();
8522
+ placeFloatingOverlays();
8523
+ }, 120);
8524
+ }
8525
+
8385
8526
  function drawHeatmapTrack() {
8527
+ _heatmapCells = null;
8386
8528
  if (!_svgGroup) {
8387
8529
  return;
8388
8530
  }
@@ -8465,6 +8607,9 @@ function (root, d3, forester, phyloXml) {
8465
8607
  // a run of CELLS, or its top and bottom edges read as a drawn rule
8466
8608
  // across the matrix rather than as missing data.
8467
8609
  let mergeBlanks = cw < HEATMAP_LABEL_MIN_COL_W;
8610
+ let cellMin = Math.min(cw, n > 1 ? 2 * pad : cw);
8611
+ let bordered = heatmapBordered(cellMin, n * visible, currentZoomScale());
8612
+ _heatmapCells = {cellMin: cellMin, count: n * visible, bordered: bordered};
8468
8613
  for (let r = 0; r < n; ++r) {
8469
8614
  let d = tips[r];
8470
8615
  let cy = Math.round(bounds[r]);
@@ -8486,7 +8631,7 @@ function (root, d3, forester, phyloXml) {
8486
8631
  rect.attr('fill', _state.backgroundColorDefault)
8487
8632
  .attr('stroke', ink).attr('stroke-opacity', 0.45).attr('stroke-width', 1);
8488
8633
  } else {
8489
- rect.attr('fill', runFill);
8634
+ paintHeatmapCell(rect, runFill, bordered);
8490
8635
  }
8491
8636
  runStart = -1;
8492
8637
  runFill = null;
@@ -8497,7 +8642,8 @@ function (root, d3, forester, phyloXml) {
8497
8642
  if (fill === HEATMAP_BLANK) {
8498
8643
  blanks++;
8499
8644
  }
8500
- if (runFill === fill && (mergeBlanks || fill !== HEATMAP_BLANK)) {
8645
+ // bordered, every cell is its own: a run would hide the cells in it
8646
+ if (!bordered && runFill === fill && (mergeBlanks || fill !== HEATMAP_BLANK)) {
8501
8647
  continue; // the run extends
8502
8648
  }
8503
8649
  flush(i);
@@ -8807,6 +8953,10 @@ function (root, d3, forester, phyloXml) {
8807
8953
  edge[n] = ang[n - 1] + pad;
8808
8954
 
8809
8955
  let blanks = 0;
8956
+ // a cell's smaller side: its ring width, or its arc at the ring's inside
8957
+ let cellMin = Math.min(ringW, n > 1 ? 2 * pad * start : ringW);
8958
+ let bordered = heatmapBordered(cellMin, n * columns.length, currentZoomScale());
8959
+ _heatmapCells = {cellMin: cellMin, count: n * columns.length, bordered: bordered};
8810
8960
  for (let c = 0; c < columns.length; ++c) {
8811
8961
  let r0 = start + (c * ringW);
8812
8962
  let r1 = r0 + ringW;
@@ -8825,15 +8975,19 @@ function (root, d3, forester, phyloXml) {
8825
8975
  // A BLANK is not bled -- it is background-coloured, so a seam
8826
8976
  // beside it shows nothing, and bleeding it would push its
8827
8977
  // outward OUTLINE under the next ring and cost it an edge.
8828
- let bleed = (runFill === HEATMAP_BLANK) ? 0 : HEATMAP_RING_BLEED;
8978
+ // (bordered, the border covers the seam and a bleed would push
8979
+ // one cell's edge under its neighbour)
8980
+ let bleed = (runFill === HEATMAP_BLANK || bordered) ? 0 : HEATMAP_RING_BLEED;
8829
8981
  let a1 = edge[endI] + (bleed / Math.max(1, r1));
8830
8982
  let path = g.append('path')
8831
8983
  .attr('d', heatmapSector(edge[runStart], a1, r0, r1 + bleed));
8832
8984
  if (runFill === HEATMAP_BLANK) {
8833
8985
  path.attr('fill', _state.backgroundColorDefault)
8834
8986
  .attr('stroke', ink).attr('stroke-opacity', 0.45).attr('stroke-width', 1);
8987
+ } else if (bordered) {
8988
+ paintHeatmapCell(path, runFill, true);
8835
8989
  } else {
8836
- path.attr('fill', runFill);
8990
+ path.attr('fill', runFill); // the bleed already closes the seams here
8837
8991
  }
8838
8992
  runStart = -1;
8839
8993
  runFill = null;
@@ -8844,7 +8998,7 @@ function (root, d3, forester, phyloXml) {
8844
8998
  if (fill === HEATMAP_BLANK) {
8845
8999
  blanks++;
8846
9000
  }
8847
- if (runFill === fill && (ringW < HEATMAP_LABEL_MIN_COL_W || fill !== HEATMAP_BLANK)) {
9001
+ if (!bordered && runFill === fill && (ringW < HEATMAP_LABEL_MIN_COL_W || fill !== HEATMAP_BLANK)) {
8848
9002
  continue;
8849
9003
  }
8850
9004
  flush(i);
@@ -9605,6 +9759,7 @@ function (root, d3, forester, phyloXml) {
9605
9759
  ['name', NODE_NAME_CB, 'showNodeName'],
9606
9760
  ['taxonomy', TAXONOMY_CB, 'showTaxonomy'],
9607
9761
  ['sequence', SEQUENCE_CB, 'showSequence'],
9762
+ ['properties', PROPERTIES_CB, 'showProperties'],
9608
9763
  ['confidence', CONFIDENCE_VALUES_CB, 'showConfidenceValues'],
9609
9764
  ['madValues', MAD_VALUES_CB, 'showMadValues'],
9610
9765
  ['branchLength', BRANCH_LENGTH_VALUES_CB, 'showBranchLengthValues'],
@@ -9727,6 +9882,9 @@ function (root, d3, forester, phyloXml) {
9727
9882
  if (heatmapAvailable()) {
9728
9883
  s.heatmap = _state.showHeatmap === true;
9729
9884
  s.heatmapOrder = heatmapMode();
9885
+ if (_state.labelProperties) {
9886
+ s.labelFields = _state.labelProperties.slice();
9887
+ }
9730
9888
  if (_state.heatmapManualOrder) {
9731
9889
  s.heatmapManual = _state.heatmapManualOrder.slice();
9732
9890
  }
@@ -9737,9 +9895,12 @@ function (root, d3, forester, phyloXml) {
9737
9895
  s.domainGlow = _state.domainGlow === true;
9738
9896
  s.domainEvalue = _state.domainEvalueExponent;
9739
9897
  }
9740
- if (_timeInfo && _timeInfo.type) {
9741
- s.timeAxis = _state.showTimeAxis === true;
9742
- s.timeGrid = _state.timeAxisGrid === true;
9898
+ if (_basicTreeProperties.branchLengths === true) {
9899
+ s.scaleAxis = _state.showScaleAxis === true;
9900
+ s.scaleGrid = _state.showScaleGrid === true;
9901
+ }
9902
+ if (_branchScaleAvailable) {
9903
+ s.scale = _state.branchScale; // time or divergence: which layout the branches show
9743
9904
  }
9744
9905
  let a = searchStateOf(0);
9745
9906
  let b = searchStateOf(1);
@@ -9810,6 +9971,16 @@ function (root, d3, forester, phyloXml) {
9810
9971
  }
9811
9972
  });
9812
9973
  }
9974
+ // the branches' layout, time or divergence, on a tree that has both:
9975
+ // laid out before the properties are measured, since the lengths are
9976
+ // the layout. A tree with one layout ignores the key.
9977
+ if (_branchScaleAvailable && (s.scale === 'time' || s.scale === 'divergence') && s.scale !== _state.branchScale) {
9978
+ let laidOut = (s.scale === 'time') ? forester.applyTimeBranchLengths(_treeData)
9979
+ : forester.applyDivergenceBranchLengths(_treeData);
9980
+ if (laidOut) {
9981
+ _state.branchScale = s.scale;
9982
+ }
9983
+ }
9813
9984
  _basicTreeProperties = forester.collectBasicTreeProperties(_root);
9814
9985
 
9815
9986
  if (s.layout === 'rectangular' || s.layout === 'circular' || s.layout === 'unrooted') {
@@ -9857,6 +10028,11 @@ function (root, d3, forester, phyloXml) {
9857
10028
  if (typeof s.heatmap === 'boolean') {
9858
10029
  _state.showHeatmap = s.heatmap;
9859
10030
  }
10031
+ if (Array.isArray(s.labelFields) && s.labelFields.every(function (r) {
10032
+ return typeof r === 'string';
10033
+ })) {
10034
+ _state.labelProperties = s.labelFields.slice();
10035
+ }
9860
10036
  if (Array.isArray(s.heatmapManual) && s.heatmapManual.every(function (r) {
9861
10037
  return typeof r === 'string';
9862
10038
  })) {
@@ -9881,11 +10057,15 @@ function (root, d3, forester, phyloXml) {
9881
10057
  _domain.palette = null; // the drawn set changed: the palette is dealt again
9882
10058
  }
9883
10059
  }
9884
- if (typeof s.timeAxis === 'boolean') {
9885
- _state.showTimeAxis = s.timeAxis;
10060
+ // timeAxis / timeGrid: the names before the axis also measured
10061
+ // distance, still read so that older links keep their axis
10062
+ let axis = typeof s.scaleAxis === 'boolean' ? s.scaleAxis : s.timeAxis;
10063
+ if (typeof axis === 'boolean') {
10064
+ _state.showScaleAxis = axis;
9886
10065
  }
9887
- if (typeof s.timeGrid === 'boolean') {
9888
- _state.timeAxisGrid = s.timeGrid;
10066
+ let grid = typeof s.scaleGrid === 'boolean' ? s.scaleGrid : s.timeGrid;
10067
+ if (typeof grid === 'boolean') {
10068
+ _state.showScaleGrid = grid;
9889
10069
  }
9890
10070
  if (!radialDisplay()) {
9891
10071
  _radialLabelsHorizontal = false;
@@ -9952,6 +10132,7 @@ function (root, d3, forester, phyloXml) {
9952
10132
  function syncViewControls() {
9953
10133
  setDisplayTypeButtons();
9954
10134
  syncZoomRowButtons();
10135
+ syncBranchScaleControls();
9955
10136
  VIEW_SHOW_FLAGS.forEach(function (f) {
9956
10137
  setCheckboxValue(f[1], _state[f[2]] === true);
9957
10138
  });
@@ -9964,8 +10145,8 @@ function (root, d3, forester, phyloXml) {
9964
10145
  setCheckboxValue(MSA_LOGO_CB, _state.showMsaLogo === true);
9965
10146
  setCheckboxValue(HEATMAP_CB, _state.showHeatmap === true);
9966
10147
  syncHeatmapControls();
9967
- setCheckboxValue(TIME_AXIS_CB, _state.showTimeAxis === true);
9968
- setCheckboxValue(TIME_GRID_CB, _state.timeAxisGrid === true);
10148
+ setCheckboxValue(SCALE_AXIS_CB, _state.showScaleAxis === true);
10149
+ setCheckboxValue(SCALE_GRID_CB, _state.showScaleGrid === true);
9969
10150
  setCheckboxValue(DOMAIN_GLOW_CB, _state.domainGlow === true);
9970
10151
  setValue(DOMAIN_LABELS_SELECT, _state.domainLabels);
9971
10152
  syncDomainControls();
@@ -10041,14 +10222,14 @@ function (root, d3, forester, phyloXml) {
10041
10222
  // The heat map's four keys and the alignment logo went in exactly that
10042
10223
  // way, while the README promised a link reproduced the figure.
10043
10224
  const VIEW_TEXT_KEYS = ['layout', 'display', 'order', 'root', 'colorBy', 'shapeBy', 'domainLabels', 'combine',
10044
- 'heatmapOrder'];
10225
+ 'heatmapOrder', 'scale'];
10045
10226
  const VIEW_INT_KEYS = ['tree', 'subtree', 'rotation', 'domainEvalue'];
10046
10227
  const VIEW_NUMBER_KEYS = ['font', 'node', 'branch'];
10047
10228
  const VIEW_BOOL_KEYS = ['horizontalLabels', 'msa', 'msaLogo', 'heatmap', 'domains', 'domainGlow',
10048
- 'timeAxis', 'timeGrid', 'matchCase', 'inverse'];
10229
+ 'scaleAxis', 'scaleGrid', 'timeAxis', 'timeGrid', 'matchCase', 'inverse'];
10049
10230
  // Lists of strings, comma-joined. A property ref carries ':' but never a
10050
10231
  // comma, and encodeViewValue leaves both readable in a hash.
10051
- const VIEW_LIST_KEYS = ['heatmapManual'];
10232
+ const VIEW_LIST_KEYS = ['heatmapManual', 'labelFields'];
10052
10233
  const VIEW_SEARCH_KEYS = [['searchA', 'a'], ['searchB', 'b']];
10053
10234
 
10054
10235
  function encodeViewValue(v) {
@@ -10212,7 +10393,7 @@ function (root, d3, forester, phyloXml) {
10212
10393
  {key: 'e', label: 'E', shift: true, what: 'Expand vertically until the labels fit', run: function () { zoomToExpandY(); }},
10213
10394
  {key: 'l', label: 'L', shift: true, what: 'Next layout: rectangular, circular, unrooted', run: cycleLayout},
10214
10395
  {key: 'd', label: 'D', shift: true, what: 'Next display type: phylogram, aligned, cladogram', run: cycleDisplayType},
10215
- {key: 'x', label: 'X', shift: true, what: 'Time / divergence scale, or the time axis', run: toggleTimeAxis},
10396
+ {key: 'x', label: 'X', shift: true, what: 'Time / divergence scale, or the scale axis', run: toggleScaleAxis},
10216
10397
  {key: 'o', label: 'O', shift: true, what: 'Ladderize (order the tree)', run: function () { ladderizeButtonPressed(); }},
10217
10398
  {key: 'u', label: 'U', shift: true, what: 'Uncollapse every clade', run: function () { uncollapseAll(); }},
10218
10399
  {key: 'f', label: 'F', what: 'Go to the search box', run: focusSearch, typing: true},
@@ -10359,19 +10540,19 @@ function (root, d3, forester, phyloXml) {
10359
10540
  }
10360
10541
 
10361
10542
  // Shift+X. The switch takes this key over where the tree offers it, as was
10362
- // always intended; on every other tree it still works the Time Axis
10543
+ // always intended; on every other tree it works the Scale Axis
10363
10544
  // checkbox, so the key never does nothing.
10364
- function toggleTimeAxis() {
10545
+ function toggleScaleAxis() {
10365
10546
  if (_branchScaleAvailable) {
10366
10547
  setBranchScale(_state.branchScale === 'time' ? 'divergence' : 'time');
10367
10548
  return;
10368
10549
  }
10369
- let cb = byId(TIME_AXIS_CB);
10550
+ let cb = byId(SCALE_AXIS_CB);
10370
10551
  if (!cb || cb.disabled) {
10371
10552
  return;
10372
10553
  }
10373
10554
  cb.checked = !cb.checked;
10374
- timeAxisCbClicked();
10555
+ scaleAxisCbClicked();
10375
10556
  }
10376
10557
 
10377
10558
  function focusSearch() {
@@ -11577,11 +11758,24 @@ function (root, d3, forester, phyloXml) {
11577
11758
  && _treeData.branch_length_unit.trim() === 'subs/site';
11578
11759
  }
11579
11760
 
11580
- function timeAxisShown() {
11581
- return _state.showTimeAxis === true && !_state.unrootedDisplay
11761
+ // The Scale Axis switch, as on the desktop: an axis under a rectangular
11762
+ // or circular phylogram. What it measures is the tree's to say -- time
11763
+ // on a time tree whose branches show time, distance from the root on
11764
+ // every other -- so flipping Time | Div changes what the axis reads,
11765
+ // never whether there is one.
11766
+ function scaleAxisShown() {
11767
+ return _state.showScaleAxis === true && !_state.unrootedDisplay
11582
11768
  && _state.phylogram === true
11769
+ && _basicTreeProperties.branchLengths === true;
11770
+ }
11771
+
11772
+ function distanceAxisShown() {
11773
+ return scaleAxisShown() && !timeAxisShown();
11774
+ }
11775
+
11776
+ function timeAxisShown() {
11777
+ return scaleAxisShown()
11583
11778
  && _timeInfo !== null && _timeInfo.type !== null
11584
- && _basicTreeProperties.branchLengths === true
11585
11779
  // The axis is an overlay calibrated to the tree's own branch scale,
11586
11780
  // so it must be hidden when the branches are showing DIVERGENCE --
11587
11781
  // calendar years against substitutions reads as a confident lie.
@@ -11594,11 +11788,14 @@ function (root, d3, forester, phyloXml) {
11594
11788
  && !branchesShowDivergence();
11595
11789
  }
11596
11790
 
11597
- function timeAxisBottomReserve() {
11791
+ function scaleAxisBottomReserve() {
11598
11792
  // circular draws its ruler in the tree's own gap, not under it
11599
- if (!timeAxisShown() || _state.circularDisplay) {
11793
+ if (!scaleAxisShown() || _state.circularDisplay) {
11600
11794
  return 0;
11601
11795
  }
11796
+ if (!timeAxisShown()) {
11797
+ return DISTANCE_AXIS_RESERVE;
11798
+ }
11602
11799
  return _timeInfo.type === 'geologic' ? TIME_GEO_RESERVE : TIME_CAL_RESERVE;
11603
11800
  }
11604
11801
 
@@ -11610,7 +11807,7 @@ function (root, d3, forester, phyloXml) {
11610
11807
  // own bottom rows (conservation, consensus, ruler) must end above it
11611
11808
  // or the bar covers them -- the old bare slider did exactly that.
11612
11809
  // The scale bar sits in the same band, under the tree's last row.
11613
- return Math.max(msaShown() ? msaBottomReserve() + MSA_NAV_RESERVE : 0, timeAxisBottomReserve(),
11810
+ return Math.max(msaShown() ? msaBottomReserve() + MSA_NAV_RESERVE : 0, scaleAxisBottomReserve(),
11614
11811
  heatmapBottomReserve(),
11615
11812
  scaleBarShown() && !radialDisplay() ? SCALE_BAR_RESERVE : 0);
11616
11813
  }
@@ -11620,14 +11817,15 @@ function (root, d3, forester, phyloXml) {
11620
11817
  // round number of branch-length units (forester.scaleBarLength) with
11621
11818
  // end ticks and its length written above, drawn in the tree's own
11622
11819
  // coordinates so it zooms and exports with the tree and its label stays
11623
- // true. Not in a cladogram (nothing to measure) and not under a time
11624
- // axis (which is a scale already); in the radial layouts it sits below
11625
- // the fan at its left edge.
11820
+ // true. Not in a cladogram (nothing to measure) and not under the scale
11821
+ // axis (which says the same, and more); in the radial layouts it sits
11822
+ // below the fan at its left edge. The desktop keeps its bar beside its
11823
+ // axis; ours gives way (Christian, 2026-09-30).
11626
11824
  const SCALE_BAR_TARGET_PX = 100;
11627
11825
  const SCALE_BAR_RESERVE = 30;
11628
11826
 
11629
11827
  function scaleBarShown() {
11630
- return _state.phylogram === true && _basicTreeProperties.branchLengths === true && !timeAxisShown();
11828
+ return _state.phylogram === true && _basicTreeProperties.branchLengths === true && !scaleAxisShown();
11631
11829
  }
11632
11830
 
11633
11831
  // pixels per branch-length unit in the current layout's own coordinates
@@ -11703,6 +11901,15 @@ function (root, d3, forester, phyloXml) {
11703
11901
  delete _floatStrips['aptx-time-axis'];
11704
11902
  }
11705
11903
  _svgGroup.selectAll('g.aptx-timegrid').remove();
11904
+ _svgGroup.selectAll('g.aptx-distance, g.aptx-distance-grid').remove();
11905
+ if (_floatGroup) {
11906
+ _floatGroup.selectAll('g.aptx-distance-axis').remove();
11907
+ delete _floatStrips['aptx-distance-axis'];
11908
+ }
11909
+ if (distanceAxisShown()) {
11910
+ drawDistanceAxis();
11911
+ return;
11912
+ }
11706
11913
  if (!timeAxisShown() || !_root || !_yScale) {
11707
11914
  return;
11708
11915
  }
@@ -11744,7 +11951,7 @@ function (root, d3, forester, phyloXml) {
11744
11951
  // BEHIND the tree (inserted first in the group) at the fine geologic
11745
11952
  // boundaries / calendar year ticks; each axis branch below fills it
11746
11953
  let grid = null;
11747
- if (_state.timeAxisGrid) {
11954
+ if (_state.showScaleGrid) {
11748
11955
  grid = _svgGroup.insert('g', 'g').attr('class', 'aptx-timegrid')
11749
11956
  .style('pointer-events', 'none');
11750
11957
  }
@@ -11858,17 +12065,16 @@ function (root, d3, forester, phyloXml) {
11858
12065
  }
11859
12066
 
11860
12067
  // ---- the axis itself: a FLOATING strip (see floatStripGroup) ----
11861
- let ax = floatStripGroup('aptx-time-axis', axisTop - 4, timeAxisBottomReserve() + 4);
12068
+ // the viewer's sans-serif: an SVG text inherits the browser's serif
12069
+ let ax = floatStripGroup('aptx-time-axis', axisTop - 4, scaleAxisBottomReserve() + 4)
12070
+ .style('font-family', FONT_DEFAULTS);
11862
12071
  function axisBackdrop(x0, x1) {
11863
12072
  // opaque, in the background colour, so tips panned under the
11864
12073
  // strip do not show through between the bands and the ruler
11865
12074
  let left = Math.min(x0, x1);
11866
12075
  ax.append('rect').attr('x', left - 2).attr('y', axisTop - 4)
11867
- .attr('width', Math.abs(x1 - x0) + 4).attr('height', timeAxisBottomReserve() + 4)
12076
+ .attr('width', Math.abs(x1 - x0) + 4).attr('height', scaleAxisBottomReserve() + 4)
11868
12077
  .attr('fill', _state.backgroundColorDefault);
11869
- ax.append('line').attr('x1', left - 2).attr('x2', left - 2 + Math.abs(x1 - x0) + 4)
11870
- .attr('y1', axisTop - 4).attr('y2', axisTop - 4)
11871
- .attr('stroke', ink).attr('stroke-opacity', 0.25).attr('stroke-width', 1);
11872
12078
  }
11873
12079
  if (info.type === 'geologic') {
11874
12080
  let rootAge = info.rootAge;
@@ -12024,7 +12230,19 @@ function (root, d3, forester, phyloXml) {
12024
12230
  let from; // the root end and the tip end, as dates
12025
12231
  let to;
12026
12232
  let bands = [];
12027
- if (info.type === 'geologic') {
12233
+ let unit = null; // written once past the tip end
12234
+ if (info.type === 'distance') {
12235
+ // not a date: a distance from the displayed root, 0 at its centre
12236
+ depthOf = function (v) {
12237
+ return info.rootDepth + (v * corr);
12238
+ };
12239
+ from = 0;
12240
+ to = info.maxDist;
12241
+ ticks = info.ticks.map(function (v) {
12242
+ return {value: v, text: String(v)};
12243
+ });
12244
+ unit = info.unit;
12245
+ } else if (info.type === 'geologic') {
12028
12246
  let rootAge = info.rootAge;
12029
12247
  if (!(rootAge > 0)) {
12030
12248
  return;
@@ -12045,6 +12263,7 @@ function (root, d3, forester, phyloXml) {
12045
12263
  ticks.push({value: to, text: String(Math.round(to * 100) / 100), first: true});
12046
12264
  }
12047
12265
  bands = forester.geoOverlapping(forester.geoBandRanks(to, rootAge)[1], to, rootAge);
12266
+ unit = 'Ma';
12048
12267
  } else {
12049
12268
  let present = info.presentDate;
12050
12269
  if (!(present > 0)) {
@@ -12107,7 +12326,7 @@ function (root, d3, forester, phyloXml) {
12107
12326
  let at = function (r, off) {
12108
12327
  return [(along[0] * r) + (across[0] * off), (along[1] * r) + (across[1] * off)];
12109
12328
  };
12110
- let ax = g.append('g').attr('class', 'aptx-time-ruler');
12329
+ let ax = g.append('g').attr('class', 'aptx-time-ruler').style('font-family', FONT_DEFAULTS);
12111
12330
  let e0 = at(r0, 0);
12112
12331
  let e1 = at(r1, 0);
12113
12332
  ax.append('line').attr('x1', e0[0]).attr('y1', e0[1]).attr('x2', e1[0]).attr('y2', e1[1])
@@ -12148,8 +12367,11 @@ function (root, d3, forester, phyloXml) {
12148
12367
  label(r, side * 11, t.text, ink, true);
12149
12368
  placed.push(r);
12150
12369
  });
12370
+ if (unit) {
12371
+ // along the ruler past its end, so a long unit reads outward
12372
+ label(r1 + 8 + (unit.length * 2.8), 0, unit, ink, false);
12373
+ }
12151
12374
  if (info.type === 'geologic') {
12152
- label(r1 + 14, 0, 'Ma', ink, false);
12153
12375
  named.forEach(function (n) {
12154
12376
  if ((n.text.length * 5.5) + 4 <= n.span) {
12155
12377
  label(n.r, -side * 11, n.text, ink, true);
@@ -12158,13 +12380,108 @@ function (root, d3, forester, phyloXml) {
12158
12380
  }
12159
12381
  }
12160
12382
 
12161
- function timeAxisCbClicked() {
12162
- _state.showTimeAxis = getCheckboxValue(TIME_AXIS_CB);
12383
+ // The distance axis, the desktop's "Scale Axis": a ruler from 0 at the
12384
+ // displayed root out to the deepest tip, in branch-length units, with
12385
+ // round ticks and the tree's unit at its end; "Scale Grid" draws each
12386
+ // tick as a faint line (a ring, in circular) behind the tree. Measured
12387
+ // per draw, so a subtree view's axis starts at ITS root.
12388
+ function drawDistanceAxis() {
12389
+ if (!_root || !_yScale) {
12390
+ return;
12391
+ }
12392
+ let corr = Math.abs(_yScale(1) - _yScale(0));
12393
+ if (!isFinite(corr) || corr <= 0) {
12394
+ return;
12395
+ }
12396
+ let rootDepth = (topNode() || _root).y;
12397
+ let maxTipX = rootDepth;
12398
+ forEachDisplayed(function (n) {
12399
+ if (!n.children && n.y > maxTipX) {
12400
+ maxTipX = n.y;
12401
+ }
12402
+ });
12403
+ let maxDist = (maxTipX - rootDepth) / corr;
12404
+ let ticks = forester.distanceAxisTickValues(maxDist);
12405
+ if (ticks.length === 0) {
12406
+ return;
12407
+ }
12408
+ let unit = _treeData && typeof _treeData.branch_length_unit === 'string'
12409
+ ? _treeData.branch_length_unit.trim() : '';
12410
+ let ink = _state.branchColorDefault;
12411
+ let grid = null;
12412
+ if (_state.showScaleGrid) {
12413
+ grid = _svgGroup.insert('g', 'g').attr('class', 'aptx-distance-grid')
12414
+ .style('pointer-events', 'none');
12415
+ }
12416
+ if (_state.circularDisplay) {
12417
+ let g = _svgGroup.append('g').attr('class', 'aptx-distance').style('pointer-events', 'none');
12418
+ drawCircularTimeRuler({type: 'distance', rootDepth: rootDepth, maxDist: maxDist, ticks: ticks,
12419
+ unit: unit || null}, null, maxTipX, corr, g, grid, ink);
12420
+ return;
12421
+ }
12422
+ let xOf = function (v) {
12423
+ return rootDepth + (v * corr);
12424
+ };
12425
+ let axisTop = _clusterH + 6;
12426
+ if (grid) {
12427
+ // the root and tip ends excluded: they would retrace the tree
12428
+ ticks.forEach(function (v) {
12429
+ if (v > 0 && v < maxDist - 1e-9) {
12430
+ grid.append('line').attr('x1', xOf(v)).attr('x2', xOf(v))
12431
+ .attr('y1', 0).attr('y2', axisTop)
12432
+ .attr('stroke', ink).attr('stroke-opacity', 0.18)
12433
+ .attr('stroke-width', 1);
12434
+ }
12435
+ });
12436
+ }
12437
+ let reserve = scaleAxisBottomReserve();
12438
+ let ax = floatStripGroup('aptx-distance-axis', axisTop - 4, reserve + 4)
12439
+ .style('font-family', FONT_DEFAULTS);
12440
+ let x0 = xOf(0);
12441
+ let x1 = xOf(maxDist);
12442
+ // opaque, as the time axis's, so tips panned under it do not show
12443
+ ax.append('rect').attr('x', x0 - 2).attr('y', axisTop - 4)
12444
+ .attr('width', (x1 - x0) + 4).attr('height', reserve + 4)
12445
+ .attr('fill', _state.backgroundColorDefault);
12446
+ let rulerY = axisTop + 4;
12447
+ ax.append('line').attr('x1', x0).attr('x2', x1)
12448
+ .attr('y1', rulerY).attr('y2', rulerY)
12449
+ .attr('stroke', ink).attr('stroke-width', 1);
12450
+ // every tick drawn, its number only where it clears the last one
12451
+ let lastRight = -Infinity;
12452
+ ticks.forEach(function (v) {
12453
+ let x = xOf(v);
12454
+ ax.append('line').attr('x1', x).attr('x2', x)
12455
+ .attr('y1', rulerY).attr('y2', rulerY + 4)
12456
+ .attr('stroke', ink).attr('stroke-width', 1);
12457
+ let text = String(v);
12458
+ let half = text.length * 2.8;
12459
+ if ((x - half) >= lastRight + 4) {
12460
+ ax.append('text').attr('x', x).attr('y', rulerY + 14)
12461
+ .attr('text-anchor', 'middle')
12462
+ .style('font-size', '9px').style('fill', ink)
12463
+ .text(text);
12464
+ lastRight = x + half;
12465
+ }
12466
+ });
12467
+ if (unit) {
12468
+ // past the ruler's end, and past the last number, which is
12469
+ // centred on that end
12470
+ ax.append('text').attr('x', Math.max(x1 + 8, lastRight + 6)).attr('y', rulerY + 14)
12471
+ .attr('text-anchor', 'start')
12472
+ .style('font-size', '9px').style('fill', ink)
12473
+ .text(unit);
12474
+ }
12475
+ placeFloatingOverlays();
12476
+ }
12477
+
12478
+ function scaleAxisCbClicked() {
12479
+ _state.showScaleAxis = getCheckboxValue(SCALE_AXIS_CB);
12163
12480
  scheduleUpdate(null, 0);
12164
12481
  }
12165
12482
 
12166
- function timeGridCbClicked() {
12167
- _state.timeAxisGrid = getCheckboxValue(TIME_GRID_CB);
12483
+ function scaleGridCbClicked() {
12484
+ _state.showScaleGrid = getCheckboxValue(SCALE_GRID_CB);
12168
12485
  scheduleUpdate(null, 0);
12169
12486
  }
12170
12487
 
@@ -13312,15 +13629,17 @@ function (root, d3, forester, phyloXml) {
13312
13629
  heatCb.disabled = _state.unrootedDisplay === true;
13313
13630
  }
13314
13631
  syncHeatmapControls();
13315
- let timeCb = byId(TIME_AXIS_CB);
13316
- if (timeCb) {
13317
- // circular draws it along the gap; only unrooted cannot
13318
- timeCb.disabled = _state.unrootedDisplay === true;
13319
- }
13320
- let timeGridCb = byId(TIME_GRID_CB);
13321
- if (timeGridCb) {
13322
- // grid lines hang off the time axis: no axis, nothing to grid
13323
- timeGridCb.disabled = _state.unrootedDisplay === true || _state.showTimeAxis !== true;
13632
+ let axisCb = byId(SCALE_AXIS_CB);
13633
+ if (axisCb) {
13634
+ // circular draws it along the gap; unrooted cannot, and a
13635
+ // cladogram has nothing to measure
13636
+ axisCb.disabled = _state.unrootedDisplay === true || _state.phylogram !== true;
13637
+ }
13638
+ let gridCb = byId(SCALE_GRID_CB);
13639
+ if (gridCb) {
13640
+ // grid lines hang off the scale axis: no axis, nothing to grid
13641
+ gridCb.disabled = _state.unrootedDisplay === true || _state.phylogram !== true
13642
+ || _state.showScaleAxis !== true;
13324
13643
  }
13325
13644
  let minus = byId(ZOOM_OUT_X);
13326
13645
  let plus = byId(ZOOM_IN_X);
@@ -13415,6 +13734,182 @@ function (root, d3, forester, phyloXml) {
13415
13734
  scheduleUpdate();
13416
13735
  }
13417
13736
 
13737
+ // ===================== Properties in the label =====================
13738
+ // The desktop's "Properties" option and its Annotation Fields chooser:
13739
+ // a node's property values in its label, values only, " | "-joined, in
13740
+ // the order chosen (forester.labelPropertiesText). Nothing chosen means
13741
+ // every user-visible property in the node's own order, less the heat
13742
+ // map's columns while it is shown -- a field has one display role.
13743
+
13744
+ function labelFields() {
13745
+ if (_labelFields === null && _treeData) {
13746
+ _labelFields = forester.labelPropertyRefs(_treeData);
13747
+ }
13748
+ return _labelFields || [];
13749
+ }
13750
+
13751
+ // the heat map's columns while it is shown: left out of the default label
13752
+ function labelFieldsExcluded() {
13753
+ let excluded = Object.create(null);
13754
+ if (heatmapShown()) {
13755
+ let m = heatmapModel();
13756
+ (m ? m.refs : []).forEach(function (c) {
13757
+ excluded[c.ref] = true;
13758
+ });
13759
+ }
13760
+ return excluded;
13761
+ }
13762
+
13763
+ function labelPropertiesOf(node) {
13764
+ return forester.labelPropertiesText(node, _state.labelProperties,
13765
+ _state.labelProperties ? null : labelFieldsExcluded());
13766
+ }
13767
+
13768
+ function propertiesCbClicked() {
13769
+ _state.showProperties = getCheckboxValue(PROPERTIES_CB);
13770
+ if (_state.showProperties) {
13771
+ _state.showExternalLabels = true;
13772
+ setCheckboxValue(EXTERNAL_LABEL_CB, true);
13773
+ }
13774
+ search0();
13775
+ search1();
13776
+ scheduleUpdate();
13777
+ }
13778
+
13779
+ // Which properties the label shows, and in what order: each field ticked
13780
+ // or not, and moved with the arrows (or the arrow keys). Apply keeps the ticked
13781
+ // ones in the order shown; picking a field that was not shown turns the
13782
+ // Properties label on, as the desktop does, since otherwise choosing it
13783
+ // would visibly do nothing. "All" goes back to every field.
13784
+ function showLabelFieldsDialog() {
13785
+ let all = labelFields();
13786
+ if (all.length < 1) {
13787
+ return;
13788
+ }
13789
+ let chosen = _state.labelProperties ? _state.labelProperties.filter(function (r) {
13790
+ return all.indexOf(r) >= 0;
13791
+ }) : null;
13792
+ // the chosen fields first, in their order, then the rest as offered;
13793
+ // with none chosen, ticked is what the label shows by default
13794
+ let excluded = chosen ? null : labelFieldsExcluded();
13795
+ let working = (chosen || []).concat(all.filter(function (r) {
13796
+ return !chosen || chosen.indexOf(r) < 0;
13797
+ })).map(function (ref) {
13798
+ return {ref: ref, on: chosen ? chosen.indexOf(ref) >= 0 : !excluded[ref]};
13799
+ });
13800
+ if (!chosen) {
13801
+ // the ticked ones first, so the list reads as the label does
13802
+ working = working.filter(function (w) {
13803
+ return w.on;
13804
+ }).concat(working.filter(function (w) {
13805
+ return !w.on;
13806
+ }));
13807
+ }
13808
+ let before = working.filter(function (w) {
13809
+ return w.on && (chosen !== null || _state.showProperties);
13810
+ }).map(function (w) {
13811
+ return w.ref;
13812
+ });
13813
+ let shell = makeDialogShell(LABEL_FIELDS_DIALOG, 'Label fields', 340);
13814
+ let body = shell.body;
13815
+ repsElement(body, 'div', 'aptx-reps-lead',
13816
+ 'Tick the properties the labels show, and set their order with the arrows.');
13817
+ let list = repsElement(body, 'div', 'aptx-reorder');
13818
+
13819
+ function move(from, to) {
13820
+ if (to < 0 || to >= working.length || from === to) {
13821
+ return false;
13822
+ }
13823
+ working.splice(to, 0, working.splice(from, 1)[0]);
13824
+ draw();
13825
+ return true;
13826
+ }
13827
+
13828
+ function draw() {
13829
+ list.textContent = '';
13830
+ working.forEach(function (w, i) {
13831
+ let row = repsElement(list, 'div', 'aptx-reorder-row');
13832
+ row.tabIndex = 0;
13833
+ let box = repsElement(row, 'input', 'aptx-reorder-check');
13834
+ box.type = 'checkbox';
13835
+ box.checked = w.on;
13836
+ box.addEventListener('change', function () {
13837
+ w.on = box.checked;
13838
+ });
13839
+ repsElement(row, 'span', 'aptx-reorder-name', forester.propertyDisplayName(w.ref)).title = w.ref;
13840
+ let up = repsElement(row, 'button', 'aptx-reorder-move', '↑');
13841
+ let down = repsElement(row, 'button', 'aptx-reorder-move', '↓');
13842
+ up.type = 'button';
13843
+ down.type = 'button';
13844
+ up.title = 'Move up';
13845
+ down.title = 'Move down';
13846
+ up.disabled = (i === 0);
13847
+ down.disabled = (i === working.length - 1);
13848
+ up.addEventListener('click', function () {
13849
+ if (move(i, i - 1)) {
13850
+ list.children[i - 1].focus();
13851
+ }
13852
+ });
13853
+ down.addEventListener('click', function () {
13854
+ if (move(i, i + 1)) {
13855
+ list.children[i + 1].focus();
13856
+ }
13857
+ });
13858
+ row.addEventListener('keydown', function (event) {
13859
+ if (event.key === ' ' && event.target === row) {
13860
+ event.preventDefault();
13861
+ box.checked = !box.checked;
13862
+ w.on = box.checked;
13863
+ return;
13864
+ }
13865
+ let d = (event.key === 'ArrowUp') ? -1 : ((event.key === 'ArrowDown') ? 1 : 0);
13866
+ if (d === 0) {
13867
+ return;
13868
+ }
13869
+ event.preventDefault();
13870
+ if (move(i, i + d)) {
13871
+ list.children[i + d].focus();
13872
+ }
13873
+ });
13874
+ });
13875
+ }
13876
+ draw();
13877
+
13878
+ let actions = repsElement(body, 'div', 'aptx-reps-actions');
13879
+ repsButton(actions, 'All', false, function () {
13880
+ _state.labelProperties = null;
13881
+ shell.dialog.close();
13882
+ scheduleUpdate();
13883
+ });
13884
+ repsButton(actions, 'Cancel', false, function () {
13885
+ shell.dialog.close();
13886
+ });
13887
+ repsButton(actions, 'Apply', true, function () {
13888
+ _state.labelProperties = working.filter(function (w) {
13889
+ return w.on;
13890
+ }).map(function (w) {
13891
+ return w.ref;
13892
+ });
13893
+ let added = _state.labelProperties.some(function (r) {
13894
+ return before.indexOf(r) < 0;
13895
+ });
13896
+ if (added && !_state.showProperties) {
13897
+ _state.showProperties = true;
13898
+ _state.showExternalLabels = true;
13899
+ setCheckboxValue(PROPERTIES_CB, true);
13900
+ setCheckboxValue(EXTERNAL_LABEL_CB, true);
13901
+ }
13902
+ shell.dialog.close();
13903
+ search0();
13904
+ search1();
13905
+ scheduleUpdate();
13906
+ });
13907
+ shell.dialog.showModal();
13908
+ if (list.firstChild) {
13909
+ list.firstChild.focus();
13910
+ }
13911
+ }
13912
+
13418
13913
  function sequenceCbClicked() {
13419
13914
  _state.showSequence = getCheckboxValue(SEQUENCE_CB);
13420
13915
  if (_state.showSequence) {
@@ -16126,6 +16621,8 @@ function (root, d3, forester, phyloXml) {
16126
16621
  on(TAXONOMY_CB, 'click', taxonomyCbClicked);
16127
16622
 
16128
16623
  on(SEQUENCE_CB, 'click', sequenceCbClicked);
16624
+ on(PROPERTIES_CB, 'click', propertiesCbClicked);
16625
+ on(LABEL_FIELDS_BUTTON, 'click', showLabelFieldsDialog);
16129
16626
 
16130
16627
  on(CONFIDENCE_VALUES_CB, 'click', confidenceValuesCbClicked);
16131
16628
  on(SUPPORT_DOTS_CB, 'click', supportDotsCbClicked);
@@ -16175,8 +16672,8 @@ function (root, d3, forester, phyloXml) {
16175
16672
  on(DOMAIN_EVALUE_INC, 'click', function () { domainEvalueStep(1); });
16176
16673
  on(DOMAIN_LABELS_SELECT, 'change', domainLabelsChanged);
16177
16674
  on(DOMAIN_GLOW_CB, 'click', domainGlowCbClicked);
16178
- on(TIME_AXIS_CB, 'click', timeAxisCbClicked);
16179
- on(TIME_GRID_CB, 'click', timeGridCbClicked);
16675
+ on(SCALE_AXIS_CB, 'click', scaleAxisCbClicked);
16676
+ on(SCALE_GRID_CB, 'click', scaleGridCbClicked);
16180
16677
 
16181
16678
  on(LAYOUT_RECT_BUTTON, 'click', layoutButtonClicked);
16182
16679
  on(BRANCH_SCALE_TIME_BUTTON, 'click', branchScaleButtonClicked);
@@ -16654,6 +17151,10 @@ function (root, d3, forester, phyloXml) {
16654
17151
  if (_basicTreeProperties.sequences) {
16655
17152
  labels.push(makeCheckboxItem('Sequence', SEQUENCE_CB, 'to show/hide node sequence information'));
16656
17153
  }
17154
+ if (labelFields().length > 0) {
17155
+ labels.push(makeCheckboxItem('Properties', PROPERTIES_CB, 'to show/hide the nodes\' property values'
17156
+ + ' (Label fields… chooses which, and in what order)'));
17157
+ }
16657
17158
  if (_nodeLabels) {
16658
17159
  for (const [key, value] of Object.entries(_nodeLabels)) {
16659
17160
  if (value.label && value.propertyRef && value.description) {
@@ -16721,16 +17222,26 @@ function (root, d3, forester, phyloXml) {
16721
17222
  if (heatmapAvailable()) {
16722
17223
  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)'));
16723
17224
  }
16724
- if (_timeInfo && _timeInfo.type) {
16725
- opts.push(makeCheckboxItem('Time Axis', TIME_AXIS_CB, 'to show/hide the '
16726
- + (_timeInfo.type === 'geologic' ? 'geologic (ICS) time axis' : 'calendar time axis')
16727
- + ' and node-age bars (phylogram; rectangular and circular layouts)'));
16728
- opts.push(makeCheckboxItem('Time Grid', TIME_GRID_CB, 'to show/hide grid lines (rings, in circular) at the '
16729
- + (_timeInfo.type === 'geologic' ? 'geologic interval boundaries' : 'calendar year ticks')));
17225
+ if (_basicTreeProperties.branchLengths === true) {
17226
+ let timeAxis = _timeInfo && _timeInfo.type
17227
+ ? (_timeInfo.type === 'geologic' ? 'the geologic (ICS) time axis' : 'the calendar time axis')
17228
+ : null;
17229
+ opts.push(makeCheckboxItem('Scale Axis', SCALE_AXIS_CB, 'to show/hide '
17230
+ + (timeAxis ? timeAxis + ' and node-age bars (a distance axis while the branches show divergence)'
17231
+ : 'a labeled distance axis from the root to the deepest tip')
17232
+ + ' (phylogram; rectangular and circular layouts)'));
17233
+ opts.push(makeCheckboxItem('Scale Grid', SCALE_GRID_CB, 'to show/hide grid lines (rings, in circular) at the '
17234
+ + (timeAxis ? 'time axis\'s intervals or ticks' : 'scale axis ticks')));
16730
17235
  }
16731
17236
 
16732
17237
  let h = '<fieldset><legend>Display Data</legend>';
16733
17238
  h = h.concat(makeCheckboxGroup('Labels', labels));
17239
+ if (labelFields().length > 0) {
17240
+ h = h.concat('<div class="aptx-domrow"><span class="aptx-domlabel"></span>'
17241
+ + '<input type="button" class="aptx-widebtn" value="Label fields…" name="' + LABEL_FIELDS_BUTTON
17242
+ + '" id="' + LABEL_FIELDS_BUTTON + '" title="choose which properties the labels show, and in what'
17243
+ + ' order"></div>');
17244
+ }
16734
17245
  h = h.concat(makeCheckboxGroup('Nodes', nodes));
16735
17246
  h = h.concat(makeCheckboxGroup('Options', opts));
16736
17247
  h = h.concat('</fieldset>');
@@ -17055,6 +17566,7 @@ function (root, d3, forester, phyloXml) {
17055
17566
  setCheckboxValue(NODE_NAME_CB, _state.showNodeName);
17056
17567
  setCheckboxValue(TAXONOMY_CB, _state.showTaxonomy);
17057
17568
  setCheckboxValue(SEQUENCE_CB, _state.showSequence)
17569
+ setCheckboxValue(PROPERTIES_CB, _state.showProperties);
17058
17570
  setCheckboxValue(CONFIDENCE_VALUES_CB, _state.showConfidenceValues);
17059
17571
  setCheckboxValue(SUPPORT_DOTS_CB, _state.showSupportDots);
17060
17572
  syncMadValuesCheckbox();
@@ -17092,8 +17604,8 @@ function (root, d3, forester, phyloXml) {
17092
17604
  setCheckboxValue(HEATMAP_CB, _state.showHeatmap);
17093
17605
  syncDomainControls();
17094
17606
  syncHeatmapControls();
17095
- setCheckboxValue(TIME_AXIS_CB, _state.showTimeAxis);
17096
- setCheckboxValue(TIME_GRID_CB, _state.timeAxisGrid);
17607
+ setCheckboxValue(SCALE_AXIS_CB, _state.showScaleAxis);
17608
+ setCheckboxValue(SCALE_GRID_CB, _state.showScaleGrid);
17097
17609
  setCheckboxValue(SHORTEN_NODE_NAME_CB, _state.shortenNodeNames);
17098
17610
  populateVisualizationMenus();
17099
17611
  initializeSearchOptions();
@@ -17729,9 +18241,11 @@ function (root, d3, forester, phyloXml) {
17729
18241
  }
17730
18242
  let name = _root_const.name ? String(_root_const.name).trim() : '';
17731
18243
  // what the phylogeny itself states goes with the part: without its
17732
- // branch_length_unit a subtree saved from Div opened again as time
18244
+ // branch_length_unit a subtree saved from Div opened again as time,
18245
+ // and without its properties (the figure setting) the part opened
18246
+ // without the heat map the whole tree opens with
17733
18247
  let part = Object.assign({}, _root, {name: name ? name + ' (subtree)' : undefined});
17734
- ['rooted', 'rerootable', 'type', 'branch_length_unit'].forEach(function (k) {
18248
+ ['rooted', 'rerootable', 'type', 'branch_length_unit', 'properties'].forEach(function (k) {
17735
18249
  if (_root_const[k] !== undefined && _root_const[k] !== null) {
17736
18250
  part[k] = _root_const[k];
17737
18251
  }