archaeopteryx 3.3.0 → 3.4.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 +143 -11
- package/archaeopteryx.d.ts +100 -10
- package/archaeopteryx.js +2059 -277
- package/forester.js +288 -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.4.0
|
|
24
24
|
// 2026-09-10
|
|
25
25
|
//
|
|
26
26
|
// forester.js is a general suite for dealing with phylogenetic trees.
|
|
@@ -1602,11 +1602,23 @@
|
|
|
1602
1602
|
// everything found here is in the candidate's domain and keeps its
|
|
1603
1603
|
// colour. A numeric candidate also gets the view's colour-mode band
|
|
1604
1604
|
// (see visNumericModes); a category keeps its mode.
|
|
1605
|
+
// `root` is a node, walked in preorder, or an array of the tips to
|
|
1606
|
+
// describe (the viewer passes what is on screen, which a collapsed clade
|
|
1607
|
+
// shortens).
|
|
1605
1608
|
forester.visualizationSummary = function (candidate, root) {
|
|
1606
1609
|
let counts = Object.create(null);
|
|
1607
1610
|
let total = 0;
|
|
1608
1611
|
let coverage = 0;
|
|
1609
|
-
|
|
1612
|
+
let tips = root;
|
|
1613
|
+
if (!Array.isArray(root)) {
|
|
1614
|
+
tips = [];
|
|
1615
|
+
forester.preOrderTraversalAll(root, function (n) {
|
|
1616
|
+
if (!n.children) {
|
|
1617
|
+
tips.push(n);
|
|
1618
|
+
}
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
tips.forEach(function (n) {
|
|
1610
1622
|
if (n.children) {
|
|
1611
1623
|
return;
|
|
1612
1624
|
}
|
|
@@ -2318,6 +2330,17 @@
|
|
|
2318
2330
|
// the shared contract -- and throwing on a combination a user
|
|
2319
2331
|
// plausibly wants is what forced callers into workarounds.
|
|
2320
2332
|
|
|
2333
|
+
// A text holding several trees (one per ';') reads as its FIRST;
|
|
2334
|
+
// parseNewHampshireTrees reads them all. Before this the statements
|
|
2335
|
+
// ran together and the LAST tree came back, the others silently
|
|
2336
|
+
// dropped.
|
|
2337
|
+
{
|
|
2338
|
+
let statements = forester.splitNewHampshire(nhStr);
|
|
2339
|
+
if (statements.length > 1) {
|
|
2340
|
+
nhStr = statements[0];
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2321
2344
|
let ancs = [];
|
|
2322
2345
|
let x = {};
|
|
2323
2346
|
|
|
@@ -2569,6 +2592,66 @@
|
|
|
2569
2592
|
}
|
|
2570
2593
|
};
|
|
2571
2594
|
|
|
2595
|
+
// Splits a New Hampshire text into its tree statements, one per ';'
|
|
2596
|
+
// outside quotes and [...] comments (a ';' inside a quoted label or a
|
|
2597
|
+
// [&...] annotation is data), each trimmed; blank statements are
|
|
2598
|
+
// dropped. A text without a terminating ';' is one statement.
|
|
2599
|
+
forester.splitNewHampshire = function (nhStr) {
|
|
2600
|
+
let s = String(nhStr);
|
|
2601
|
+
let parts = [];
|
|
2602
|
+
let start = 0;
|
|
2603
|
+
let inSingle = false;
|
|
2604
|
+
let inDouble = false;
|
|
2605
|
+
let depth = 0;
|
|
2606
|
+
for (let i = 0, n = s.length; i < n; ++i) {
|
|
2607
|
+
let c = s.charAt(i);
|
|
2608
|
+
if (inSingle) {
|
|
2609
|
+
if (c === "'") {
|
|
2610
|
+
inSingle = false;
|
|
2611
|
+
}
|
|
2612
|
+
} else if (inDouble) {
|
|
2613
|
+
if (c === '"') {
|
|
2614
|
+
inDouble = false;
|
|
2615
|
+
}
|
|
2616
|
+
} else if (depth > 0) {
|
|
2617
|
+
if (c === ']') {
|
|
2618
|
+
--depth;
|
|
2619
|
+
} else if (c === '[') {
|
|
2620
|
+
++depth;
|
|
2621
|
+
}
|
|
2622
|
+
} else if (c === "'") {
|
|
2623
|
+
inSingle = true;
|
|
2624
|
+
} else if (c === '"') {
|
|
2625
|
+
inDouble = true;
|
|
2626
|
+
} else if (c === '[') {
|
|
2627
|
+
depth = 1;
|
|
2628
|
+
} else if (c === ';') {
|
|
2629
|
+
parts.push(s.substring(start, i + 1));
|
|
2630
|
+
start = i + 1;
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
parts.push(s.substring(start));
|
|
2634
|
+
return parts.map(function (p) {
|
|
2635
|
+
return p.trim();
|
|
2636
|
+
}).filter(function (p) {
|
|
2637
|
+
return p.length > 0;
|
|
2638
|
+
});
|
|
2639
|
+
};
|
|
2640
|
+
|
|
2641
|
+
// Every tree in a New Hampshire text (a file can hold many, one per
|
|
2642
|
+
// ';'), each parsed as parseNewHampshire does, in file order. A text
|
|
2643
|
+
// with no statement at all is handed to parseNewHampshire whole, so it
|
|
2644
|
+
// fails the way an empty tree always did.
|
|
2645
|
+
forester.parseNewHampshireTrees = function (nhStr, confidenceValuesInBrackets, confidenceValuesAsInternalNames) {
|
|
2646
|
+
let statements = forester.splitNewHampshire(nhStr);
|
|
2647
|
+
if (statements.length === 0) {
|
|
2648
|
+
return [forester.parseNewHampshire(nhStr, confidenceValuesInBrackets, confidenceValuesAsInternalNames)];
|
|
2649
|
+
}
|
|
2650
|
+
return statements.map(function (statement) {
|
|
2651
|
+
return forester.parseNewHampshire(statement, confidenceValuesInBrackets, confidenceValuesAsInternalNames);
|
|
2652
|
+
});
|
|
2653
|
+
};
|
|
2654
|
+
|
|
2572
2655
|
// Parses a Nexus-formatted string and returns an ARRAY of tree objects,
|
|
2573
2656
|
// each in the same shape parseNewHampshire produces (a Nexus file can
|
|
2574
2657
|
// hold any number of trees). Ported from the desktop's
|
|
@@ -3793,6 +3876,209 @@
|
|
|
3793
3876
|
return {backbone: {x: start, w: Number(da.length) * f}, boxes: boxes};
|
|
3794
3877
|
};
|
|
3795
3878
|
|
|
3879
|
+
// --------------------------------------------------------------
|
|
3880
|
+
// Scale bar
|
|
3881
|
+
// --------------------------------------------------------------
|
|
3882
|
+
// A phylogram's scale bar spans a round number of branch-length units
|
|
3883
|
+
// -- 1, 2 or 5 times a power of ten -- chosen so the bar comes out about
|
|
3884
|
+
// targetPx long at pxPerUnit pixels per unit. Returns {length, label,
|
|
3885
|
+
// px}, or null when the scale is unusable (zero, negative or infinite).
|
|
3886
|
+
forester.scaleBarLength = function (pxPerUnit, targetPx) {
|
|
3887
|
+
if (!(pxPerUnit > 0) || !isFinite(pxPerUnit)) {
|
|
3888
|
+
return null;
|
|
3889
|
+
}
|
|
3890
|
+
let raw = (targetPx || 100) / pxPerUnit;
|
|
3891
|
+
let k = Math.floor(Math.log10(raw));
|
|
3892
|
+
let base = raw / Math.pow(10, k);
|
|
3893
|
+
let nice = base < 1.5 ? 1 : (base < 3.5 ? 2 : (base < 7.5 ? 5 : 10));
|
|
3894
|
+
let length = Number((nice * Math.pow(10, k)).toPrecision(2));
|
|
3895
|
+
return {length: length, label: String(length), px: length * pxPerUnit};
|
|
3896
|
+
};
|
|
3897
|
+
|
|
3898
|
+
// --------------------------------------------------------------
|
|
3899
|
+
// Metadata tables
|
|
3900
|
+
// --------------------------------------------------------------
|
|
3901
|
+
// A table beside the tree -- TSV or CSV with a header row, the first
|
|
3902
|
+
// column naming the tip -- joined onto the tips as node properties, so
|
|
3903
|
+
// that everything downstream of a property sees the columns as if the
|
|
3904
|
+
// file had carried them: the automatic Color-by and Shape candidates,
|
|
3905
|
+
// the legends, the search fields, the node-data dialog, the phyloXML
|
|
3906
|
+
// writer. Every other browser viewer takes such a table; it was the one
|
|
3907
|
+
// input this library lacked (field review, 2026-09-13).
|
|
3908
|
+
|
|
3909
|
+
forester.METADATA_NAMESPACE = 'meta';
|
|
3910
|
+
|
|
3911
|
+
function splitDelimitedLine(line, delimiter) {
|
|
3912
|
+
let out = [];
|
|
3913
|
+
let cur = '';
|
|
3914
|
+
let quoted = false;
|
|
3915
|
+
for (let i = 0; i < line.length; ++i) {
|
|
3916
|
+
let c = line.charAt(i);
|
|
3917
|
+
if (quoted) {
|
|
3918
|
+
if (c === '"') {
|
|
3919
|
+
if (line.charAt(i + 1) === '"') { // a doubled quote is a literal one
|
|
3920
|
+
cur += '"';
|
|
3921
|
+
i++;
|
|
3922
|
+
} else {
|
|
3923
|
+
quoted = false;
|
|
3924
|
+
}
|
|
3925
|
+
} else {
|
|
3926
|
+
cur += c;
|
|
3927
|
+
}
|
|
3928
|
+
} else if (c === '"') {
|
|
3929
|
+
quoted = true;
|
|
3930
|
+
} else if (c === delimiter) {
|
|
3931
|
+
out.push(cur);
|
|
3932
|
+
cur = '';
|
|
3933
|
+
} else {
|
|
3934
|
+
cur += c;
|
|
3935
|
+
}
|
|
3936
|
+
}
|
|
3937
|
+
out.push(cur);
|
|
3938
|
+
return out.map(function (s) {
|
|
3939
|
+
return s.trim();
|
|
3940
|
+
});
|
|
3941
|
+
}
|
|
3942
|
+
|
|
3943
|
+
// Splits delimited text into its header and rows. The delimiter is
|
|
3944
|
+
// whichever of tab, comma and semicolon occurs most in the header line
|
|
3945
|
+
// (tab when none does); fields may be double-quoted; blank lines and
|
|
3946
|
+
// lines starting with '#' are skipped; Windows line ends are fine.
|
|
3947
|
+
// Header names and cells come back trimmed. Throws when there is no
|
|
3948
|
+
// header or fewer than two columns.
|
|
3949
|
+
forester.parseDelimitedTable = function (text) {
|
|
3950
|
+
let lines = String(text || '').split(/\r?\n/).filter(function (l) {
|
|
3951
|
+
return l.trim().length > 0 && l.charAt(0) !== '#';
|
|
3952
|
+
});
|
|
3953
|
+
if (lines.length === 0) {
|
|
3954
|
+
throw new Error('the table is empty');
|
|
3955
|
+
}
|
|
3956
|
+
let delimiter = '\t';
|
|
3957
|
+
let best = -1;
|
|
3958
|
+
['\t', ',', ';'].forEach(function (d) {
|
|
3959
|
+
let n = lines[0].split(d).length - 1;
|
|
3960
|
+
if (n > best) {
|
|
3961
|
+
best = n;
|
|
3962
|
+
delimiter = d;
|
|
3963
|
+
}
|
|
3964
|
+
});
|
|
3965
|
+
let columns = splitDelimitedLine(lines[0], delimiter);
|
|
3966
|
+
if (columns.length < 2) {
|
|
3967
|
+
throw new Error('the table needs a header row with at least two columns: the tip name, then the data');
|
|
3968
|
+
}
|
|
3969
|
+
let rows = [];
|
|
3970
|
+
for (let i = 1; i < lines.length; ++i) {
|
|
3971
|
+
rows.push(splitDelimitedLine(lines[i], delimiter));
|
|
3972
|
+
}
|
|
3973
|
+
return {columns: columns, rows: rows, delimiter: delimiter};
|
|
3974
|
+
};
|
|
3975
|
+
|
|
3976
|
+
// The property ref for a column. A header that already reads as a
|
|
3977
|
+
// phyloXML ref (ns:local, no whitespace) is kept as it is; any other
|
|
3978
|
+
// becomes "meta:" plus the header with its whitespace as '_' -- the
|
|
3979
|
+
// display name prettifies that back to spaces, so "Collection Date"
|
|
3980
|
+
// stays "Collection Date" in every menu.
|
|
3981
|
+
forester.metadataColumnRef = function (header, index) {
|
|
3982
|
+
let h = String(header || '').trim();
|
|
3983
|
+
if (h.length === 0) {
|
|
3984
|
+
h = 'column_' + (index + 1);
|
|
3985
|
+
}
|
|
3986
|
+
if (/^[A-Za-z0-9_]+:\S+$/.test(h)) {
|
|
3987
|
+
return h;
|
|
3988
|
+
}
|
|
3989
|
+
return forester.METADATA_NAMESPACE + ':' + h.replace(/\s+/g, '_');
|
|
3990
|
+
};
|
|
3991
|
+
|
|
3992
|
+
// Joins a table onto the tree's tips. The first column is the key,
|
|
3993
|
+
// matched to the tip's name exactly, then case-insensitively. Every
|
|
3994
|
+
// other column becomes one property per matched tip with a non-empty
|
|
3995
|
+
// cell (applies_to node; the datatype is xsd:integer or xsd:double when
|
|
3996
|
+
// every filled cell of the column is such a number, xsd:string
|
|
3997
|
+
// otherwise). A property the tip already carries under the same ref is
|
|
3998
|
+
// replaced -- the table wins. Returns what happened:
|
|
3999
|
+
// {columns: [{header, ref, datatype, filled}], tips, matchedTips,
|
|
4000
|
+
// unmatchedTips: [names], unmatchedRows: [keys], properties}
|
|
4001
|
+
forester.joinMetadataTable = function (tree, text) {
|
|
4002
|
+
let table = forester.parseDelimitedTable(text);
|
|
4003
|
+
let tips = forester.getAllExternalNodes(tree);
|
|
4004
|
+
let byName = Object.create(null);
|
|
4005
|
+
let byLower = Object.create(null);
|
|
4006
|
+
tips.forEach(function (n) {
|
|
4007
|
+
if (n.name) {
|
|
4008
|
+
byName[n.name] = n;
|
|
4009
|
+
let lower = n.name.toLowerCase();
|
|
4010
|
+
if (!byLower[lower]) {
|
|
4011
|
+
byLower[lower] = n;
|
|
4012
|
+
}
|
|
4013
|
+
}
|
|
4014
|
+
});
|
|
4015
|
+
let columns = [];
|
|
4016
|
+
for (let j = 1; j < table.columns.length; ++j) {
|
|
4017
|
+
let filled = table.rows.map(function (r) {
|
|
4018
|
+
return r[j] === undefined ? '' : r[j];
|
|
4019
|
+
}).filter(function (v) {
|
|
4020
|
+
return v.length > 0;
|
|
4021
|
+
});
|
|
4022
|
+
let datatype = 'xsd:string';
|
|
4023
|
+
if (filled.length > 0) {
|
|
4024
|
+
if (filled.every(function (v) { return /^[+-]?\d+$/.test(v); })) {
|
|
4025
|
+
datatype = 'xsd:integer';
|
|
4026
|
+
} else if (filled.every(function (v) { return VIS_NUMERIC_RE.test(v); })) {
|
|
4027
|
+
datatype = 'xsd:double';
|
|
4028
|
+
}
|
|
4029
|
+
}
|
|
4030
|
+
columns.push({header: table.columns[j], ref: forester.metadataColumnRef(table.columns[j], j),
|
|
4031
|
+
datatype: datatype, filled: 0});
|
|
4032
|
+
}
|
|
4033
|
+
let matched = new Set();
|
|
4034
|
+
let unmatchedRows = [];
|
|
4035
|
+
let properties = 0;
|
|
4036
|
+
table.rows.forEach(function (r) {
|
|
4037
|
+
let key = r[0] === undefined ? '' : r[0];
|
|
4038
|
+
if (key.length === 0) {
|
|
4039
|
+
return;
|
|
4040
|
+
}
|
|
4041
|
+
let tip = byName[key] || byLower[key.toLowerCase()];
|
|
4042
|
+
if (!tip) {
|
|
4043
|
+
unmatchedRows.push(key);
|
|
4044
|
+
return;
|
|
4045
|
+
}
|
|
4046
|
+
matched.add(tip);
|
|
4047
|
+
columns.forEach(function (col, k) {
|
|
4048
|
+
let v = r[k + 1] === undefined ? '' : r[k + 1];
|
|
4049
|
+
if (v.length === 0) {
|
|
4050
|
+
return;
|
|
4051
|
+
}
|
|
4052
|
+
if (!tip.properties) {
|
|
4053
|
+
tip.properties = [];
|
|
4054
|
+
}
|
|
4055
|
+
let existing = null;
|
|
4056
|
+
for (let i = 0; i < tip.properties.length; ++i) {
|
|
4057
|
+
if (tip.properties[i].ref === col.ref) {
|
|
4058
|
+
existing = tip.properties[i];
|
|
4059
|
+
break;
|
|
4060
|
+
}
|
|
4061
|
+
}
|
|
4062
|
+
if (existing) {
|
|
4063
|
+
existing.value = v;
|
|
4064
|
+
existing.datatype = col.datatype;
|
|
4065
|
+
existing.applies_to = 'node';
|
|
4066
|
+
} else {
|
|
4067
|
+
tip.properties.push({ref: col.ref, value: v, datatype: col.datatype, applies_to: 'node'});
|
|
4068
|
+
}
|
|
4069
|
+
col.filled++;
|
|
4070
|
+
properties++;
|
|
4071
|
+
});
|
|
4072
|
+
});
|
|
4073
|
+
let unmatchedTips = tips.filter(function (n) {
|
|
4074
|
+
return !matched.has(n);
|
|
4075
|
+
}).map(function (n) {
|
|
4076
|
+
return n.name || '';
|
|
4077
|
+
});
|
|
4078
|
+
return {columns: columns, tips: tips.length, matchedTips: matched.size,
|
|
4079
|
+
unmatchedTips: unmatchedTips, unmatchedRows: unmatchedRows, properties: properties};
|
|
4080
|
+
};
|
|
4081
|
+
|
|
3796
4082
|
// --------------------------------------------------------------
|
|
3797
4083
|
// Search engine
|
|
3798
4084
|
// --------------------------------------------------------------
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "archaeopteryx",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"description": "Archaeopteryx.js is a software tool for the visualization and analysis of highly annotated phylogenetic trees.",
|
|
5
5
|
"main": "archaeopteryx.js",
|
|
6
6
|
"types": "archaeopteryx.d.ts",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"LICENSE"
|
|
21
21
|
],
|
|
22
22
|
"scripts": {
|
|
23
|
-
"test": "node test/forester_test.js && node test/search_test.js && node test/visualization_test.js && node test/domain_test.js",
|
|
23
|
+
"test": "node test/forester_test.js && node test/search_test.js && node test/visualization_test.js && node test/domain_test.js && node test/metadata_test.js",
|
|
24
24
|
"lint": "eslint .",
|
|
25
25
|
"lint:fix": "eslint . --fix",
|
|
26
26
|
"format": "prettier --write .",
|