archaeopteryx 3.21.0 → 3.23.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/README.md +255 -9
- package/archaeopteryx.d.ts +24 -0
- package/archaeopteryx.js +1797 -38
- package/forester.js +762 -2
- package/package.json +2 -2
package/forester.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
// v 3.
|
|
23
|
+
// v 3.23.0
|
|
24
24
|
// 2026-09-17
|
|
25
25
|
//
|
|
26
26
|
// forester.js is a general suite for dealing with phylogenetic trees.
|
|
@@ -3828,6 +3828,10 @@
|
|
|
3828
3828
|
// one, and the longest (Lmax, the scale every track shares)
|
|
3829
3829
|
properties.domainArchitectures = false;
|
|
3830
3830
|
properties.maxDomainArchitectureLength = 0;
|
|
3831
|
+
// genome regions on the tips (genes as located sequences): whether
|
|
3832
|
+
// any tip carries one, and the longest span in base pairs
|
|
3833
|
+
properties.geneRegions = false;
|
|
3834
|
+
properties.maxGeneRegionLength = 0;
|
|
3831
3835
|
properties.externalNodesCount = 0;
|
|
3832
3836
|
properties.nodeCount = 0;
|
|
3833
3837
|
// Branches that carry a length AT ALL -- an explicit zero is a real
|
|
@@ -3918,6 +3922,17 @@
|
|
|
3918
3922
|
properties.maxDomainArchitectureLength = Number(da.length);
|
|
3919
3923
|
}
|
|
3920
3924
|
}
|
|
3925
|
+
// a tree carries genome regions when some tip carries
|
|
3926
|
+
// TWO genes or more: a gene-family tree whose tips each
|
|
3927
|
+
// locate their one gene is not a tree of genomes
|
|
3928
|
+
let gr = forester.geneRegionOf(n);
|
|
3929
|
+
if (gr && gr.genes.length >= 2) {
|
|
3930
|
+
properties.geneRegions = true;
|
|
3931
|
+
let L = gr.span.end - gr.span.start + 1;
|
|
3932
|
+
if (L > properties.maxGeneRegionLength) {
|
|
3933
|
+
properties.maxGeneRegionLength = L;
|
|
3934
|
+
}
|
|
3935
|
+
}
|
|
3921
3936
|
}
|
|
3922
3937
|
}
|
|
3923
3938
|
if (n.taxonomies && n.taxonomies.length > 0) {
|
|
@@ -4446,7 +4461,7 @@
|
|
|
4446
4461
|
support: [],
|
|
4447
4462
|
ultrametric: false,
|
|
4448
4463
|
tipsWithTaxonomy: 0, distinctTaxonomies: 0, tipsWithTaxonomyId: 0,
|
|
4449
|
-
tipsWithSequence: 0, tipsWithMolSeq: 0, tipsWithDomains: 0,
|
|
4464
|
+
tipsWithSequence: 0, tipsWithMolSeq: 0, tipsWithDomains: 0, tipsWithGenes: 0,
|
|
4450
4465
|
nodesWithDate: 0, tipsWithDistribution: 0, namedInternal: 0,
|
|
4451
4466
|
duplications: 0, speciations: 0, nodesWithEvents: 0,
|
|
4452
4467
|
propertyRefs: []
|
|
@@ -4566,6 +4581,9 @@
|
|
|
4566
4581
|
if (forester.domainArchitectureOf(n)) {
|
|
4567
4582
|
++stats.tipsWithDomains;
|
|
4568
4583
|
}
|
|
4584
|
+
if (forester.geneRegionOf(n)) {
|
|
4585
|
+
++stats.tipsWithGenes;
|
|
4586
|
+
}
|
|
4569
4587
|
}
|
|
4570
4588
|
if (n.distributions && n.distributions.length > 0) {
|
|
4571
4589
|
++stats.tipsWithDistribution;
|
|
@@ -7227,6 +7245,192 @@
|
|
|
7227
7245
|
return bothPicturesHaveDepth(root);
|
|
7228
7246
|
};
|
|
7229
7247
|
|
|
7248
|
+
// ---- the CLOCK PLOT ---------------------------------------------------
|
|
7249
|
+
// Every node as a point, its DATE against its DIVERGENCE from the root,
|
|
7250
|
+
// and a straight line through the tips: the slope is the rate the tips
|
|
7251
|
+
// accumulated divergence at, and where the line comes down to the root's
|
|
7252
|
+
// divergence is the date it puts the root on. TempEst's and TreeTime's
|
|
7253
|
+
// root-to-tip regression, Auspice's clock view. Christian, 2026-10-02:
|
|
7254
|
+
// a panel linked to the tree, named "Clock plot"; JS only until the
|
|
7255
|
+
// design is final.
|
|
7256
|
+
//
|
|
7257
|
+
// THE RULE, designed for two kinds of tree, the first of them built:
|
|
7258
|
+
//
|
|
7259
|
+
// 1. 'divergence' -- a tree with Time and Div (hasTimeAndDivergence): each
|
|
7260
|
+
// node states a date and a divergence from the root, recorded on it or
|
|
7261
|
+
// summed from each branch's time x its clock rate. Every node is a
|
|
7262
|
+
// point; which of the two layouts is on screen does not matter.
|
|
7263
|
+
// 2. 'root-to-tip' (NOT BUILT) -- a tree whose branch lengths are
|
|
7264
|
+
// divergence and whose tips are dated, with no dates on its ancestors
|
|
7265
|
+
// (it is not a time tree): a tip's divergence is the sum of the stated
|
|
7266
|
+
// lengths from the root. Tips only. This is the tree a clock plot is
|
|
7267
|
+
// wanted for most -- is there a signal worth dating? -- and the one
|
|
7268
|
+
// whose answer depends on its root.
|
|
7269
|
+
//
|
|
7270
|
+
// In both, the plot is offered when its LINE can be drawn: three tips or
|
|
7271
|
+
// more, and not all of them on one date. A tree sampled at one moment has
|
|
7272
|
+
// no slope to estimate. Asked of the tree, never of the view: in the view
|
|
7273
|
+
// of a clade too small or too uniform for a line the points are drawn and
|
|
7274
|
+
// the line is not.
|
|
7275
|
+
//
|
|
7276
|
+
// The FIT is ordinary least squares over the TIPS alone. A tip's date is
|
|
7277
|
+
// an observation; an ancestor's was inferred, usually with a clock, and
|
|
7278
|
+
// counting it would have the estimate confirm itself. The line is not
|
|
7279
|
+
// forced through the root, so the date it reaches the root's divergence
|
|
7280
|
+
// at can be held against the date the tree states for the root.
|
|
7281
|
+
const CLOCK_PLOT_MIN_TIPS = 3;
|
|
7282
|
+
|
|
7283
|
+
/**
|
|
7284
|
+
* Which clock plot the tree has, or null for none (see THE RULE above).
|
|
7285
|
+
*
|
|
7286
|
+
* @param phy the tree
|
|
7287
|
+
* @returns {string|null} 'divergence', or null
|
|
7288
|
+
*/
|
|
7289
|
+
forester.clockPlotKind = function (phy) {
|
|
7290
|
+
if (!phy || !forester.hasTimeAndDivergence(phy)) {
|
|
7291
|
+
return null;
|
|
7292
|
+
}
|
|
7293
|
+
let tips = 0;
|
|
7294
|
+
let first = null;
|
|
7295
|
+
let differ = false;
|
|
7296
|
+
forester.preOrderTraversalAll(forester.getTreeRoot(phy), function (n) {
|
|
7297
|
+
if (n.children && n.children.length > 0) {
|
|
7298
|
+
return;
|
|
7299
|
+
}
|
|
7300
|
+
++tips;
|
|
7301
|
+
let d = auspiceNodeDate(n);
|
|
7302
|
+
if (first === null) {
|
|
7303
|
+
first = d;
|
|
7304
|
+
} else if (d !== first) {
|
|
7305
|
+
differ = true;
|
|
7306
|
+
}
|
|
7307
|
+
});
|
|
7308
|
+
return (tips >= CLOCK_PLOT_MIN_TIPS && differ) ? 'divergence' : null;
|
|
7309
|
+
};
|
|
7310
|
+
|
|
7311
|
+
/**
|
|
7312
|
+
* Ordinary least squares of y on x, computed about the means: a date is
|
|
7313
|
+
* near 2020 and its spread a few months, and sums of raw squares would
|
|
7314
|
+
* spend every digit on the 2020.
|
|
7315
|
+
*
|
|
7316
|
+
* @param xs the dates
|
|
7317
|
+
* @param ys the divergences, as many
|
|
7318
|
+
* @returns {{n, slope, intercept, r2, meanX, meanY}|null} null with fewer
|
|
7319
|
+
* than three points or every x the same; r2 is null where every
|
|
7320
|
+
* y is the same (no variance to explain)
|
|
7321
|
+
*/
|
|
7322
|
+
forester.clockRegression = function (xs, ys) {
|
|
7323
|
+
let n = xs.length;
|
|
7324
|
+
if (n < CLOCK_PLOT_MIN_TIPS || ys.length !== n) {
|
|
7325
|
+
return null;
|
|
7326
|
+
}
|
|
7327
|
+
let mx = 0;
|
|
7328
|
+
let my = 0;
|
|
7329
|
+
for (let i = 0; i < n; ++i) {
|
|
7330
|
+
mx += xs[i];
|
|
7331
|
+
my += ys[i];
|
|
7332
|
+
}
|
|
7333
|
+
mx /= n;
|
|
7334
|
+
my /= n;
|
|
7335
|
+
let sxx = 0;
|
|
7336
|
+
let sxy = 0;
|
|
7337
|
+
let syy = 0;
|
|
7338
|
+
for (let i = 0; i < n; ++i) {
|
|
7339
|
+
let dx = xs[i] - mx;
|
|
7340
|
+
let dy = ys[i] - my;
|
|
7341
|
+
sxx += dx * dx;
|
|
7342
|
+
sxy += dx * dy;
|
|
7343
|
+
syy += dy * dy;
|
|
7344
|
+
}
|
|
7345
|
+
if (!(sxx > 0) || !isFinite(sxx) || !isFinite(sxy) || !isFinite(syy)) {
|
|
7346
|
+
return null;
|
|
7347
|
+
}
|
|
7348
|
+
let slope = sxy / sxx;
|
|
7349
|
+
return {
|
|
7350
|
+
n: n,
|
|
7351
|
+
slope: slope,
|
|
7352
|
+
intercept: my - (slope * mx),
|
|
7353
|
+
r2: syy > 0 ? ((sxy * sxy) / (sxx * syy)) : null,
|
|
7354
|
+
meanX: mx,
|
|
7355
|
+
meanY: my
|
|
7356
|
+
};
|
|
7357
|
+
};
|
|
7358
|
+
|
|
7359
|
+
/**
|
|
7360
|
+
* The clock plot of the tree, or of the clade on view.
|
|
7361
|
+
*
|
|
7362
|
+
* @param phy the tree: the RULE is asked of it
|
|
7363
|
+
* @param view the tree or clade to plot (the tree, a node, or the
|
|
7364
|
+
* viewer's subtree root); the whole tree where none is given
|
|
7365
|
+
* @returns {object|null} null where the tree has no clock plot; else
|
|
7366
|
+
* kind 'divergence'
|
|
7367
|
+
* forward whether the dates increase toward the tips (calendar
|
|
7368
|
+
* dates); false for ages
|
|
7369
|
+
* unit the dates' unit as the tree states it, or null
|
|
7370
|
+
* fromRates whether the divergence is time x clock rate
|
|
7371
|
+
* points [{node, date, div, tip}] in preorder; the tips'
|
|
7372
|
+
* `residual` is their divergence less the line's
|
|
7373
|
+
* root {node, date, div}: the top of the view
|
|
7374
|
+
* fit forester.clockRegression over the tips, with
|
|
7375
|
+
* rate the slope in the direction time runs: divergence
|
|
7376
|
+
* per unit of time (negative where the tips'
|
|
7377
|
+
* divergence falls with time)
|
|
7378
|
+
* rootDate the date the line reaches the root's divergence
|
|
7379
|
+
* at; null unless the rate is positive
|
|
7380
|
+
* or null where the view has no line
|
|
7381
|
+
*/
|
|
7382
|
+
forester.clockPlotData = function (phy, view) {
|
|
7383
|
+
let kind = forester.clockPlotKind(phy);
|
|
7384
|
+
if (kind === null) {
|
|
7385
|
+
return null;
|
|
7386
|
+
}
|
|
7387
|
+
let treeRoot = forester.getTreeRoot(phy);
|
|
7388
|
+
// a node is its own top; the tree, or the viewer's holder of a clade,
|
|
7389
|
+
// is a parentless wrapper around it
|
|
7390
|
+
let top = treeRoot;
|
|
7391
|
+
if (view) {
|
|
7392
|
+
top = (!view.parent && view.children && view.children.length === 1) ? view.children[0] : view;
|
|
7393
|
+
}
|
|
7394
|
+
let points = [];
|
|
7395
|
+
let xs = [];
|
|
7396
|
+
let ys = [];
|
|
7397
|
+
forester.preOrderTraversalAll(top, function (n) {
|
|
7398
|
+
let date = auspiceNodeDate(n);
|
|
7399
|
+
let div = auspiceNodeDiv(n);
|
|
7400
|
+
if (date === null || div === null) {
|
|
7401
|
+
return; // not on a tree with Time and Div; a guard, not a rule
|
|
7402
|
+
}
|
|
7403
|
+
let tip = !(n.children && n.children.length > 0);
|
|
7404
|
+
points.push({node: n, date: date, div: div, tip: tip});
|
|
7405
|
+
if (tip) {
|
|
7406
|
+
xs.push(date);
|
|
7407
|
+
ys.push(div);
|
|
7408
|
+
}
|
|
7409
|
+
});
|
|
7410
|
+
let forward = timeIncreasesTowardTips(treeRoot);
|
|
7411
|
+
let root = {node: top, date: auspiceNodeDate(top), div: auspiceNodeDiv(top)};
|
|
7412
|
+
let fit = forester.clockRegression(xs, ys);
|
|
7413
|
+
if (fit) {
|
|
7414
|
+
fit.rate = forward ? fit.slope : -fit.slope;
|
|
7415
|
+
fit.rootDate = (fit.rate > 0 && root.div !== null)
|
|
7416
|
+
? ((root.div - fit.intercept) / fit.slope) : null;
|
|
7417
|
+
for (let i = 0; i < points.length; ++i) {
|
|
7418
|
+
if (points[i].tip) {
|
|
7419
|
+
points[i].residual = points[i].div - (fit.intercept + (fit.slope * points[i].date));
|
|
7420
|
+
}
|
|
7421
|
+
}
|
|
7422
|
+
}
|
|
7423
|
+
return {
|
|
7424
|
+
kind: kind,
|
|
7425
|
+
forward: forward,
|
|
7426
|
+
unit: dateUnitOf(treeRoot),
|
|
7427
|
+
fromRates: treeRoot._divergenceFromRates === true && !recordsDivergence(treeRoot),
|
|
7428
|
+
points: points,
|
|
7429
|
+
root: root,
|
|
7430
|
+
fit: fit
|
|
7431
|
+
};
|
|
7432
|
+
};
|
|
7433
|
+
|
|
7230
7434
|
// A number that is not NaN. It used to test only for null, undefined and
|
|
7231
7435
|
// NaN, and so answered TRUE for "hello", "", {}, [] and true -- which no
|
|
7232
7436
|
// caller was hurt by, since all of them pass the result of parseFloat, but
|
|
@@ -7943,6 +8147,527 @@
|
|
|
7943
8147
|
return {backbone: {x: start, w: Number(da.length) * f}, boxes: boxes};
|
|
7944
8148
|
};
|
|
7945
8149
|
|
|
8150
|
+
// --------------------------------------------------------------
|
|
8151
|
+
// Genome regions (the gene track)
|
|
8152
|
+
// --------------------------------------------------------------
|
|
8153
|
+
// A tip that is a genome can carry (part of) that genome as phyloXML
|
|
8154
|
+
// <sequence> elements: one per gene, each with a <location> on a contig
|
|
8155
|
+
// and its family in an <annotation ref="...">, plus one record per
|
|
8156
|
+
// contig. The viewer draws them as arrows on a backbone beside the tip,
|
|
8157
|
+
// the rows anchored on one family so gene order compares across genomes
|
|
8158
|
+
// (Christian, 2026-10-01: the encoding, the location grammar, anchoring
|
|
8159
|
+
// by default, the words "Genes" and "Genome regions"). Nothing in the
|
|
8160
|
+
// schema changes: <location> is phyloXML's "location of a sequence on a
|
|
8161
|
+
// genome/chromosome", an annotation's ref its "colon-separated reference
|
|
8162
|
+
// to an external database".
|
|
8163
|
+
//
|
|
8164
|
+
// Everything that can be tested without a DOM lives here: the grammar,
|
|
8165
|
+
// which sequences are genes and which the contig record, the anchor, the
|
|
8166
|
+
// mapping of every row onto one shared coordinate (flips, lanes, contig
|
|
8167
|
+
// ends), the arrow outline, the families, the legend rows and the links.
|
|
8168
|
+
// test/gene_test.js pins the numbers. The SVG and the controls are
|
|
8169
|
+
// archaeopteryx.js's.
|
|
8170
|
+
|
|
8171
|
+
// contig:start-end(+) -- 1-based, inclusive, the strand optional. Split
|
|
8172
|
+
// on the LAST colon so a contig id with a colon of its own survives.
|
|
8173
|
+
// A located sequence WITH a strand is a gene; one WITHOUT is the record
|
|
8174
|
+
// of the contig itself (its accession, name and length), which is how
|
|
8175
|
+
// the drawing knows where a contig ends.
|
|
8176
|
+
const SEQUENCE_LOCATION_RE = /^(.+):(\d+)-(\d+)(?:\(([+-])\))?$/;
|
|
8177
|
+
|
|
8178
|
+
forester.parseSequenceLocation = function (text) {
|
|
8179
|
+
if (typeof text !== 'string') {
|
|
8180
|
+
return null;
|
|
8181
|
+
}
|
|
8182
|
+
let m = SEQUENCE_LOCATION_RE.exec(text.trim());
|
|
8183
|
+
if (!m) {
|
|
8184
|
+
return null;
|
|
8185
|
+
}
|
|
8186
|
+
let start = Number(m[2]);
|
|
8187
|
+
let end = Number(m[3]);
|
|
8188
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 1 || end < start) {
|
|
8189
|
+
return null;
|
|
8190
|
+
}
|
|
8191
|
+
let contig = m[1].trim();
|
|
8192
|
+
if (contig.length === 0) {
|
|
8193
|
+
return null;
|
|
8194
|
+
}
|
|
8195
|
+
return {contig: contig, start: start, end: end, strand: m[4] || null};
|
|
8196
|
+
};
|
|
8197
|
+
|
|
8198
|
+
// The namespace of an annotation ref: "pgfam" of "pgfam:PGF_00019355".
|
|
8199
|
+
// A ref without a colon has none and names no family.
|
|
8200
|
+
forester.annotationNamespace = function (ref) {
|
|
8201
|
+
if (typeof ref !== 'string') {
|
|
8202
|
+
return null;
|
|
8203
|
+
}
|
|
8204
|
+
let c = ref.indexOf(':');
|
|
8205
|
+
return c > 0 ? ref.substring(0, c) : null;
|
|
8206
|
+
};
|
|
8207
|
+
|
|
8208
|
+
function textOf(v) {
|
|
8209
|
+
return (v === null || v === undefined) ? '' : String(v);
|
|
8210
|
+
}
|
|
8211
|
+
|
|
8212
|
+
// Every located sequence of a node, sorted into genes, contig records
|
|
8213
|
+
// and the ones whose location is not in the grammar (counted, never
|
|
8214
|
+
// fatal: <location> is free text in phyloXML, and an older file may say
|
|
8215
|
+
// "chromosome 3" there).
|
|
8216
|
+
function locatedSequences(node) {
|
|
8217
|
+
let out = {genes: [], records: [], ignored: 0};
|
|
8218
|
+
if (!node.sequences) {
|
|
8219
|
+
return out;
|
|
8220
|
+
}
|
|
8221
|
+
node.sequences.forEach(function (s, i) {
|
|
8222
|
+
if (!s || s.location === undefined || s.location === null || s.location === '') {
|
|
8223
|
+
return;
|
|
8224
|
+
}
|
|
8225
|
+
let loc = forester.parseSequenceLocation(s.location);
|
|
8226
|
+
if (!loc) {
|
|
8227
|
+
out.ignored++;
|
|
8228
|
+
return;
|
|
8229
|
+
}
|
|
8230
|
+
let acc = (s.accession && s.accession.value !== undefined && s.accession.value !== null
|
|
8231
|
+
&& s.accession.value !== '')
|
|
8232
|
+
? {source: textOf(s.accession.source), value: textOf(s.accession.value)} : null;
|
|
8233
|
+
if (loc.strand === null) {
|
|
8234
|
+
out.records.push({contig: loc.contig, start: loc.start, end: loc.end, accession: acc,
|
|
8235
|
+
name: textOf(s.name)});
|
|
8236
|
+
return;
|
|
8237
|
+
}
|
|
8238
|
+
let refs = [];
|
|
8239
|
+
(s.annotations || []).forEach(function (a) {
|
|
8240
|
+
if (a && forester.annotationNamespace(a.ref) !== null) {
|
|
8241
|
+
refs.push(a.ref);
|
|
8242
|
+
}
|
|
8243
|
+
});
|
|
8244
|
+
out.genes.push({index: i, contig: loc.contig, start: loc.start, end: loc.end, strand: loc.strand,
|
|
8245
|
+
length: loc.end - loc.start + 1, symbol: textOf(s.symbol), geneName: textOf(s.gene_name),
|
|
8246
|
+
product: textOf(s.name), accession: acc, refs: refs});
|
|
8247
|
+
});
|
|
8248
|
+
return out;
|
|
8249
|
+
}
|
|
8250
|
+
|
|
8251
|
+
// The region a tip carries: its genes on ONE contig -- the contig with
|
|
8252
|
+
// the most genes (ties: the first listed) -- in position order, with
|
|
8253
|
+
// that contig's record when there is one, the span of the genes, and
|
|
8254
|
+
// how many genes on other contigs were left out. Null for a tip with no
|
|
8255
|
+
// genes (a record alone is not a region).
|
|
8256
|
+
forester.geneRegionOf = function (node) {
|
|
8257
|
+
let found = locatedSequences(node);
|
|
8258
|
+
if (found.genes.length === 0) {
|
|
8259
|
+
return null;
|
|
8260
|
+
}
|
|
8261
|
+
let counts = new Map();
|
|
8262
|
+
found.genes.forEach(function (g) {
|
|
8263
|
+
counts.set(g.contig, (counts.get(g.contig) || 0) + 1);
|
|
8264
|
+
});
|
|
8265
|
+
let contig = null;
|
|
8266
|
+
let best = 0;
|
|
8267
|
+
counts.forEach(function (n, c) {
|
|
8268
|
+
if (n > best) {
|
|
8269
|
+
best = n;
|
|
8270
|
+
contig = c;
|
|
8271
|
+
}
|
|
8272
|
+
});
|
|
8273
|
+
let genes = found.genes.filter(function (g) {
|
|
8274
|
+
return g.contig === contig;
|
|
8275
|
+
}).sort(function (a, b) {
|
|
8276
|
+
return (a.start - b.start) || (a.end - b.end) || (a.index - b.index);
|
|
8277
|
+
});
|
|
8278
|
+
let record = null;
|
|
8279
|
+
for (let i = 0; i < found.records.length; ++i) {
|
|
8280
|
+
if (found.records[i].contig === contig) {
|
|
8281
|
+
record = found.records[i];
|
|
8282
|
+
break;
|
|
8283
|
+
}
|
|
8284
|
+
}
|
|
8285
|
+
let span = {start: genes[0].start, end: genes[0].end};
|
|
8286
|
+
genes.forEach(function (g) {
|
|
8287
|
+
if (g.end > span.end) {
|
|
8288
|
+
span.end = g.end;
|
|
8289
|
+
}
|
|
8290
|
+
});
|
|
8291
|
+
return {contig: contig, genes: genes, record: record, span: span,
|
|
8292
|
+
otherContigs: found.genes.length - genes.length, contigs: counts.size};
|
|
8293
|
+
};
|
|
8294
|
+
|
|
8295
|
+
// The tree's gene facts: tips with a region, genes drawn, genes on other
|
|
8296
|
+
// contigs left out, the longest span, and locations not in the grammar.
|
|
8297
|
+
forester.geneRegionStats = function (tree) {
|
|
8298
|
+
let stats = {tips: 0, genes: 0, otherContigs: 0, maxSpan: 0, ignored: 0};
|
|
8299
|
+
forester.preOrderTraversalAll(tree, function (n) {
|
|
8300
|
+
if (n.children) {
|
|
8301
|
+
return;
|
|
8302
|
+
}
|
|
8303
|
+
stats.ignored += locatedSequences(n).ignored;
|
|
8304
|
+
let r = forester.geneRegionOf(n);
|
|
8305
|
+
if (!r) {
|
|
8306
|
+
return;
|
|
8307
|
+
}
|
|
8308
|
+
stats.tips++;
|
|
8309
|
+
stats.genes += r.genes.length;
|
|
8310
|
+
stats.otherContigs += r.otherContigs;
|
|
8311
|
+
let L = r.span.end - r.span.start + 1;
|
|
8312
|
+
if (L > stats.maxSpan) {
|
|
8313
|
+
stats.maxSpan = L;
|
|
8314
|
+
}
|
|
8315
|
+
});
|
|
8316
|
+
return stats;
|
|
8317
|
+
};
|
|
8318
|
+
|
|
8319
|
+
// The family namespaces the genes carry, most-used first (ties by code
|
|
8320
|
+
// unit): a BV-BRC export has both pgfam and plfam on every gene, and the
|
|
8321
|
+
// track colours by ONE of them.
|
|
8322
|
+
forester.geneFamilyNamespaces = function (regions) {
|
|
8323
|
+
let counts = new Map();
|
|
8324
|
+
regions.forEach(function (r) {
|
|
8325
|
+
if (!r) {
|
|
8326
|
+
return;
|
|
8327
|
+
}
|
|
8328
|
+
r.genes.forEach(function (g) {
|
|
8329
|
+
let seen = new Set();
|
|
8330
|
+
g.refs.forEach(function (ref) {
|
|
8331
|
+
let ns = forester.annotationNamespace(ref);
|
|
8332
|
+
if (ns !== null && !seen.has(ns)) {
|
|
8333
|
+
seen.add(ns);
|
|
8334
|
+
counts.set(ns, (counts.get(ns) || 0) + 1);
|
|
8335
|
+
}
|
|
8336
|
+
});
|
|
8337
|
+
});
|
|
8338
|
+
});
|
|
8339
|
+
let out = [];
|
|
8340
|
+
counts.forEach(function (genes, ns) {
|
|
8341
|
+
out.push({ns: ns, genes: genes});
|
|
8342
|
+
});
|
|
8343
|
+
out.sort(function (a, b) {
|
|
8344
|
+
return (b.genes - a.genes) || (a.ns < b.ns ? -1 : (a.ns > b.ns ? 1 : 0));
|
|
8345
|
+
});
|
|
8346
|
+
return out;
|
|
8347
|
+
};
|
|
8348
|
+
|
|
8349
|
+
// A gene's family in a namespace: its first annotation ref there, in
|
|
8350
|
+
// full ("pgfam:PGF_00019355"), or null.
|
|
8351
|
+
forester.geneFamilyOf = function (gene, ns) {
|
|
8352
|
+
if (!ns) {
|
|
8353
|
+
return null;
|
|
8354
|
+
}
|
|
8355
|
+
for (let i = 0; i < gene.refs.length; ++i) {
|
|
8356
|
+
if (forester.annotationNamespace(gene.refs[i]) === ns) {
|
|
8357
|
+
return gene.refs[i];
|
|
8358
|
+
}
|
|
8359
|
+
}
|
|
8360
|
+
return null;
|
|
8361
|
+
};
|
|
8362
|
+
|
|
8363
|
+
// Which families could anchor the rows: for each, the regions carrying
|
|
8364
|
+
// it exactly once (where it CAN anchor) and the regions carrying it at
|
|
8365
|
+
// all; most single-copy regions first, ties by code unit. A product and
|
|
8366
|
+
// symbol ride along for the control's labels.
|
|
8367
|
+
forester.geneAnchorCandidates = function (regions, ns) {
|
|
8368
|
+
let fams = new Map();
|
|
8369
|
+
regions.forEach(function (r) {
|
|
8370
|
+
if (!r) {
|
|
8371
|
+
return;
|
|
8372
|
+
}
|
|
8373
|
+
let here = new Map();
|
|
8374
|
+
r.genes.forEach(function (g) {
|
|
8375
|
+
let fam = forester.geneFamilyOf(g, ns);
|
|
8376
|
+
if (fam === null) {
|
|
8377
|
+
return;
|
|
8378
|
+
}
|
|
8379
|
+
here.set(fam, (here.get(fam) || 0) + 1);
|
|
8380
|
+
if (!fams.has(fam)) {
|
|
8381
|
+
fams.set(fam, {family: fam, single: 0, tips: 0, product: g.product, symbol: g.symbol});
|
|
8382
|
+
}
|
|
8383
|
+
});
|
|
8384
|
+
here.forEach(function (n, fam) {
|
|
8385
|
+
let f = fams.get(fam);
|
|
8386
|
+
f.tips++;
|
|
8387
|
+
if (n === 1) {
|
|
8388
|
+
f.single++;
|
|
8389
|
+
}
|
|
8390
|
+
});
|
|
8391
|
+
});
|
|
8392
|
+
let out = [];
|
|
8393
|
+
fams.forEach(function (f) {
|
|
8394
|
+
out.push(f);
|
|
8395
|
+
});
|
|
8396
|
+
out.sort(function (a, b) {
|
|
8397
|
+
return (b.single - a.single) || (a.family < b.family ? -1 : (a.family > b.family ? 1 : 0));
|
|
8398
|
+
});
|
|
8399
|
+
return out;
|
|
8400
|
+
};
|
|
8401
|
+
|
|
8402
|
+
// The anchor a tree opens with: the family single-copy in the most
|
|
8403
|
+
// regions, provided that is at least half of them. Otherwise none, and
|
|
8404
|
+
// the rows are left-aligned.
|
|
8405
|
+
forester.geneDefaultAnchor = function (regions, ns) {
|
|
8406
|
+
let n = 0;
|
|
8407
|
+
regions.forEach(function (r) {
|
|
8408
|
+
if (r) {
|
|
8409
|
+
n++;
|
|
8410
|
+
}
|
|
8411
|
+
});
|
|
8412
|
+
let c = forester.geneAnchorCandidates(regions, ns);
|
|
8413
|
+
return (c.length > 0 && c[0].single > 0 && c[0].single * 2 >= n) ? c[0].family : null;
|
|
8414
|
+
};
|
|
8415
|
+
|
|
8416
|
+
// The backbone reaches this far past the first and last gene, as a share
|
|
8417
|
+
// of the span -- unless the contig record says the contig ends first.
|
|
8418
|
+
forester.GENE_REGION_PAD = 0.02;
|
|
8419
|
+
|
|
8420
|
+
// Every row onto one shared coordinate, in base pairs. A row whose
|
|
8421
|
+
// anchor family occurs exactly once puts that gene's midpoint at 0 and,
|
|
8422
|
+
// when the gene is on the minus strand, is MIRRORED so the anchor points
|
|
8423
|
+
// right and the neighbours flip with it (the Compare Region convention).
|
|
8424
|
+
// Any other row -- no anchor, no such gene, two of them -- is left-
|
|
8425
|
+
// aligned at the leftmost edge of the anchored rows (at 0 when there are
|
|
8426
|
+
// none). Overlapping genes take a second lane. `regions` is per
|
|
8427
|
+
// displayed tip, in display order, null where a tip has none; the result
|
|
8428
|
+
// has a row (or null) per entry and the extent over all rows.
|
|
8429
|
+
forester.geneTrackRows = function (regions, anchor, ns) {
|
|
8430
|
+
let rows = regions.map(function (region) {
|
|
8431
|
+
if (!region) {
|
|
8432
|
+
return null;
|
|
8433
|
+
}
|
|
8434
|
+
let span = region.span;
|
|
8435
|
+
let pad = forester.GENE_REGION_PAD * (span.end - span.start + 1);
|
|
8436
|
+
let b0 = span.start - pad;
|
|
8437
|
+
let b1 = span.end + pad;
|
|
8438
|
+
let capStart = false;
|
|
8439
|
+
let capEnd = false;
|
|
8440
|
+
if (region.record) {
|
|
8441
|
+
if (b0 <= region.record.start) {
|
|
8442
|
+
b0 = region.record.start;
|
|
8443
|
+
capStart = true;
|
|
8444
|
+
}
|
|
8445
|
+
if (b1 >= region.record.end) {
|
|
8446
|
+
b1 = region.record.end;
|
|
8447
|
+
capEnd = true;
|
|
8448
|
+
}
|
|
8449
|
+
}
|
|
8450
|
+
let anchors = anchor ? region.genes.filter(function (g) {
|
|
8451
|
+
return forester.geneFamilyOf(g, ns) === anchor;
|
|
8452
|
+
}) : [];
|
|
8453
|
+
let anchored = anchors.length === 1;
|
|
8454
|
+
let flip = anchored && anchors[0].strand === '-';
|
|
8455
|
+
return {region: region, b0: b0, b1: b1, capStart: capStart, capEnd: capEnd, anchored: anchored, flip: flip,
|
|
8456
|
+
origin: anchored ? (anchors[0].start + anchors[0].end) / 2 : null};
|
|
8457
|
+
});
|
|
8458
|
+
let min = Infinity;
|
|
8459
|
+
let max = -Infinity;
|
|
8460
|
+
let place = function (row) {
|
|
8461
|
+
let o = row.origin;
|
|
8462
|
+
let map = row.flip
|
|
8463
|
+
? function (a, b) {
|
|
8464
|
+
return [o - b, o - a];
|
|
8465
|
+
}
|
|
8466
|
+
: function (a, b) {
|
|
8467
|
+
return [a - o, b - o];
|
|
8468
|
+
};
|
|
8469
|
+
let bb = map(row.b0, row.b1);
|
|
8470
|
+
let genes = row.region.genes.map(function (g) {
|
|
8471
|
+
let x = map(g.start, g.end);
|
|
8472
|
+
let dir = g.strand === '+' ? 1 : -1;
|
|
8473
|
+
return {gene: g, x0: x[0], x1: x[1], dir: row.flip ? -dir : dir, lane: 0};
|
|
8474
|
+
});
|
|
8475
|
+
genes.sort(function (p, q) {
|
|
8476
|
+
return (p.x0 - q.x0) || (p.x1 - q.x1);
|
|
8477
|
+
});
|
|
8478
|
+
// greedy lanes: the first lane that is free, else the one that
|
|
8479
|
+
// frees first; two lanes at most
|
|
8480
|
+
let ends = [-Infinity, -Infinity];
|
|
8481
|
+
genes.forEach(function (g) {
|
|
8482
|
+
let lane = g.x0 >= ends[0] ? 0 : (g.x0 >= ends[1] ? 1 : (ends[0] <= ends[1] ? 0 : 1));
|
|
8483
|
+
g.lane = lane;
|
|
8484
|
+
ends[lane] = Math.max(ends[lane], g.x1);
|
|
8485
|
+
});
|
|
8486
|
+
if (bb[0] < min) {
|
|
8487
|
+
min = bb[0];
|
|
8488
|
+
}
|
|
8489
|
+
if (bb[1] > max) {
|
|
8490
|
+
max = bb[1];
|
|
8491
|
+
}
|
|
8492
|
+
return {backbone: {x0: bb[0], x1: bb[1]}, capLeft: row.flip ? row.capEnd : row.capStart,
|
|
8493
|
+
capRight: row.flip ? row.capStart : row.capEnd, anchored: row.anchored, flip: row.flip,
|
|
8494
|
+
lanes: genes.some(function (g) {
|
|
8495
|
+
return g.lane === 1;
|
|
8496
|
+
}) ? 2 : 1, genes: genes, region: row.region};
|
|
8497
|
+
};
|
|
8498
|
+
let out = new Array(rows.length);
|
|
8499
|
+
rows.forEach(function (row, i) {
|
|
8500
|
+
if (row && row.anchored) {
|
|
8501
|
+
out[i] = place(row);
|
|
8502
|
+
}
|
|
8503
|
+
});
|
|
8504
|
+
let left = isFinite(min) ? min : 0;
|
|
8505
|
+
rows.forEach(function (row, i) {
|
|
8506
|
+
if (row && !row.anchored) {
|
|
8507
|
+
row.origin = row.b0 - left; // its backbone starts at the left edge
|
|
8508
|
+
out[i] = place(row);
|
|
8509
|
+
} else if (!row) {
|
|
8510
|
+
out[i] = null;
|
|
8511
|
+
}
|
|
8512
|
+
});
|
|
8513
|
+
return {rows: out, extent: isFinite(min) ? {min: min, max: max} : null};
|
|
8514
|
+
};
|
|
8515
|
+
|
|
8516
|
+
// One row in pixels: the backbone and each gene's box, given the track's
|
|
8517
|
+
// start x, the px per base pair f and the extent's left edge.
|
|
8518
|
+
forester.geneRowGeometry = function (row, start, f, min) {
|
|
8519
|
+
let px = function (x) {
|
|
8520
|
+
return start + ((x - min) * f);
|
|
8521
|
+
};
|
|
8522
|
+
return {
|
|
8523
|
+
backbone: {x: px(row.backbone.x0), w: (row.backbone.x1 - row.backbone.x0) * f},
|
|
8524
|
+
capLeft: row.capLeft,
|
|
8525
|
+
capRight: row.capRight,
|
|
8526
|
+
genes: row.genes.map(function (g) {
|
|
8527
|
+
return {x: px(g.x0), w: (g.x1 - g.x0) * f, dir: g.dir, lane: g.lane, gene: g.gene};
|
|
8528
|
+
})
|
|
8529
|
+
};
|
|
8530
|
+
};
|
|
8531
|
+
|
|
8532
|
+
// The arrow: a box whose leading end tapers to a point in the strand's
|
|
8533
|
+
// direction, the head half the height long; a gene shorter than the head
|
|
8534
|
+
// is a triangle and still shows its direction. Path data to a hundredth
|
|
8535
|
+
// of a pixel; '' for a degenerate box.
|
|
8536
|
+
forester.geneArrowPath = function (x, y, w, h, dir) {
|
|
8537
|
+
if (!isFinite(x) || !isFinite(y) || !(w > 0) || !isFinite(w) || !(h > 0) || !isFinite(h)) {
|
|
8538
|
+
return '';
|
|
8539
|
+
}
|
|
8540
|
+
let num = function (n) {
|
|
8541
|
+
return Math.round(n * 100) / 100;
|
|
8542
|
+
};
|
|
8543
|
+
let head = Math.min(h / 2, w);
|
|
8544
|
+
let body = w - head;
|
|
8545
|
+
let hh = h / 2;
|
|
8546
|
+
if (dir < 0) {
|
|
8547
|
+
// the point on the left
|
|
8548
|
+
return 'M' + num(x + w) + ',' + num(y)
|
|
8549
|
+
+ (body > 0 ? 'h' + num(-body) : '')
|
|
8550
|
+
+ 'l' + num(-head) + ',' + num(hh)
|
|
8551
|
+
+ 'l' + num(head) + ',' + num(hh)
|
|
8552
|
+
+ (body > 0 ? 'h' + num(body) : '') + 'Z';
|
|
8553
|
+
}
|
|
8554
|
+
return 'M' + num(x) + ',' + num(y)
|
|
8555
|
+
+ (body > 0 ? 'h' + num(body) : '')
|
|
8556
|
+
+ 'l' + num(head) + ',' + num(hh)
|
|
8557
|
+
+ 'l' + num(-head) + ',' + num(hh)
|
|
8558
|
+
+ (body > 0 ? 'h' + num(-body) : '') + 'Z';
|
|
8559
|
+
};
|
|
8560
|
+
|
|
8561
|
+
forester.GENE_SINGLETON_COLOR = '#9e9e9e'; // a family met in one region only
|
|
8562
|
+
forester.GENE_NO_FAMILY_COLOR = '#cfcfcf'; // a gene with no family at all
|
|
8563
|
+
|
|
8564
|
+
// The families over the regions given (display order): the SHARED ones
|
|
8565
|
+
// -- in two regions or more, or, when `shared` (a Set of family refs) is
|
|
8566
|
+
// given, the ones in it -- sorted by code unit (the palette's order),
|
|
8567
|
+
// the legend rows in first-appearance order with each family's region
|
|
8568
|
+
// and gene counts and its commonest product and symbol, and how many
|
|
8569
|
+
// drawn genes are singletons or carry no family. The viewer passes the
|
|
8570
|
+
// TREE's shared families as `shared` when it summarizes a view, so a
|
|
8571
|
+
// family shown once in a subtree keeps the colour it has on the tree.
|
|
8572
|
+
forester.geneFamilySummary = function (regions, ns, shared) {
|
|
8573
|
+
let fams = new Map();
|
|
8574
|
+
let singles = 0;
|
|
8575
|
+
let unfamilied = 0;
|
|
8576
|
+
regions.forEach(function (r) {
|
|
8577
|
+
if (!r) {
|
|
8578
|
+
return;
|
|
8579
|
+
}
|
|
8580
|
+
let here = new Set();
|
|
8581
|
+
r.genes.forEach(function (g) {
|
|
8582
|
+
let fam = forester.geneFamilyOf(g, ns);
|
|
8583
|
+
if (fam === null) {
|
|
8584
|
+
unfamilied++;
|
|
8585
|
+
return;
|
|
8586
|
+
}
|
|
8587
|
+
let f = fams.get(fam);
|
|
8588
|
+
if (!f) {
|
|
8589
|
+
f = {family: fam, tips: 0, genes: 0, products: new Map(), symbols: new Map()};
|
|
8590
|
+
fams.set(fam, f);
|
|
8591
|
+
}
|
|
8592
|
+
f.genes++;
|
|
8593
|
+
if (g.product) {
|
|
8594
|
+
f.products.set(g.product, (f.products.get(g.product) || 0) + 1);
|
|
8595
|
+
}
|
|
8596
|
+
if (g.symbol) {
|
|
8597
|
+
f.symbols.set(g.symbol, (f.symbols.get(g.symbol) || 0) + 1);
|
|
8598
|
+
}
|
|
8599
|
+
here.add(fam);
|
|
8600
|
+
});
|
|
8601
|
+
here.forEach(function (fam) {
|
|
8602
|
+
fams.get(fam).tips++;
|
|
8603
|
+
});
|
|
8604
|
+
});
|
|
8605
|
+
let commonest = function (m) {
|
|
8606
|
+
let best = '';
|
|
8607
|
+
let n = 0;
|
|
8608
|
+
m.forEach(function (count, text) {
|
|
8609
|
+
if (count > n) {
|
|
8610
|
+
n = count;
|
|
8611
|
+
best = text;
|
|
8612
|
+
}
|
|
8613
|
+
});
|
|
8614
|
+
return best;
|
|
8615
|
+
};
|
|
8616
|
+
let legend = [];
|
|
8617
|
+
let names = [];
|
|
8618
|
+
fams.forEach(function (f) {
|
|
8619
|
+
if (shared ? !shared.has(f.family) : f.tips < 2) {
|
|
8620
|
+
singles += f.genes;
|
|
8621
|
+
return;
|
|
8622
|
+
}
|
|
8623
|
+
names.push(f.family);
|
|
8624
|
+
legend.push({family: f.family, tips: f.tips, genes: f.genes,
|
|
8625
|
+
product: commonest(f.products), symbol: commonest(f.symbols)});
|
|
8626
|
+
});
|
|
8627
|
+
names.sort();
|
|
8628
|
+
return {names: names, legend: legend, singletons: singles, unfamilied: unfamilied};
|
|
8629
|
+
};
|
|
8630
|
+
|
|
8631
|
+
// Where a click on a gene goes: its BV-BRC feature page for a BV-BRC /
|
|
8632
|
+
// PATRIC accession (fig|...), NCBI's protein page for a GenBank / RefSeq
|
|
8633
|
+
// one; nowhere otherwise.
|
|
8634
|
+
const BVBRC_FEATURE = 'https://www.bv-brc.org/view/Feature/';
|
|
8635
|
+
const NCBI_PROTEIN = 'https://www.ncbi.nlm.nih.gov/protein/';
|
|
8636
|
+
|
|
8637
|
+
forester.geneReference = function (gene) {
|
|
8638
|
+
if (!gene || !gene.accession) {
|
|
8639
|
+
return null;
|
|
8640
|
+
}
|
|
8641
|
+
let v = gene.accession.value.trim();
|
|
8642
|
+
let src = gene.accession.source.toLowerCase();
|
|
8643
|
+
if (v.length === 0) {
|
|
8644
|
+
return null;
|
|
8645
|
+
}
|
|
8646
|
+
if (/^fig\|/.test(v) || /bv-?brc|patric/.test(src)) {
|
|
8647
|
+
return {url: BVBRC_FEATURE + encodeURIComponent(v).replace(/%7C/gi, '|'), site: 'BV-BRC'};
|
|
8648
|
+
}
|
|
8649
|
+
if (/ncbi|genbank|refseq|uniprot/.test(src)) {
|
|
8650
|
+
return {url: NCBI_PROTEIN + encodeURIComponent(v), site: 'NCBI'};
|
|
8651
|
+
}
|
|
8652
|
+
return null;
|
|
8653
|
+
};
|
|
8654
|
+
|
|
8655
|
+
// The track's own scale bar: a round number of base pairs about
|
|
8656
|
+
// targetPx long, labelled in kb from 1000 up.
|
|
8657
|
+
forester.geneScaleBar = function (pxPerBp, targetPx) {
|
|
8658
|
+
let bar = forester.scaleBarLength(pxPerBp, targetPx);
|
|
8659
|
+
if (!bar) {
|
|
8660
|
+
return null;
|
|
8661
|
+
}
|
|
8662
|
+
let bp = bar.length;
|
|
8663
|
+
return {bp: bp, px: bar.px, label: bp >= 1000 ? (bp / 1000) + ' kb' : bp + ' bp'};
|
|
8664
|
+
};
|
|
8665
|
+
|
|
8666
|
+
// A location as the readout and the node-data dialog show it.
|
|
8667
|
+
forester.geneLocationText = function (gene) {
|
|
8668
|
+
return gene.contig + ':' + gene.start + '-' + gene.end + '(' + gene.strand + ')';
|
|
8669
|
+
};
|
|
8670
|
+
|
|
7946
8671
|
// --------------------------------------------------------------
|
|
7947
8672
|
// Scale bar
|
|
7948
8673
|
// --------------------------------------------------------------
|
|
@@ -9681,6 +10406,41 @@
|
|
|
9681
10406
|
return vals;
|
|
9682
10407
|
};
|
|
9683
10408
|
|
|
10409
|
+
// Month ticks over [from, to] in decimal years, for a span too short for
|
|
10410
|
+
// whole years (an outbreak sampled over some months): the first of every
|
|
10411
|
+
// 1st, 2nd, 3rd, 6th or 12th month, the smallest step leaving six ticks
|
|
10412
|
+
// or fewer; none where even a tick a year is too many. {value, year,
|
|
10413
|
+
// month}; month is 1-12, value the decimal year at which that month
|
|
10414
|
+
// begins.
|
|
10415
|
+
forester.calendarTickMonths = function (from, to) {
|
|
10416
|
+
let span = to - from;
|
|
10417
|
+
if (!(span > 0) || !isFinite(span) || !isFinite(from)) {
|
|
10418
|
+
return [];
|
|
10419
|
+
}
|
|
10420
|
+
let steps = [1, 2, 3, 6, 12];
|
|
10421
|
+
for (let s = 0; s < steps.length; ++s) {
|
|
10422
|
+
let step = steps[s];
|
|
10423
|
+
let ticks = [];
|
|
10424
|
+
let year = Math.floor(from);
|
|
10425
|
+
// month index = year * 12 + (month - 1); ticks where it divides
|
|
10426
|
+
for (let k = year * 12; ticks.length <= 6; ++k) {
|
|
10427
|
+
let y = Math.floor(k / 12);
|
|
10428
|
+
let m = (k - (y * 12)) + 1;
|
|
10429
|
+
let v = y + ((tipDateDayOfYear(y, m, 1) - 1) / tipDateYearLength(y));
|
|
10430
|
+
if (v > to + 1e-9) {
|
|
10431
|
+
break;
|
|
10432
|
+
}
|
|
10433
|
+
if (v >= from - 1e-9 && (k % step) === 0) {
|
|
10434
|
+
ticks.push({value: v, year: y, month: m});
|
|
10435
|
+
}
|
|
10436
|
+
}
|
|
10437
|
+
if (ticks.length <= 6) {
|
|
10438
|
+
return ticks;
|
|
10439
|
+
}
|
|
10440
|
+
}
|
|
10441
|
+
return [];
|
|
10442
|
+
};
|
|
10443
|
+
|
|
9684
10444
|
|
|
9685
10445
|
// --------------------------------------------------------------
|
|
9686
10446
|
// Multiple sequence alignment (the desktop's alignment track)
|