archaeopteryx 3.21.0 → 3.22.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.21.0
23
+ // v 3.22.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.21.0';
106
+ const VERSION = '3.22.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';
@@ -248,6 +248,7 @@ function (root, d3, forester, phyloXml) {
248
248
  const LEGEND_LABEL_COLOR = 'legendLabelColor';
249
249
  const LEGEND_NODE_SHAPE = 'legendNodeShape';
250
250
  const LEGEND_DOMAINS = 'legendDomains';
251
+ const LEGEND_GENES = 'legendGenes';
251
252
  const NH_EXPORT_FORMAT = 'Newick';
252
253
  const NEXUS_EXPORT_FORMAT = 'Nexus';
253
254
  const NODE_SIZE_MAX = 9;
@@ -380,6 +381,13 @@ function (root, d3, forester, phyloXml) {
380
381
  const DOMAIN_EVALUE_READOUT = 'domain_evalue';
381
382
  const DOMAIN_LABELS_SELECT = 'domain_labels';
382
383
  const DOMAIN_GLOW_CB = 'domain_glow_cb';
384
+ const GENES_CB = 'genes_cb';
385
+ const GENE_CONTROLS = 'gene_controls';
386
+ const GENE_WIDTH_DEC = 'gene_width_dec';
387
+ const GENE_WIDTH_INC = 'gene_width_inc';
388
+ const GENE_ANCHOR_SELECT = 'gene_anchor';
389
+ const GENE_FAMILY_SELECT = 'gene_family';
390
+ const GENE_LABELS_SELECT = 'gene_labels';
383
391
  const SCALE_AXIS_CB = 'scaleaxis_cb';
384
392
  const SCALE_GRID_CB = 'scalegrid_cb';
385
393
  const MSA_SCROLL_ID = 'aptxmsascroll';
@@ -605,6 +613,15 @@ function (root, d3, forester, phyloXml) {
605
613
  const DOMAIN_LABEL_MODES = ['none', 'domains', 'legend'];
606
614
  let _domain = null; // {width, palette, next, legendFrac}, set per launch
607
615
  let _domainReserve = 0; // horizontal px reserved for the tracks, set with _w
616
+ // The gene track (genome regions) shares the domain track's widths, box
617
+ // heights, backbone colour and legend geometry: the two are the same
618
+ // kind of thing, a backbone with boxes beside each tip.
619
+ const GENE_LABEL_MODES = ['none', 'genes', 'legend'];
620
+ const GENE_KB_BAR_TARGET_PX = 80; // the track's own scale bar aims at this length
621
+ const GENE_CAP_W = 1.5; // px: the bar that marks a contig end
622
+ const GENE_LANE_MIN_H = 3; // px: a lane of two is never thinner
623
+ let _gene = null; // {width, radialWidth, palette, legendFrac, cache}, set per launch
624
+ let _geneReserve = 0; // horizontal px reserved for the gene track, set with _w
608
625
  // ------ heat map (the desktop's MATRIX annotation columns) ------
609
626
  const HEATMAP_TRACK_GAP = 8; // px between what is left of the tree and the matrix
610
627
  const HEATMAP_PREF_COL_W = 14; // a column never grows past this
@@ -1184,7 +1201,7 @@ function (root, d3, forester, phyloXml) {
1184
1201
  // the viewport: measured box=[0,13 1497x755] against
1185
1202
  // view=[-254,-10 1497x800] with rootOffset=254, so "does it fit" said
1186
1203
  // no by precisely 254px and the overview appeared after every Fit.
1187
- maxX += calcMaxTreeLengthForDisplay() - _settings.rootOffset + _domainReserve;
1204
+ maxX += calcMaxTreeLengthForDisplay() - _settings.rootOffset + _domainReserve + _geneReserve;
1188
1205
  minY -= heatmapTopReserve(); // the dendrogram band stands above the first row
1189
1206
  if (scaleBarShown()) {
1190
1207
  maxY += SCALE_BAR_RESERVE; // the bar sits under the last row
@@ -2900,8 +2917,10 @@ function (root, d3, forester, phyloXml) {
2900
2917
  // the alignment track reserves its window on the right, so the
2901
2918
  // tree and labels compress to make room rather than overlapping
2902
2919
  _msaReserve = 0;
2903
- // and the domain tracks reserve their column past the labels
2920
+ // and the domain tracks reserve their column past the labels,
2921
+ // the gene track its own past theirs
2904
2922
  _domainReserve = domainReserve();
2923
+ _geneReserve = geneReserve();
2905
2924
  // The heat map is budgeted FIRST of the two right-hand tracks: it
2906
2925
  // wants a fixed, finite width (its columns at their preferred size
2907
2926
  // and no more), while the alignment's band is a WINDOW that scrolls
@@ -2913,7 +2932,7 @@ function (root, d3, forester, phyloXml) {
2913
2932
  let vp0 = svgSize();
2914
2933
  let vw0 = Math.min(_displayWidth, (vp0 && vp0.w) ? vp0.w : _displayWidth);
2915
2934
  let hband = Math.max(HEATMAP_MIN_BAND_PX, Math.round(vw0 * HEATMAP_MAX_VIEWPORT_FRACTION));
2916
- let hmax = _displayWidth - calcMaxTreeLengthForDisplay() - _domainReserve
2935
+ let hmax = _displayWidth - calcMaxTreeLengthForDisplay() - _domainReserve - _geneReserve
2917
2936
  - HEATMAP_TRACK_GAP - HEATMAP_MIN_TREE_PX;
2918
2937
  hband = Math.max(HEATMAP_MIN_BAND_PX, Math.min(hband, hmax));
2919
2938
  _heatmapReserve = HEATMAP_TRACK_GAP + Math.min(cols * HEATMAP_PREF_COL_W, hband);
@@ -2928,12 +2947,13 @@ function (root, d3, forester, phyloXml) {
2928
2947
  let band = Math.max(MSA_MIN_BAND_PX, Math.round(vw * MSA_MAX_VIEWPORT_FRACTION));
2929
2948
  // a wide alignment must not squeeze the tree itself away: the
2930
2949
  // band yields until the tree keeps its minimum share
2931
- let maxBand = _displayWidth - calcMaxTreeLengthForDisplay() - _domainReserve
2950
+ let maxBand = _displayWidth - calcMaxTreeLengthForDisplay() - _domainReserve - _geneReserve
2932
2951
  - _heatmapReserve - MSA_TRACK_GAP - MSA_MIN_TREE_PX;
2933
2952
  band = Math.max(MSA_MIN_BAND_PX, Math.min(band, maxBand));
2934
2953
  _msaReserve = MSA_TRACK_GAP + Math.min(fullPx, band);
2935
2954
  }
2936
- _w = _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve - _domainReserve - _heatmapReserve;
2955
+ _w = _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve - _domainReserve - _geneReserve
2956
+ - _heatmapReserve;
2937
2957
  if (_w < 1) {
2938
2958
  _w = 1;
2939
2959
  }
@@ -3487,6 +3507,8 @@ function (root, d3, forester, phyloXml) {
3487
3507
  drawScaleBar();
3488
3508
  drawDomainArchitectures();
3489
3509
  drawDomainLegend();
3510
+ drawGeneTrack();
3511
+ drawGeneLegend();
3490
3512
  drawMsaTrack();
3491
3513
  drawHeatmapTrack();
3492
3514
  drawTimeOverlays();
@@ -5651,6 +5673,10 @@ function (root, d3, forester, phyloXml) {
5651
5673
  'domainLabels',
5652
5674
  'domainGlow',
5653
5675
  'domainEvalueExponent',
5676
+ 'showGenes',
5677
+ 'geneAnchor',
5678
+ 'geneFamily',
5679
+ 'geneLabels',
5654
5680
  'showScaleAxis',
5655
5681
  'showScaleGrid',
5656
5682
  'showSupportDots',
@@ -5961,6 +5987,43 @@ function (root, d3, forester, phyloXml) {
5961
5987
  + ' with a missing or impossible from / to / E-value ignored');
5962
5988
  }
5963
5989
  }
5990
+ // Likewise a tree whose tips carry genome regions -- genes as located
5991
+ // sequences -- draws its gene track from the start, unless the caller
5992
+ // decided. The anchor family and the family namespace are chosen from
5993
+ // the tree ('auto') unless named; the label mode is the domain
5994
+ // track's.
5995
+ if (_state.showGenes === undefined) {
5996
+ _state.showGenes = _basicTreeProperties.geneRegions === true;
5997
+ }
5998
+ if (_state.geneLabels === undefined) {
5999
+ _state.geneLabels = 'genes';
6000
+ } else if (GENE_LABEL_MODES.indexOf(_state.geneLabels) < 0) {
6001
+ throw new Error(ERROR + '"geneLabels" must be "none", "genes" or "legend"');
6002
+ }
6003
+ if (_state.geneAnchor === undefined) {
6004
+ _state.geneAnchor = 'auto';
6005
+ } else if (typeof _state.geneAnchor !== 'string' || _state.geneAnchor.length === 0) {
6006
+ throw new Error(ERROR + '"geneAnchor" must be a family ref such as "pgfam:PGF_00019355", "auto" or "none"');
6007
+ }
6008
+ if (_state.geneFamily === undefined) {
6009
+ _state.geneFamily = 'auto';
6010
+ } else if (typeof _state.geneFamily !== 'string' || _state.geneFamily.length === 0) {
6011
+ throw new Error(ERROR + '"geneFamily" must be an annotation namespace such as "pgfam", or "auto"');
6012
+ }
6013
+ _gene = {width: null, radialWidth: null, palette: null, legendFrac: null, cache: new WeakMap()};
6014
+ if (_basicTreeProperties.geneRegions) {
6015
+ // a location outside the grammar and a gene on a second contig
6016
+ // are left out, never fatal -- said once, here
6017
+ let gs = forester.geneRegionStats(_treeData);
6018
+ if (gs.ignored > 0) {
6019
+ console.warn(MESSAGE + gs.ignored + ' sequence location' + (gs.ignored === 1 ? '' : 's')
6020
+ + ' not in the form contig:start-end(+) ignored');
6021
+ }
6022
+ if (gs.otherContigs > 0) {
6023
+ console.warn(MESSAGE + gs.otherContigs + ' gene' + (gs.otherContigs === 1 ? '' : 's')
6024
+ + ' on a genome\'s other contigs not drawn: the gene track shows one contig per genome');
6025
+ }
6026
+ }
5964
6027
  // Likewise a dated tree draws its scale axis from the start -- a
5965
6028
  // time axis of geologic ICS bands or calendar years, decided from the
5966
6029
  // <date> elements by forester.timeAxisInfo -- again unless the caller
@@ -6059,6 +6122,14 @@ function (root, d3, forester, phyloXml) {
6059
6122
  return null;
6060
6123
  }
6061
6124
  let s = n.sequences[0];
6125
+ // On a tree of genomes a tip's located sequences -- its
6126
+ // contig record and its genes -- describe the region drawn
6127
+ // beside it, not the tip: "S01 chromosome" is no name for
6128
+ // S01, and a gene's product even less so. They are no
6129
+ // label, so the node name stands.
6130
+ if (_basicTreeProperties.geneRegions && forester.parseSequenceLocation(s.location)) {
6131
+ return null;
6132
+ }
6062
6133
  let l = '';
6063
6134
  if (_state.showSequenceSymbol) {
6064
6135
  l = joinFrag(l, s.symbol);
@@ -7374,7 +7445,7 @@ function (root, d3, forester, phyloXml) {
7374
7445
  // once flung the whole tree off-screen after three X+ presses).
7375
7446
  return {
7376
7447
  horizontal: Math.max(1, _displayWidth - calcMaxTreeLengthForDisplay() - _msaReserve
7377
- - _domainReserve - _heatmapReserve),
7448
+ - _domainReserve - _geneReserve - _heatmapReserve),
7378
7449
  vertical: Math.max(40, _displayHeight - (2 * TOP_AND_BOTTOM_BORDER_HEIGHT)
7379
7450
  - bottomOverlayReserve() - topOverlayReserve())
7380
7451
  };
@@ -8722,9 +8793,9 @@ function (root, d3, forester, phyloXml) {
8722
8793
  }
8723
8794
 
8724
8795
  // A faint dashed guide from each tip across to its row, as the
8725
- // alignment track draws -- skipped when the domain tracks are in
8726
- // between, where it would run straight through their boxes.
8727
- if (_domainReserve === 0) {
8796
+ // alignment track draws -- skipped when the domain or gene tracks are
8797
+ // in between, where it would run straight through their boxes.
8798
+ if (_domainReserve === 0 && _geneReserve === 0) {
8728
8799
  let guideFont = _state.externalNodeFontSize + 'px ' + _state.defaultFont;
8729
8800
  let guideGap = _state.nodeLabelGap;
8730
8801
  let guideEnd = originX - 3;
@@ -9899,6 +9970,14 @@ function (root, d3, forester, phyloXml) {
9899
9970
  s.domainGlow = _state.domainGlow === true;
9900
9971
  s.domainEvalue = _state.domainEvalueExponent;
9901
9972
  }
9973
+ if (_basicTreeProperties.geneRegions === true) {
9974
+ s.genes = _state.showGenes === true;
9975
+ s.geneLabels = _state.geneLabels;
9976
+ // the EFFECTIVE choices, so that a link reproduces the figure
9977
+ // even where an automatic choice would have moved
9978
+ s.geneAnchor = geneAnchor() || 'none';
9979
+ s.geneFamily = geneFamilyNamespace() || 'auto';
9980
+ }
9902
9981
  if (_basicTreeProperties.branchLengths === true) {
9903
9982
  s.scaleAxis = _state.showScaleAxis === true;
9904
9983
  s.scaleGrid = _state.showScaleGrid === true;
@@ -10061,6 +10140,21 @@ function (root, d3, forester, phyloXml) {
10061
10140
  _domain.palette = null; // the drawn set changed: the palette is dealt again
10062
10141
  }
10063
10142
  }
10143
+ if (typeof s.genes === 'boolean') {
10144
+ _state.showGenes = s.genes;
10145
+ }
10146
+ if (GENE_LABEL_MODES.indexOf(s.geneLabels) >= 0) {
10147
+ _state.geneLabels = s.geneLabels;
10148
+ }
10149
+ if (typeof s.geneFamily === 'string' && s.geneFamily.length > 0 && s.geneFamily !== _state.geneFamily) {
10150
+ _state.geneFamily = s.geneFamily;
10151
+ if (_gene) {
10152
+ _gene.palette = null; // the families change with the namespace
10153
+ }
10154
+ }
10155
+ if (typeof s.geneAnchor === 'string' && s.geneAnchor.length > 0) {
10156
+ _state.geneAnchor = s.geneAnchor;
10157
+ }
10064
10158
  // timeAxis / timeGrid: the names before the axis also measured
10065
10159
  // distance, still read so that older links keep their axis
10066
10160
  let axis = typeof s.scaleAxis === 'boolean' ? s.scaleAxis : s.timeAxis;
@@ -10073,8 +10167,9 @@ function (root, d3, forester, phyloXml) {
10073
10167
  }
10074
10168
  if (!radialDisplay()) {
10075
10169
  _radialLabelsHorizontal = false;
10076
- } else if (_state.showDomainArchitectures && _basicTreeProperties.domainArchitectures) {
10077
- _radialLabelsHorizontal = false; // the domain boxes ride the spokes, so the labels must too
10170
+ } else if ((_state.showDomainArchitectures && _basicTreeProperties.domainArchitectures)
10171
+ || (_state.showGenes && _basicTreeProperties.geneRegions)) {
10172
+ _radialLabelsHorizontal = false; // the domain boxes and gene arrows ride the spokes, so the labels must too
10078
10173
  }
10079
10174
  if (_vis) {
10080
10175
  if (s.colorBy === 'none') {
@@ -10154,6 +10249,7 @@ function (root, d3, forester, phyloXml) {
10154
10249
  setCheckboxValue(DOMAIN_GLOW_CB, _state.domainGlow === true);
10155
10250
  setValue(DOMAIN_LABELS_SELECT, _state.domainLabels);
10156
10251
  syncDomainControls();
10252
+ syncGeneControls();
10157
10253
  setSliderValue(FONT_SIZE_SLIDER, _state.externalNodeFontSize);
10158
10254
  setSliderValue(NODE_SIZE_SLIDER, _state.nodeSizeDefault);
10159
10255
  setSliderValue(BRANCH_WIDTH_SLIDER, _state.branchWidthDefault);
@@ -10226,10 +10322,10 @@ function (root, d3, forester, phyloXml) {
10226
10322
  // The heat map's four keys and the alignment logo went in exactly that
10227
10323
  // way, while the README promised a link reproduced the figure.
10228
10324
  const VIEW_TEXT_KEYS = ['layout', 'display', 'order', 'root', 'colorBy', 'shapeBy', 'domainLabels', 'combine',
10229
- 'heatmapOrder', 'scale'];
10325
+ 'heatmapOrder', 'scale', 'geneAnchor', 'geneFamily', 'geneLabels'];
10230
10326
  const VIEW_INT_KEYS = ['tree', 'subtree', 'rotation', 'domainEvalue'];
10231
10327
  const VIEW_NUMBER_KEYS = ['font', 'node', 'branch'];
10232
- const VIEW_BOOL_KEYS = ['horizontalLabels', 'msa', 'msaLogo', 'heatmap', 'domains', 'domainGlow',
10328
+ const VIEW_BOOL_KEYS = ['horizontalLabels', 'msa', 'msaLogo', 'heatmap', 'domains', 'domainGlow', 'genes',
10233
10329
  'scaleAxis', 'scaleGrid', 'timeAxis', 'timeGrid', 'matchCase', 'inverse'];
10234
10330
  // Lists of strings, comma-joined. A property ref carries ':' but never a
10235
10331
  // comma, and encodeViewValue leaves both readable in a hash.
@@ -11742,6 +11838,710 @@ function (root, d3, forester, phyloXml) {
11742
11838
  });
11743
11839
  }
11744
11840
 
11841
+ // ===================== Genome regions (the gene track) =====================
11842
+ // A tip that is a genome can carry a region of it: its genes as located
11843
+ // sequences with their families, one per gene, plus the contig's record
11844
+ // (forester's gene model, test/gene_test.js). Drawn as the domain track
11845
+ // is drawn -- a backbone per tip in one aligned column past the labels in
11846
+ // the rectangular layout, riding the tip's spoke in the radial ones --
11847
+ // but each gene is an ARROW pointing by strand, coloured by family, and
11848
+ // the rows are ANCHORED: the anchor family's gene sits at the same x in
11849
+ // every row, pointing right, the neighbours following, so gene order
11850
+ // compares across genomes (Christian, 2026-10-01). One scale for the
11851
+ // whole tree -- f px per base pair, from the track width and the extent
11852
+ // the rows share -- so distances compare too. A bar at the end of a
11853
+ // backbone is a contig end; the track's own kb bar sits under the rows.
11854
+
11855
+ function genesShown() {
11856
+ return _gene !== null
11857
+ && _state.showGenes === true
11858
+ && _basicTreeProperties.geneRegions === true
11859
+ && _state.showExternalLabels
11860
+ // in a radial layout the arrows ride the spokes, so only radial
11861
+ // labels go with them
11862
+ && (!radialDisplay() || !_radialLabelsHorizontal);
11863
+ }
11864
+
11865
+ // The region a tip carries, computed once per node: its sequences never
11866
+ // change after load, and a subtree view shows the same nodes.
11867
+ function geneRegion(d) {
11868
+ if (_gene.cache.has(d)) {
11869
+ return _gene.cache.get(d);
11870
+ }
11871
+ let r = forester.geneRegionOf(d);
11872
+ _gene.cache.set(d, r);
11873
+ return r;
11874
+ }
11875
+
11876
+ // Every region of the WHOLE tree, in its order. The anchor, the family
11877
+ // namespace and the palette are decided on the tree, never on the view,
11878
+ // so that entering a subtree moves nothing under the reader.
11879
+ function geneTreeRegions() {
11880
+ let out = [];
11881
+ if (!_treeData) {
11882
+ return out;
11883
+ }
11884
+ forester.preOrderTraversalAll(forester.getTreeRoot(_treeData), function (n) {
11885
+ if (!n.children) {
11886
+ let r = geneRegion(n);
11887
+ if (r) {
11888
+ out.push(r);
11889
+ }
11890
+ }
11891
+ });
11892
+ return out;
11893
+ }
11894
+
11895
+ // The namespace the colours and the anchor follow: the one named in the
11896
+ // config or the view, else the one most of the genes carry.
11897
+ function geneFamilyNamespace() {
11898
+ if (_state.geneFamily && _state.geneFamily !== 'auto') {
11899
+ return _state.geneFamily;
11900
+ }
11901
+ let all = forester.geneFamilyNamespaces(geneTreeRegions());
11902
+ return all.length > 0 ? all[0].ns : null;
11903
+ }
11904
+
11905
+ // The anchor family: 'none' is none, 'auto' the tree's best (single-copy
11906
+ // in the most genomes, at least half of them), a ref is itself.
11907
+ function geneAnchor() {
11908
+ let a = _state.geneAnchor;
11909
+ if (a === 'none') {
11910
+ return null;
11911
+ }
11912
+ if (!a || a === 'auto') {
11913
+ return forester.geneDefaultAnchor(geneTreeRegions(), geneFamilyNamespace());
11914
+ }
11915
+ return a;
11916
+ }
11917
+
11918
+ // The track width: the domain track's rules (a quarter of the viewport
11919
+ // at first use in the rectangular layouts, a width of the radial
11920
+ // layouts' own, the same step buttons), kept apart from the domain
11921
+ // track's so each is sized for what it draws.
11922
+ function geneRectangularWidth() {
11923
+ if (!_gene.width) {
11924
+ let vp = svgSize();
11925
+ let vw = Math.min(_displayWidth, (vp && vp.w) ? vp.w : _displayWidth);
11926
+ _gene.width = Math.max(DOMAIN_WIDTH_MIN, Math.round(vw * DOMAIN_WIDTH_VIEWPORT_FRACTION));
11927
+ }
11928
+ return _gene.width;
11929
+ }
11930
+
11931
+ function geneTrackWidth() {
11932
+ if (!radialDisplay()) {
11933
+ return geneRectangularWidth();
11934
+ }
11935
+ if (!_gene.radialWidth) {
11936
+ let r = _state.circularDisplay ? (_radial ? _radial.maxRad : 0) : (_unroot ? _unroot.maxRad : 0);
11937
+ if (!(r > 0)) {
11938
+ return geneRectangularWidth(); // no radius yet (before the first radial draw)
11939
+ }
11940
+ _gene.radialWidth = Math.max(DOMAIN_WIDTH_MIN, Math.min(geneRectangularWidth(), DOMAIN_RADIAL_WIDTH_FRACTION * r));
11941
+ }
11942
+ return _gene.radialWidth;
11943
+ }
11944
+
11945
+ function setGeneTrackWidth(w) {
11946
+ if (radialDisplay()) {
11947
+ _gene.radialWidth = w;
11948
+ } else {
11949
+ _gene.width = w;
11950
+ }
11951
+ }
11952
+
11953
+ // The rectangular layout reserves the track's column past the domain
11954
+ // tracks' (both counted wherever _w is); the radial layouts fit it into
11955
+ // the ring (fitRadialExtent).
11956
+ function geneReserve() {
11957
+ if (!genesShown() || radialDisplay()) {
11958
+ return 0;
11959
+ }
11960
+ return DOMAIN_TRACK_START_GAP + geneTrackWidth() + DOMAIN_TRACK_END_GAP;
11961
+ }
11962
+
11963
+ function geneRadialExtent() {
11964
+ if (!genesShown() || !radialDisplay()) {
11965
+ return 0;
11966
+ }
11967
+ return DOMAIN_RADIAL_GAP + geneTrackWidth() + DOMAIN_TRACK_END_GAP;
11968
+ }
11969
+
11970
+ // Colours: the families SHARED over the whole tree (in two genomes or
11971
+ // more), sorted by code unit, take the palette in order -- dealt at load
11972
+ // and when the namespace changes. A family met in one genome only is
11973
+ // grey, so conserved order stands out; a gene with no family is lighter
11974
+ // grey.
11975
+ function genePalette() {
11976
+ if (!_gene.palette) {
11977
+ let names = forester.geneFamilySummary(geneTreeRegions(), geneFamilyNamespace()).names;
11978
+ _gene.palette = Object.create(null);
11979
+ names.forEach(function (name, i) {
11980
+ _gene.palette[name] = i;
11981
+ });
11982
+ }
11983
+ return _gene.palette;
11984
+ }
11985
+
11986
+ function geneColor(family) {
11987
+ if (!family) {
11988
+ return forester.GENE_NO_FAMILY_COLOR;
11989
+ }
11990
+ let palette = genePalette();
11991
+ if (palette[family] === undefined) {
11992
+ return forester.GENE_SINGLETON_COLOR;
11993
+ }
11994
+ return forester.domainQualitativeColor(palette[family]);
11995
+ }
11996
+
11997
+ // What a gene is called where there is room for one word: its symbol,
11998
+ // else its gene name.
11999
+ function geneLabel(g) {
12000
+ return g.symbol || g.geneName || '';
12001
+ }
12002
+
12003
+ // ---- hover and click: the readout the domains, heat map and alignment use
12004
+ function geneInfoAt(event) {
12005
+ let t = event.target;
12006
+ return (t && t.__aptxGene) ? t.__aptxGene : null;
12007
+ }
12008
+
12009
+ let _geneHovered = null;
12010
+
12011
+ function geneHover(event) {
12012
+ let info = geneInfoAt(event);
12013
+ if (!info) {
12014
+ geneHoverOut();
12015
+ return;
12016
+ }
12017
+ let tip_el = _node_mouseover_div.node();
12018
+ if (info === _geneHovered) {
12019
+ placeHoverReadout(tip_el, event); // follows the pointer; the text is unchanged
12020
+ return;
12021
+ }
12022
+ _geneHovered = info;
12023
+ let g = info.gene;
12024
+ let title = geneLabel(g) || g.product || '(unnamed)';
12025
+ let txt = 'Gene: ' + title + '<br>';
12026
+ if (g.product && g.product !== title) {
12027
+ txt += 'Product: ' + g.product + '<br>';
12028
+ }
12029
+ if (info.family) {
12030
+ txt += 'Family: ' + info.family + '<br>';
12031
+ }
12032
+ txt += 'Location: ' + forester.geneLocationText(g) + (info.flipped ? ' (the row is mirrored)' : '') + '<br>';
12033
+ txt += 'Length: ' + countNumber(g.length) + ' bp<br>';
12034
+ if (info.tip) {
12035
+ txt += 'Tip: ' + info.tip + '<br>';
12036
+ }
12037
+ if (g.accession) {
12038
+ txt += 'Accession' + (g.accession.source ? ' [' + g.accession.source + ']' : '') + ': ' + g.accession.value + '<br>';
12039
+ }
12040
+ let ref = forester.geneReference(g);
12041
+ if (ref) {
12042
+ // the readout follows the pointer, so the ARROW is the link and
12043
+ // the readout says so (a "label: value" row, as the domains do it)
12044
+ txt += 'Click: the ' + ref.site + ' entry';
12045
+ }
12046
+ tip_el.classList.remove('aptx-light', 'aptx-dark');
12047
+ if (_panelTheme) {
12048
+ tip_el.classList.add('aptx-' + _panelTheme);
12049
+ }
12050
+ _node_mouseover_div.html(markUpDataLabels(escapeHtmlKeepBreaks(txt)));
12051
+ placeHoverReadout(tip_el, event);
12052
+ _node_mouseover_div.transition().duration(100).style('opacity', 0.95);
12053
+ }
12054
+
12055
+ function geneHoverOut() {
12056
+ _geneHovered = null;
12057
+ _node_mouseover_div.transition().duration(300).style('opacity', 1e-6);
12058
+ }
12059
+
12060
+ let _geneOpenedAt = 0;
12061
+
12062
+ function geneClicked(event) {
12063
+ let info = geneInfoAt(event);
12064
+ if (!info) {
12065
+ return;
12066
+ }
12067
+ event.stopPropagation(); // the arrow's click, linked or not: never the node menu behind it
12068
+ let ref = forester.geneReference(info.gene);
12069
+ if (!ref) {
12070
+ return;
12071
+ }
12072
+ let now = Date.now();
12073
+ if ((now - _geneOpenedAt) < 500) { // a double click arrives as two clicks
12074
+ return;
12075
+ }
12076
+ _geneOpenedAt = now;
12077
+ openExternal(ref.url);
12078
+ }
12079
+
12080
+ function geneDblClicked(event) {
12081
+ if (geneInfoAt(event)) {
12082
+ event.stopPropagation();
12083
+ event.preventDefault();
12084
+ }
12085
+ }
12086
+
12087
+ // ---- the track
12088
+ function drawGeneTrack() {
12089
+ if (!_svgGroup) {
12090
+ return;
12091
+ }
12092
+ let existing = _svgGroup.select('g.aptx-genes');
12093
+ // A region goes WITH ITS NAME, and is left out when the crowding rule
12094
+ // took the name (the domain track's rule, for the same reason)
12095
+ let tips = (genesShown() && _root) ? displayedTips().filter(function (d) {
12096
+ if (d._labelDropped) {
12097
+ return false;
12098
+ }
12099
+ return _state.unrootedDisplay ? (d.ux !== undefined) : (d.x !== undefined);
12100
+ }) : [];
12101
+ if (tips.length > 0 && !radialDisplay()) {
12102
+ tips.sort(function (p, q) {
12103
+ return p.x - q.x;
12104
+ });
12105
+ }
12106
+ let regions = tips.map(geneRegion);
12107
+ let ns = tips.length > 0 ? geneFamilyNamespace() : null;
12108
+ let anchor = tips.length > 0 ? geneAnchor() : null;
12109
+ let laid = tips.length > 0 ? forester.geneTrackRows(regions, anchor, ns) : null;
12110
+ let W = tips.length > 0 ? geneTrackWidth() : 0;
12111
+ let f = (laid && laid.extent && laid.extent.max > laid.extent.min)
12112
+ ? (W / (laid.extent.max - laid.extent.min)) * DOMAIN_SCALE_HEADROOM : 0;
12113
+ if (!laid || !(f > 0) || !isFinite(f)) {
12114
+ existing.remove();
12115
+ geneHoverOut(); // the arrow under the pointer just stopped existing
12116
+ return;
12117
+ }
12118
+ // [the backbones and caps, the arrows, the symbols, the kb bar], kept
12119
+ // between redraws and moved to the end of the tree group
12120
+ let g = existing.node();
12121
+ if (!g) {
12122
+ g = document.createElementNS(d3.namespaces.svg, 'g');
12123
+ g.setAttribute('class', 'aptx-genes');
12124
+ g.style.pointerEvents = 'none';
12125
+ ['g', 'g', 'g', 'g'].forEach(function (tag) {
12126
+ g.appendChild(document.createElementNS(d3.namespaces.svg, tag));
12127
+ });
12128
+ }
12129
+ _svgGroup.node().appendChild(g);
12130
+ let underLayer = g.children[0];
12131
+ let bodyLayer = g.children[1];
12132
+ let labelLayer = g.children[2];
12133
+ let rulerLayer = g.children[3];
12134
+ // the group ignores the mouse so the tree underneath stays clickable;
12135
+ // the ARROWS alone take it back
12136
+ bodyLayer.style.pointerEvents = 'auto';
12137
+ if (!bodyLayer.__aptxWired) {
12138
+ bodyLayer.__aptxWired = true;
12139
+ bodyLayer.addEventListener('mousemove', geneHover);
12140
+ bodyLayer.addEventListener('mouseout', geneHoverOut);
12141
+ bodyLayer.addEventListener('click', geneClicked);
12142
+ bodyLayer.addEventListener('dblclick', geneDblClicked);
12143
+ }
12144
+
12145
+ // Where each tip's region goes: the start of the track along the
12146
+ // row, the top and height of its arrows, and in the radial layouts
12147
+ // the transform that turns it onto its spoke. Past the domain tracks
12148
+ // when those are shown.
12149
+ let labelSpace = tipLabelSpace();
12150
+ let rows = [];
12151
+ let columnStart = null;
12152
+ if (_state.circularDisplay) {
12153
+ let r0 = _radial.maxRad + labelSpace + domainRadialExtent() + DOMAIN_RADIAL_GAP;
12154
+ let h = domainBoxHeight((r0 * 2 * Math.PI) / tips.length);
12155
+ tips.forEach(function (d) {
12156
+ rows.push({d: d, start: r0, y1: -h / 2, h: h,
12157
+ transform: 'rotate(' + labelAngleDeg(d) + ')', labelsOn: false});
12158
+ });
12159
+ } else if (_state.unrootedDisplay) {
12160
+ let h = domainBoxHeight((Math.PI * 2 * _unroot.maxRad) / tips.length);
12161
+ let start = labelSpace + domainRadialExtent() + DOMAIN_RADIAL_GAP;
12162
+ tips.forEach(function (d) {
12163
+ rows.push({d: d, start: start, y1: -h / 2, h: h,
12164
+ transform: 'translate(' + d.ux + ',' + d.uy + ') rotate(' + labelAngleDeg(d) + ')', labelsOn: false});
12165
+ });
12166
+ } else {
12167
+ let n = tips.length;
12168
+ let pitch = n > 1 ? (tips[n - 1].x - tips[0].x) / (n - 1) : _state.externalNodeFontSize;
12169
+ let h = domainBoxHeight(pitch / 2);
12170
+ columnStart = _w + _state.nodeLabelGap + labelSpace + _domainReserve + DOMAIN_TRACK_START_GAP;
12171
+ let labelsOn = _state.geneLabels === 'genes';
12172
+ tips.forEach(function (d) {
12173
+ rows.push({d: d, start: columnStart, y1: d.x - (h / 2), h: h, transform: null, labelsOn: labelsOn});
12174
+ });
12175
+ }
12176
+
12177
+ // ---- collect: the backbones and caps as path data (one path for the
12178
+ // whole track in the rectangular layout, one per row in the radial
12179
+ // ones, each in its row's turned frame -- the domain track's lesson
12180
+ // about turned coordinates and PDF size), one path element per
12181
+ // arrow (it is what the mouse finds), the symbols that fit
12182
+ let under = []; // {d, transform}
12183
+ let trackParts = [];
12184
+ let bodies = []; // {d, fill, stroke, transform, info, link}
12185
+ let names = [];
12186
+ let lowest = -Infinity;
12187
+ rows.forEach(function (row, i) {
12188
+ let lr = laid.rows[i];
12189
+ if (!lr) {
12190
+ return;
12191
+ }
12192
+ let geo = forester.geneRowGeometry(lr, row.start, f, laid.extent.min);
12193
+ let h = row.h;
12194
+ let y1 = row.y1;
12195
+ let parts = row.transform ? [] : trackParts;
12196
+ parts.push(roundRectPath(geo.backbone.x, y1 + (h / 2) - 0.5, geo.backbone.w, 1, 0));
12197
+ if (geo.capLeft) {
12198
+ parts.push(roundRectPath(geo.backbone.x - (GENE_CAP_W / 2), y1, GENE_CAP_W, h, 0));
12199
+ }
12200
+ if (geo.capRight) {
12201
+ parts.push(roundRectPath(geo.backbone.x + geo.backbone.w - (GENE_CAP_W / 2), y1, GENE_CAP_W, h, 0));
12202
+ }
12203
+ if (row.transform) {
12204
+ under.push({d: parts.join(''), transform: row.transform});
12205
+ }
12206
+ if (y1 + h > lowest) {
12207
+ lowest = y1 + h;
12208
+ }
12209
+ // once per ROW: displayNodeName walks the node's properties when
12210
+ // a label-ref visualization is on
12211
+ let rowTip = displayNodeName(row.d) || '';
12212
+ // two lanes share the height where genes overlap
12213
+ let laneH = lr.lanes > 1 ? Math.max(GENE_LANE_MIN_H, h / 2) : h;
12214
+ let fs = Math.min(_state.externalNodeFontSize, laneH - 2);
12215
+ let font = fs + 'px ' + FONT_DEFAULTS;
12216
+ geo.genes.forEach(function (b) {
12217
+ let fam = forester.geneFamilyOf(b.gene, ns);
12218
+ let base = geneColor(fam);
12219
+ let y = (lr.lanes > 1 && b.lane === 1) ? (y1 + h - laneH) : y1;
12220
+ let d = forester.geneArrowPath(b.x, y, b.w, laneH, b.dir);
12221
+ if (d === '') {
12222
+ return;
12223
+ }
12224
+ let info = {gene: b.gene, family: fam, tip: rowTip, flipped: lr.flip};
12225
+ bodies.push({d: d, fill: base, stroke: forester.domainDarken(base, 0.3), transform: row.transform,
12226
+ info: info, link: !!forester.geneReference(b.gene)});
12227
+ let label = geneLabel(b.gene);
12228
+ let head = Math.min(laneH / 2, b.w);
12229
+ if (row.labelsOn && label && fs > 4 && legendTextWidth(label, font) <= b.w - head - 4) {
12230
+ // centred on the body, which the head is not part of
12231
+ names.push({x: b.x + ((b.dir > 0 ? 0 : head) + ((b.w - head) / 2)), y: y + (laneH / 2), font: font,
12232
+ fill: forester.domainLabelInk(base), text: label});
12233
+ }
12234
+ });
12235
+ });
12236
+ if (trackParts.length > 0) {
12237
+ under.push({d: trackParts.join(''), transform: null});
12238
+ }
12239
+
12240
+ // ---- draw: bottom to top, reusing the elements already there
12241
+ let paths = sizeLayer(underLayer, 'path', under.length);
12242
+ under.forEach(function (u, k) {
12243
+ paths[k].setAttribute('d', u.d);
12244
+ if (u.transform) {
12245
+ paths[k].setAttribute('transform', u.transform);
12246
+ } else {
12247
+ paths[k].removeAttribute('transform');
12248
+ }
12249
+ paths[k].style.fill = DOMAIN_BACKBONE_COLOR;
12250
+ });
12251
+ geneHoverOut(); // a redraw can take the hovered arrow away without a mouseout
12252
+ let arrows = sizeLayer(bodyLayer, 'path', bodies.length);
12253
+ bodies.forEach(function (b, k) {
12254
+ let el = arrows[k];
12255
+ el.setAttribute('d', b.d);
12256
+ if (b.transform) {
12257
+ el.setAttribute('transform', b.transform);
12258
+ } else {
12259
+ el.removeAttribute('transform');
12260
+ }
12261
+ el.style.fill = b.fill;
12262
+ el.style.stroke = b.stroke;
12263
+ el.style.strokeWidth = 1;
12264
+ el.style.strokeLinejoin = 'round';
12265
+ el.__aptxGene = b.info;
12266
+ el.style.cursor = b.link ? 'pointer' : 'default'; // a hand only where the click goes somewhere
12267
+ });
12268
+ let texts = sizeLayer(labelLayer, 'text', names.length);
12269
+ names.forEach(function (t, k) {
12270
+ let el = texts[k];
12271
+ el.setAttribute('x', t.x);
12272
+ el.setAttribute('y', t.y);
12273
+ el.setAttribute('dy', '0.35em');
12274
+ el.setAttribute('text-anchor', 'middle');
12275
+ el.style.font = t.font;
12276
+ el.style.fill = t.fill;
12277
+ el.textContent = t.text;
12278
+ });
12279
+
12280
+ // the kb bar under the rows, rectangular only: a round number of base
12281
+ // pairs at this scale, with end ticks, the label after it
12282
+ let ruler = d3.select(rulerLayer);
12283
+ ruler.selectAll('*').remove();
12284
+ let bar = (columnStart !== null && isFinite(lowest)) ? forester.geneScaleBar(f, GENE_KB_BAR_TARGET_PX) : null;
12285
+ if (bar) {
12286
+ let ink = _state.labelColorDefault;
12287
+ let y = lowest + 12;
12288
+ let x0 = columnStart;
12289
+ let x1 = columnStart + bar.px;
12290
+ let fs = Math.max(9, Math.min(_state.externalNodeFontSize, 12));
12291
+ ruler.append('path')
12292
+ .attr('d', 'M' + x0 + ',' + (y - 3) + 'v6M' + x0 + ',' + y + 'H' + x1 + 'M' + x1 + ',' + (y - 3) + 'v6')
12293
+ .style('fill', 'none').style('stroke', ink).style('stroke-width', 1);
12294
+ ruler.append('text')
12295
+ .attr('x', x1 + 5).attr('y', y).attr('dy', '0.35em')
12296
+ .style('font', fs + 'px ' + FONT_DEFAULTS).style('fill', ink)
12297
+ .text(bar.label);
12298
+ }
12299
+ }
12300
+
12301
+ // The gene legend, in "Legend" mode only: the shared families drawn in
12302
+ // the view, in first-appearance order, each with its commonest product
12303
+ // and the number of genomes carrying it; then, when there are any, the
12304
+ // grey rows for the families unique to one genome and the genes without
12305
+ // a family. Its own card with the domain legend's manners: home
12306
+ // bottom-right, a drag keeps its place, a double-click sends it home.
12307
+ function drawGeneLegend() {
12308
+ if (!_baseSvg) {
12309
+ return;
12310
+ }
12311
+ _baseSvg.selectAll('g.' + LEGEND_GENES).remove();
12312
+ if (!_root || !genesShown() || _state.geneLabels !== 'legend') {
12313
+ return;
12314
+ }
12315
+ let tips = displayedTips().filter(function (d) {
12316
+ return d.x !== undefined;
12317
+ }).sort(function (p, q) {
12318
+ return p.x - q.x;
12319
+ });
12320
+ let ns = geneFamilyNamespace();
12321
+ let summary = forester.geneFamilySummary(tips.map(geneRegion), ns, new Set(Object.keys(genePalette())));
12322
+ let size = svgSize();
12323
+ if ((summary.legend.length === 0 && summary.singletons === 0 && summary.unfamilied === 0) || !size) {
12324
+ return;
12325
+ }
12326
+ const FS = Math.max(11, _state.externalNodeFontSize || 11);
12327
+ const PAD = 9;
12328
+ const ROW = FS + 6;
12329
+ const SWATCH = 10;
12330
+ const GAP = 6;
12331
+ const rowFont = FS + 'px ' + FONT_DEFAULTS;
12332
+ const titleFont = '600 ' + (FS + 1) + 'px ' + FONT_DEFAULTS;
12333
+ const ink = _state.labelColorDefault;
12334
+ const frame = _state.branchColorDefault;
12335
+ let title = 'Gene families' + (ns ? ' (' + ns + ')' : '');
12336
+ let rows = summary.legend.map(function (r) {
12337
+ let what = r.product || r.symbol || r.family;
12338
+ return {
12339
+ text: clipTextToWidth(what + ' (' + r.tips + ')', rowFont, DOMAIN_LEGEND_ROW_MAX_PX),
12340
+ color: geneColor(r.family)
12341
+ };
12342
+ });
12343
+ if (summary.singletons > 0) {
12344
+ rows.push({text: 'unique to one genome (' + summary.singletons + ')', color: forester.GENE_SINGLETON_COLOR});
12345
+ }
12346
+ if (summary.unfamilied > 0) {
12347
+ rows.push({text: 'no family (' + summary.unfamilied + ')', color: forester.GENE_NO_FAMILY_COLOR});
12348
+ }
12349
+ let width = legendTextWidth(title, titleFont);
12350
+ rows.forEach(function (r) {
12351
+ width = Math.max(width, SWATCH + GAP + legendTextWidth(r.text, rowFont));
12352
+ });
12353
+ width += 2 * PAD;
12354
+ let height = PAD + ROW + (rows.length * ROW) + PAD - 2;
12355
+ let x, y;
12356
+ if (_gene.legendFrac) {
12357
+ x = _gene.legendFrac.fx * size.w;
12358
+ y = _gene.legendFrac.fy * size.h;
12359
+ } else {
12360
+ x = size.w - width - DOMAIN_LEGEND_INSET;
12361
+ y = size.h - height - DOMAIN_LEGEND_INSET;
12362
+ }
12363
+ x = Math.max(0, Math.min(size.w - 20, x));
12364
+ y = Math.max(0, Math.min(size.h - 20, y));
12365
+
12366
+ let g = _baseSvg.append('g').attr('class', LEGEND_GENES)
12367
+ .style('cursor', 'move')
12368
+ .call(d3.drag()
12369
+ .on('start', function (event) {
12370
+ if (event.sourceEvent) {
12371
+ event.sourceEvent.stopPropagation(); // not a pan of the tree
12372
+ }
12373
+ })
12374
+ .on('drag', function (event) {
12375
+ x += event.dx;
12376
+ y += event.dy;
12377
+ _gene.legendFrac = {fx: x / size.w, fy: y / size.h};
12378
+ drawGeneLegend();
12379
+ }))
12380
+ .on('dblclick', function (event) {
12381
+ event.stopPropagation();
12382
+ _gene.legendFrac = null;
12383
+ drawGeneLegend();
12384
+ });
12385
+ g.append('title').text('drag to move; double-click to send it back to the corner');
12386
+ g.append('rect')
12387
+ .attr('x', x).attr('y', y)
12388
+ .attr('width', width).attr('height', height)
12389
+ .attr('rx', 5)
12390
+ .style('fill', _state.backgroundColorDefault)
12391
+ .style('fill-opacity', 0.92)
12392
+ .style('stroke', frame)
12393
+ .style('stroke-opacity', 0.5);
12394
+ let baseline = y + PAD + FS;
12395
+ g.append('text')
12396
+ .attr('x', x + PAD).attr('y', baseline)
12397
+ .style('font', titleFont)
12398
+ .style('fill', ink)
12399
+ .text(title);
12400
+ rows.forEach(function (r) {
12401
+ baseline += ROW;
12402
+ g.append('rect')
12403
+ .attr('x', x + PAD).attr('y', baseline - SWATCH + 1)
12404
+ .attr('width', SWATCH).attr('height', SWATCH)
12405
+ .attr('rx', 2)
12406
+ .style('fill', r.color);
12407
+ g.append('text')
12408
+ .attr('x', x + PAD + SWATCH + GAP).attr('y', baseline)
12409
+ .style('font', rowFont)
12410
+ .style('fill', ink)
12411
+ .text(r.text);
12412
+ });
12413
+ }
12414
+
12415
+ // ---- the controls follow the toggle: hidden without it; while shown the
12416
+ // step buttons, the two selects and the label mode say where things stand
12417
+ function fillSelect(sel, options, value) {
12418
+ sel.textContent = '';
12419
+ options.forEach(function (o) {
12420
+ let opt = document.createElement('option');
12421
+ opt.value = o.value;
12422
+ opt.textContent = o.text;
12423
+ if (o.title) {
12424
+ opt.title = o.title;
12425
+ }
12426
+ sel.appendChild(opt);
12427
+ });
12428
+ sel.value = value;
12429
+ }
12430
+
12431
+ function geneFamilyShortName(c) {
12432
+ return c.symbol || c.product || c.family;
12433
+ }
12434
+
12435
+ function syncGeneControls() {
12436
+ let fs = byId(GENE_CONTROLS);
12437
+ if (!fs) {
12438
+ return;
12439
+ }
12440
+ let on = _state.showGenes === true && _basicTreeProperties.geneRegions === true && _gene !== null;
12441
+ setCheckboxValue(GENES_CB, _state.showGenes === true);
12442
+ fs.style.display = on ? '' : 'none';
12443
+ if (!on) {
12444
+ return;
12445
+ }
12446
+ let w = geneTrackWidth();
12447
+ (w > DOMAIN_WIDTH_MIN ? enableButton : disableButton)(byId(GENE_WIDTH_DEC));
12448
+ (w < DOMAIN_WIDTH_MAX ? enableButton : disableButton)(byId(GENE_WIDTH_INC));
12449
+ let regions = geneTreeRegions();
12450
+ let ns = geneFamilyNamespace();
12451
+ // the anchor: Auto (naming its pick), every family that can anchor
12452
+ // two genomes or more -- most first -- and None
12453
+ let sel = byId(GENE_ANCHOR_SELECT);
12454
+ if (sel) {
12455
+ let auto = forester.geneDefaultAnchor(regions, ns);
12456
+ let cands = forester.geneAnchorCandidates(regions, ns);
12457
+ let byFamily = Object.create(null);
12458
+ cands.forEach(function (c) {
12459
+ byFamily[c.family] = c;
12460
+ });
12461
+ let options = [{value: 'auto', text: 'Auto' + (auto ? ' (' + geneFamilyShortName(byFamily[auto]) + ')' : ' (none)'),
12462
+ title: auto ? auto : 'no family is single-copy in half the genomes'}];
12463
+ cands.forEach(function (c) {
12464
+ if (c.single >= 2 || c.family === _state.geneAnchor) {
12465
+ options.push({value: c.family, text: geneFamilyShortName(c) + ' (' + c.single + '/' + regions.length + ')',
12466
+ title: c.family + (c.product && c.symbol ? ' — ' + c.product : '')});
12467
+ }
12468
+ });
12469
+ if (_state.geneAnchor !== 'auto' && _state.geneAnchor !== 'none' && !byFamily[_state.geneAnchor]) {
12470
+ options.push({value: _state.geneAnchor, text: _state.geneAnchor + ' (not in this tree)', title: _state.geneAnchor});
12471
+ }
12472
+ options.push({value: 'none', text: 'None (left-aligned)', title: 'no anchor: every row starts at its left end'});
12473
+ fillSelect(sel, options, _state.geneAnchor);
12474
+ }
12475
+ // the namespace: offered when the genes carry more than one
12476
+ let nss = forester.geneFamilyNamespaces(regions);
12477
+ let row = byId(GENE_FAMILY_SELECT + '_row');
12478
+ let fsel = byId(GENE_FAMILY_SELECT);
12479
+ if (fsel && row) {
12480
+ let options = nss.map(function (x) {
12481
+ return {value: x.ns, text: x.ns + ' (' + countNumber(x.genes) + ' genes)', title: 'annotations with the prefix ' + x.ns + ':'};
12482
+ });
12483
+ if (ns && !nss.some(function (x) {
12484
+ return x.ns === ns;
12485
+ })) {
12486
+ options.push({value: ns, text: ns + ' (not in this tree)', title: ns});
12487
+ }
12488
+ fillSelect(fsel, options, ns || '');
12489
+ row.style.display = options.length > 1 ? '' : 'none';
12490
+ }
12491
+ setValue(GENE_LABELS_SELECT, _state.geneLabels);
12492
+ }
12493
+
12494
+ function genesCbClicked() {
12495
+ _state.showGenes = getCheckboxValue(GENES_CB);
12496
+ if (_state.showGenes && radialDisplay()) {
12497
+ _radialLabelsHorizontal = false; // the arrows ride the spokes, so the labels must too
12498
+ syncZoomRowButtons();
12499
+ }
12500
+ syncGeneControls();
12501
+ scheduleUpdate(null, 0);
12502
+ }
12503
+
12504
+ function geneWidthStep(grow) {
12505
+ let w = geneTrackWidth();
12506
+ if (grow ? w >= DOMAIN_WIDTH_MAX : w <= DOMAIN_WIDTH_MIN) {
12507
+ return;
12508
+ }
12509
+ setGeneTrackWidth(w * (grow ? DOMAIN_WIDTH_GROW : DOMAIN_WIDTH_SHRINK));
12510
+ syncGeneControls();
12511
+ if (radialDisplay()) {
12512
+ scheduleUpdate(null, 0, true);
12513
+ afterUpdate(zoomToFit);
12514
+ } else {
12515
+ scheduleUpdate(null, 0);
12516
+ }
12517
+ }
12518
+
12519
+ function geneAnchorChanged() {
12520
+ let v = getValue(GENE_ANCHOR_SELECT);
12521
+ if (!v || v === _state.geneAnchor) {
12522
+ return;
12523
+ }
12524
+ _state.geneAnchor = v;
12525
+ scheduleUpdate(null, 0, true);
12526
+ }
12527
+
12528
+ function geneFamilyChanged() {
12529
+ let v = getValue(GENE_FAMILY_SELECT);
12530
+ if (!v || v === geneFamilyNamespace()) {
12531
+ return;
12532
+ }
12533
+ _state.geneFamily = v;
12534
+ _state.geneAnchor = 'auto'; // the old anchor was a family of the old namespace
12535
+ _gene.palette = null; // the families change with the namespace
12536
+ syncGeneControls();
12537
+ scheduleUpdate(null, 0, true);
12538
+ }
12539
+
12540
+ function geneLabelsChanged() {
12541
+ _state.geneLabels = getValue(GENE_LABELS_SELECT);
12542
+ scheduleUpdate(null, 0, true);
12543
+ }
12544
+
11745
12545
  // ===================== Time axis =====================
11746
12546
  // The desktop's time overlays, drawn beneath a rectangular PHYLOGRAM of
11747
12547
  // a dated tree: the two-band ICS geologic axis with a "Ma before
@@ -12506,7 +13306,7 @@ function (root, d3, forester, phyloXml) {
12506
13306
  return;
12507
13307
  }
12508
13308
  let labelSpace = (_maxLabelLength * _state.externalNodeFontSize * LABEL_SIZE_CALC_FACTOR) + LABEL_SIZE_CALC_ADDITION;
12509
- let outer = maxRad + labelSpace + domainRadialExtent() + heatmapRadialExtent();
13309
+ let outer = maxRad + labelSpace + domainRadialExtent() + geneRadialExtent() + heatmapRadialExtent();
12510
13310
  let W = +_baseSvg.attr('width'), H = +_baseSvg.attr('height');
12511
13311
  let scale = 0.9 * (Math.min(W, H) / (2 * outer));
12512
13312
  if (!isFinite(scale) || scale <= 0) {
@@ -13565,12 +14365,14 @@ function (root, d3, forester, phyloXml) {
13565
14365
  search0();
13566
14366
  search1();
13567
14367
  }
13568
- if (radialDisplay() && _state.showDomainArchitectures && _basicTreeProperties.domainArchitectures) {
13569
- // the domain boxes ride the tips' spokes, so the labels must too
14368
+ if (radialDisplay() && ((_state.showDomainArchitectures && _basicTreeProperties.domainArchitectures)
14369
+ || (_state.showGenes && _basicTreeProperties.geneRegions))) {
14370
+ // the domain boxes and gene arrows ride the tips' spokes, so the labels must too
13570
14371
  _radialLabelsHorizontal = false;
13571
14372
  }
13572
14373
  syncZoomRowButtons();
13573
14374
  afterUpdate(syncDomainControls); // the track width buttons follow the layout's own width
14375
+ afterUpdate(syncGeneControls);
13574
14376
  zoomToFit();
13575
14377
  }
13576
14378
 
@@ -14502,7 +15304,7 @@ function (root, d3, forester, phyloXml) {
14502
15304
  const PANEL_SECTIONS_KEY = 'aptx-panel-sections';
14503
15305
  // Download too (Christian, 2026-09-28): four rows, and not something a
14504
15306
  // session uses often.
14505
- const PANEL_SECTIONS_CLOSED_BY_DEFAULT = ['View & Tools', 'Sizes', 'Domain Architectures', 'Download'];
15307
+ const PANEL_SECTIONS_CLOSED_BY_DEFAULT = ['View & Tools', 'Sizes', 'Domain Architectures', 'Genome regions', 'Download'];
14506
15308
 
14507
15309
  function loadPanelSections() {
14508
15310
  if (_panelSections) {
@@ -15832,6 +16634,15 @@ function (root, d3, forester, phyloXml) {
15832
16634
  if (s.type) {
15833
16635
  text += '- Type: ' + s.type + '<br>';
15834
16636
  }
16637
+ // The annotations, which this dialog never listed and which a
16638
+ // gene's family lives in (<annotation ref="pgfam:...">): the
16639
+ // ref, with the description when there is one.
16640
+ (s.annotations || []).forEach(function (an) {
16641
+ if (!an || (!an.ref && !an.desc)) {
16642
+ return;
16643
+ }
16644
+ text += '- Annotation: ' + (an.ref ? an.ref + (an.desc ? ' (' + an.desc + ')' : '') : an.desc) + '<br>';
16645
+ });
15835
16646
  // The domain architecture, which phyloXML has always carried
15836
16647
  // and this dialog has never shown: a reader could find a node
15837
16648
  // by searching for one of its domains -- forester indexes them
@@ -16287,6 +17098,10 @@ function (root, d3, forester, phyloXml) {
16287
17098
  statOf(st.tipsWithDomains, st.tips, 'tips')
16288
17099
  + (bp.maxDomainArchitectureLength > 0
16289
17100
  ? (', up to ' + countNumber(bp.maxDomainArchitectureLength) + ' residues') : ''));
17101
+ carries('Genome regions', st.tipsWithGenes > 0,
17102
+ statOf(st.tipsWithGenes, st.tips, 'tips')
17103
+ + (bp.maxGeneRegionLength > 0
17104
+ ? (', up to ' + countNumber(bp.maxGeneRegionLength) + ' bp') : ''));
16290
17105
  carries('Dates', st.nodesWithDate > 0, statOf(st.nodesWithDate, st.nodes, 'nodes'));
16291
17106
  carries('Distributions', st.tipsWithDistribution > 0,
16292
17107
  statOf(st.tipsWithDistribution, st.tips, 'tips'));
@@ -16559,6 +17374,8 @@ function (root, d3, forester, phyloXml) {
16559
17374
 
16560
17375
  c0.insertAdjacentHTML('beforeend',makeDomainControls());
16561
17376
 
17377
+ c0.insertAdjacentHTML('beforeend',makeGeneControls());
17378
+
16562
17379
  c0.insertAdjacentHTML('beforeend',makeHeatmapControls());
16563
17380
 
16564
17381
  c0.insertAdjacentHTML('beforeend',makeZoomControl());
@@ -16688,6 +17505,12 @@ function (root, d3, forester, phyloXml) {
16688
17505
  on(DOMAIN_EVALUE_INC, 'click', function () { domainEvalueStep(1); });
16689
17506
  on(DOMAIN_LABELS_SELECT, 'change', domainLabelsChanged);
16690
17507
  on(DOMAIN_GLOW_CB, 'click', domainGlowCbClicked);
17508
+ on(GENES_CB, 'click', genesCbClicked);
17509
+ onHoldRepeat(GENE_WIDTH_DEC, function () { geneWidthStep(false); });
17510
+ onHoldRepeat(GENE_WIDTH_INC, function () { geneWidthStep(true); });
17511
+ on(GENE_ANCHOR_SELECT, 'change', geneAnchorChanged);
17512
+ on(GENE_FAMILY_SELECT, 'change', geneFamilyChanged);
17513
+ on(GENE_LABELS_SELECT, 'change', geneLabelsChanged);
16691
17514
  on(SCALE_AXIS_CB, 'click', scaleAxisCbClicked);
16692
17515
  on(SCALE_GRID_CB, 'click', scaleGridCbClicked);
16693
17516
 
@@ -17237,6 +18060,9 @@ function (root, d3, forester, phyloXml) {
17237
18060
  if (_basicTreeProperties.domainArchitectures) {
17238
18061
  opts.push(makeCheckboxItem('Domain Architectures', DOMAINS_CB, 'to show/hide the protein domain architectures beside the tips', true));
17239
18062
  }
18063
+ if (_basicTreeProperties.geneRegions) {
18064
+ opts.push(makeCheckboxItem('Genes', GENES_CB, 'to show/hide the genome regions beside the tips: each gene an arrow on a backbone, coloured by family, the rows lined up on one family'));
18065
+ }
17240
18066
  if (heatmapAvailable()) {
17241
18067
  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)'));
17242
18068
  }
@@ -17375,6 +18201,40 @@ function (root, d3, forester, phyloXml) {
17375
18201
  return h;
17376
18202
  }
17377
18203
 
18204
+ // The gene track's controls, shown only while the Genes toggle is on
18205
+ // (syncGeneControls): the track width, the anchor family, the family
18206
+ // namespace the colours follow (shown when the genes carry more than
18207
+ // one), and the label mode. The two selects are filled per tree by
18208
+ // syncGeneControls, since their options come from the genes.
18209
+ function makeGeneControls() {
18210
+ let h = '<fieldset id="' + GENE_CONTROLS + '" style="display:none">';
18211
+ h = h.concat('<legend>Genome regions</legend>');
18212
+ h = h.concat('<div class="aptx-domrow"><span class="aptx-domlabel">Track width</span>');
18213
+ h = h.concat(makeButton('−', GENE_WIDTH_DEC, 'narrow the gene track (hold to repeat)'));
18214
+ h = h.concat(makeButton('+', GENE_WIDTH_INC, 'widen the gene track (hold to repeat)'));
18215
+ h = h.concat('</div>');
18216
+ h = h.concat('<div class="aptx-domrow"><label class="aptx-domlabel" for="' + GENE_ANCHOR_SELECT + '">Anchor</label>');
18217
+ h = h.concat('<select name="' + GENE_ANCHOR_SELECT + '" id="' + GENE_ANCHOR_SELECT
18218
+ + '" title="the family the rows line up on: its gene sits at the same place in every genome, pointing'
18219
+ + ' right, and the neighbours follow (a genome with it on the other strand is mirrored). Auto picks the'
18220
+ + ' family single-copy in the most genomes; None left-aligns the rows"></select></div>');
18221
+ h = h.concat('<div class="aptx-domrow" id="' + GENE_FAMILY_SELECT + '_row"><label class="aptx-domlabel" for="'
18222
+ + GENE_FAMILY_SELECT + '">Family</label>');
18223
+ h = h.concat('<select name="' + GENE_FAMILY_SELECT + '" id="' + GENE_FAMILY_SELECT
18224
+ + '" title="which family annotation colours the genes and anchors the rows (a BV-BRC export carries'
18225
+ + ' both PGfams and PLfams)"></select></div>');
18226
+ h = h.concat('<div class="aptx-domrow"><label class="aptx-domlabel" for="' + GENE_LABELS_SELECT + '">Labels</label>');
18227
+ h = h.concat('<select name="' + GENE_LABELS_SELECT + '" id="' + GENE_LABELS_SELECT
18228
+ + '" title="where the gene symbols go: on the arrows (rectangular layout only), in a legend of the'
18229
+ + ' families, or nowhere">');
18230
+ h = h.concat('<option value="genes">On genes</option>');
18231
+ h = h.concat('<option value="legend">Legend</option>');
18232
+ h = h.concat('<option value="none">None</option>');
18233
+ h = h.concat('</select></div>');
18234
+ h = h.concat('</fieldset>');
18235
+ return h;
18236
+ }
18237
+
17378
18238
  // Shown only while the Heat Map toggle is on (syncHeatmapControls).
17379
18239
  // The desktop's View > Order Matrix Columns, minus its Manual mode --
17380
18240
  // that one means "the order you dragged the rows into", and there is
@@ -17621,6 +18481,7 @@ function (root, d3, forester, phyloXml) {
17621
18481
  setCheckboxValue(MSA_LOGO_CB, _state.showMsaLogo === true);
17622
18482
  setCheckboxValue(HEATMAP_CB, _state.showHeatmap);
17623
18483
  syncDomainControls();
18484
+ syncGeneControls();
17624
18485
  syncHeatmapControls();
17625
18486
  setCheckboxValue(SCALE_AXIS_CB, _state.showScaleAxis);
17626
18487
  setCheckboxValue(SCALE_GRID_CB, _state.showScaleGrid);
@@ -18083,7 +18944,8 @@ function (root, d3, forester, phyloXml) {
18083
18944
  // colour and shape legends, the domain legend, the heat-map ring's scale.
18084
18945
  // On screen they sit where the window has room; in a full-size figure the
18085
18946
  // window is gone, so they are stacked beside the tree instead.
18086
- const WINDOW_LEGEND_CLASSES = [LEGEND_LABEL_COLOR, LEGEND_NODE_SHAPE, LEGEND_DOMAINS, 'aptx-heatmap-ringlegend'];
18947
+ const WINDOW_LEGEND_CLASSES = [LEGEND_LABEL_COLOR, LEGEND_NODE_SHAPE, LEGEND_DOMAINS, LEGEND_GENES,
18948
+ 'aptx-heatmap-ringlegend'];
18087
18949
  const FULL_SIZE_LEGEND_STACK_GAP = 12;
18088
18950
 
18089
18951
  // The enlarged drawing, cropped to what is actually drawn: the tree