archaeopteryx 3.10.0 → 3.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +331 -8
- package/archaeopteryx.d.ts +23 -0
- package/archaeopteryx.js +1597 -58
- package/forester.js +862 -9
- 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.12.0
|
|
24
24
|
// 2026-09-17
|
|
25
25
|
//
|
|
26
26
|
// forester.js is a general suite for dealing with phylogenetic trees.
|
|
@@ -2775,6 +2775,780 @@
|
|
|
2775
2775
|
return candidates;
|
|
2776
2776
|
};
|
|
2777
2777
|
|
|
2778
|
+
/**
|
|
2779
|
+
* The HEAT-MAP columns of a tree: every per-tip NUMERIC property ref, and the
|
|
2780
|
+
* one shared scale their values are drawn on.
|
|
2781
|
+
*
|
|
2782
|
+
* Candidacy is not decided here. A column is a ref that
|
|
2783
|
+
* `forester.visualizationCandidates` already calls a numeric property, so the
|
|
2784
|
+
* heat map offers exactly what Color-by offers and inherits the refusal rules
|
|
2785
|
+
* with it -- a ref carried twice by one node, a ref no tip has, an internal
|
|
2786
|
+
* `aptx:` ref. Two features disagreeing about what counts as a numeric field
|
|
2787
|
+
* would be worse than either rule.
|
|
2788
|
+
*
|
|
2789
|
+
* The ORDER is this function's own, and it is not first-appearance. The
|
|
2790
|
+
* candidate list is sorted by the selection ranking, which is right for a menu
|
|
2791
|
+
* and wrong for a matrix: a producer writes the columns in the order it means
|
|
2792
|
+
* them to be read -- gene classes together, say -- and that grouping is
|
|
2793
|
+
* information a matrix should keep.
|
|
2794
|
+
*
|
|
2795
|
+
* But a tip may be MISSING a column, and first-appearance then breaks on the
|
|
2796
|
+
* very thing it is for. Measured on the pan-genome demo: 7 of its 100 tips
|
|
2797
|
+
* carry no `dnaK`, the first tip is one of them, and first-appearance put a
|
|
2798
|
+
* core gene at column 40 of 40, splitting the core block it belongs to. So each
|
|
2799
|
+
* column sits at its MEAN position among the tips that do carry it -- every
|
|
2800
|
+
* tip's own property order is intact, and averaging merges those orders without
|
|
2801
|
+
* one gap being able to move a column. `dnaK` is at index 2 in the tips that
|
|
2802
|
+
* have it, `recA` at 2.93 across tips with and without, so the core block
|
|
2803
|
+
* survives. First appearance breaks a tie, so the result is deterministic.
|
|
2804
|
+
*
|
|
2805
|
+
* ONE scale spans every column, so a value means the same thing across the
|
|
2806
|
+
* matrix: a 4 in one gene is the colour a 4 is in every other. Per-column
|
|
2807
|
+
* scaling would make each column use the whole range and a colour mean
|
|
2808
|
+
* something different in each.
|
|
2809
|
+
*
|
|
2810
|
+
* Returns `{ refs: [{ref, label}], min, max }`, or refs: [] when the tree has
|
|
2811
|
+
* no numeric per-tip property. `min`/`max` are over every value of every
|
|
2812
|
+
* column and are `null` when there is nothing to scale.
|
|
2813
|
+
*/
|
|
2814
|
+
forester.heatmapColumns = function (tree) {
|
|
2815
|
+
let numeric = Object.create(null);
|
|
2816
|
+
let label = Object.create(null);
|
|
2817
|
+
forester.visualizationCandidates(tree).forEach(function (c) {
|
|
2818
|
+
if (c && c.kind === 'property' && c.numeric === true && c.ref) {
|
|
2819
|
+
numeric[c.ref] = true;
|
|
2820
|
+
label[c.ref] = c.label || c.ref;
|
|
2821
|
+
}
|
|
2822
|
+
});
|
|
2823
|
+
let seen = Object.create(null); // ref -> its label, for the refs any tip carries
|
|
2824
|
+
let sequences = []; // one per tip: the columns IT lists, in ITS order
|
|
2825
|
+
let min = null;
|
|
2826
|
+
let max = null;
|
|
2827
|
+
forester.preOrderTraversalAll(tree, function (n) {
|
|
2828
|
+
if (n.children && n.children.length > 0) {
|
|
2829
|
+
return; // per-TIP columns: an internal node has no row
|
|
2830
|
+
}
|
|
2831
|
+
let seq = [];
|
|
2832
|
+
(n.properties || []).forEach(function (p) {
|
|
2833
|
+
if (!p || !numeric[p.ref]) {
|
|
2834
|
+
return;
|
|
2835
|
+
}
|
|
2836
|
+
seen[p.ref] = label[p.ref];
|
|
2837
|
+
seq.push(p.ref);
|
|
2838
|
+
let v = forester.heatmapValue(n, p.ref);
|
|
2839
|
+
if (v !== null) {
|
|
2840
|
+
min = (min === null || v < min) ? v : min;
|
|
2841
|
+
max = (max === null || v > max) ? v : max;
|
|
2842
|
+
}
|
|
2843
|
+
});
|
|
2844
|
+
if (seq.length > 0) {
|
|
2845
|
+
sequences.push(seq);
|
|
2846
|
+
}
|
|
2847
|
+
});
|
|
2848
|
+
// The consensus of what the tips actually said (see
|
|
2849
|
+
// heatmapConsensusOrder). Nothing here depends on the order the tips
|
|
2850
|
+
// were REACHED in, and that matters: the traversal follows the tree's
|
|
2851
|
+
// current child order, which ladderizing rewrites -- measured, the same
|
|
2852
|
+
// file once gave "B A" ladderized and "A B" not. A column order is a
|
|
2853
|
+
// fact about the values.
|
|
2854
|
+
let refs = forester.heatmapConsensusOrder(sequences, Object.keys(seen));
|
|
2855
|
+
return {
|
|
2856
|
+
refs: refs.map(function (ref) {
|
|
2857
|
+
return {ref: ref, label: seen[ref]};
|
|
2858
|
+
}),
|
|
2859
|
+
min: min,
|
|
2860
|
+
max: max
|
|
2861
|
+
};
|
|
2862
|
+
};
|
|
2863
|
+
|
|
2864
|
+
/**
|
|
2865
|
+
* The number in `node`'s heat-map cell for `ref`, or `null` when there is none.
|
|
2866
|
+
*
|
|
2867
|
+
* `null` is NOT zero and must never be drawn as one: a missing property is a
|
|
2868
|
+
* cell nobody filled in -- absence of evidence -- and on a presence/absence
|
|
2869
|
+
* matrix reading it as 0 would state the opposite of what the file says. An
|
|
2870
|
+
* empty, blank or non-numeric value is the same case. Only the FIRST value of
|
|
2871
|
+
* a repeated ref can be shown, there being one cell to show it in; such refs
|
|
2872
|
+
* are refused upstream as candidates, so this is a backstop, not the path.
|
|
2873
|
+
*/
|
|
2874
|
+
forester.heatmapValue = function (node, ref) {
|
|
2875
|
+
let ps = (node && node.properties) || [];
|
|
2876
|
+
for (let i = 0; i < ps.length; i++) {
|
|
2877
|
+
if (ps[i] && ps[i].ref === ref) {
|
|
2878
|
+
let v = ps[i].value;
|
|
2879
|
+
if (v === null || v === undefined || String(v).trim() === '') {
|
|
2880
|
+
return null;
|
|
2881
|
+
}
|
|
2882
|
+
let n = Number(String(v).trim());
|
|
2883
|
+
return isFinite(n) ? n : null;
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
return null;
|
|
2887
|
+
};
|
|
2888
|
+
|
|
2889
|
+
// ---- heat-map column ORDER -------------------------------------------
|
|
2890
|
+
//
|
|
2891
|
+
// The desktop's View > Order Matrix Columns, minus its Manual mode (which
|
|
2892
|
+
// needs a drag-to-reorder dialog we have not got). Every data-driven mode
|
|
2893
|
+
// starts from the columns in DOCUMENT order, so a result never depends on
|
|
2894
|
+
// the order they happened to be in: switching Alphabetical -> Clustered
|
|
2895
|
+
// and Document -> Clustered must give the same clustering.
|
|
2896
|
+
//
|
|
2897
|
+
// A cell that is not assessed never counts as 0, in any mode: blank is
|
|
2898
|
+
// absence of evidence, not evidence of absence.
|
|
2899
|
+
|
|
2900
|
+
forester.HEATMAP_ORDER_MODES = ['document', 'clustered', 'clustered-presence', 'alphabetical',
|
|
2901
|
+
'frequency', 'manual'];
|
|
2902
|
+
|
|
2903
|
+
/**
|
|
2904
|
+
* The value of every column at every tip, [column][tip], null where the
|
|
2905
|
+
* tip has no value -- the same number the cell is drawn from. EVERY tip
|
|
2906
|
+
* counts, collapsed or not, as the shared colour scale does: an order that
|
|
2907
|
+
* changed when a clade is collapsed would make the columns jump under the
|
|
2908
|
+
* reader.
|
|
2909
|
+
*/
|
|
2910
|
+
forester.heatmapValueMatrix = function (tree, refs) {
|
|
2911
|
+
let tips = forester.getAllExternalNodes(tree);
|
|
2912
|
+
return refs.map(function (ref) {
|
|
2913
|
+
return tips.map(function (t) {
|
|
2914
|
+
return forester.heatmapValue(t, ref);
|
|
2915
|
+
});
|
|
2916
|
+
});
|
|
2917
|
+
};
|
|
2918
|
+
|
|
2919
|
+
/**
|
|
2920
|
+
* Euclidean distance between every two columns, R's dist(method =
|
|
2921
|
+
* "euclidean") convention for missing values: only the tips where BOTH
|
|
2922
|
+
* columns have a value are used, and the squared sum is scaled up by
|
|
2923
|
+
* tips / used, so a pair assessed on fewer tips is not made to look closer
|
|
2924
|
+
* for it. A pair that shares no assessed tip at all has no distance; it is
|
|
2925
|
+
* +Infinity here (R returns NA and its hclust then refuses), so it joins
|
|
2926
|
+
* last.
|
|
2927
|
+
*/
|
|
2928
|
+
forester.heatmapEuclideanDistances = function (v) {
|
|
2929
|
+
let n = v.length;
|
|
2930
|
+
let d = v.map(function () {
|
|
2931
|
+
return new Array(n).fill(0);
|
|
2932
|
+
});
|
|
2933
|
+
for (let a = 0; a < n; ++a) {
|
|
2934
|
+
for (let b = a + 1; b < n; ++b) {
|
|
2935
|
+
let sum = 0;
|
|
2936
|
+
let used = 0;
|
|
2937
|
+
let tips = v[a].length;
|
|
2938
|
+
for (let t = 0; t < tips; ++t) {
|
|
2939
|
+
if (v[a][t] !== null && v[b][t] !== null) {
|
|
2940
|
+
let dev = v[a][t] - v[b][t];
|
|
2941
|
+
sum += dev * dev;
|
|
2942
|
+
++used;
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
let dist = (used === 0) ? Infinity
|
|
2946
|
+
: Math.sqrt((used === tips) ? sum : (sum / (used / tips)));
|
|
2947
|
+
d[a][b] = dist;
|
|
2948
|
+
d[b][a] = dist;
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2951
|
+
return d;
|
|
2952
|
+
};
|
|
2953
|
+
|
|
2954
|
+
/**
|
|
2955
|
+
* Bray-Curtis dissimilarity between every two columns: sum|x - y| /
|
|
2956
|
+
* sum(x + y) over the tips where BOTH have a value (pairwise deletion, R
|
|
2957
|
+
* vegan's vegdist(method = "bray", na.rm = TRUE)). Being a ratio it
|
|
2958
|
+
* normalizes itself, so -- unlike Euclidean -- it needs no scale-up for
|
|
2959
|
+
* the tips it dropped.
|
|
2960
|
+
*
|
|
2961
|
+
* A tip where both columns are 0 adds nothing to either sum, so it drops
|
|
2962
|
+
* out: the DOUBLE ZERO, which Euclidean reads as agreement, is simply not
|
|
2963
|
+
* evidence here. Two genes that are each rare and never in the same tip
|
|
2964
|
+
* are maximally distant (1) rather than nearly identical. On 0/1 data this
|
|
2965
|
+
* is exactly the Sorensen-Dice dissimilarity 1 - 2|A and B| / (|A| + |B|).
|
|
2966
|
+
*
|
|
2967
|
+
* Two divergences from vegdist, both forced: a pair sharing no assessed
|
|
2968
|
+
* tip is +Infinity (vegdist gives NA, and hclust then refuses), and a pair
|
|
2969
|
+
* that is 0 at every tip it shares -- the undefined 0/0 -- is 0, because
|
|
2970
|
+
* those columns are identical wherever they can be compared (vegdist gives
|
|
2971
|
+
* NaN). The same rule covers the only other way the denominator can fail,
|
|
2972
|
+
* negative values cancelling: a pair that cannot be told apart is 0, one
|
|
2973
|
+
* that differs over a non-positive total is +Infinity rather than a
|
|
2974
|
+
* negative or NaN distance. Bray-Curtis is meant for values 0 or more.
|
|
2975
|
+
*
|
|
2976
|
+
* The result is a DISSIMILARITY, not a metric -- it does not obey the
|
|
2977
|
+
* triangle inequality, which complete linkage does not require.
|
|
2978
|
+
*/
|
|
2979
|
+
forester.heatmapBrayCurtisDistances = function (v) {
|
|
2980
|
+
let n = v.length;
|
|
2981
|
+
let d = v.map(function () {
|
|
2982
|
+
return new Array(n).fill(0);
|
|
2983
|
+
});
|
|
2984
|
+
for (let a = 0; a < n; ++a) {
|
|
2985
|
+
for (let b = a + 1; b < n; ++b) {
|
|
2986
|
+
let num = 0;
|
|
2987
|
+
let den = 0;
|
|
2988
|
+
let used = 0;
|
|
2989
|
+
let tips = v[a].length;
|
|
2990
|
+
for (let t = 0; t < tips; ++t) {
|
|
2991
|
+
if (v[a][t] !== null && v[b][t] !== null) {
|
|
2992
|
+
num += Math.abs(v[a][t] - v[b][t]);
|
|
2993
|
+
den += v[a][t] + v[b][t];
|
|
2994
|
+
++used;
|
|
2995
|
+
}
|
|
2996
|
+
}
|
|
2997
|
+
let dist;
|
|
2998
|
+
if (used === 0) {
|
|
2999
|
+
dist = Infinity;
|
|
3000
|
+
} else if (den <= 0) {
|
|
3001
|
+
dist = (num === 0) ? 0 : Infinity;
|
|
3002
|
+
} else {
|
|
3003
|
+
dist = num / den;
|
|
3004
|
+
}
|
|
3005
|
+
d[a][b] = dist;
|
|
3006
|
+
d[b][a] = dist;
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
return d;
|
|
3010
|
+
};
|
|
3011
|
+
|
|
3012
|
+
/**
|
|
3013
|
+
* The complete-linkage dendrogram of a symmetric distance matrix, the same
|
|
3014
|
+
* structure R's hclust returns, so it can be pinned against R wholesale:
|
|
3015
|
+
*
|
|
3016
|
+
* left / right one merge per stage, in R's $merge node-id convention --
|
|
3017
|
+
* a NEGATIVE value is the singleton of that index
|
|
3018
|
+
* (-1 = column 0), a POSITIVE value the cluster formed at
|
|
3019
|
+
* that earlier stage. Oriented by R's hcass2 rule
|
|
3020
|
+
* (singleton before cluster, lower index between
|
|
3021
|
+
* singletons, earlier stage between clusters), which is
|
|
3022
|
+
* what makes the leaf order reproducible.
|
|
3023
|
+
* height the distance each merge happened AT ($height) -- what a
|
|
3024
|
+
* drawn dendrogram's bar heights are. It can be +Infinity
|
|
3025
|
+
* for a pair sharing no assessed tip, so anything drawing
|
|
3026
|
+
* it has to decide what to do with a merge that has no
|
|
3027
|
+
* finite height.
|
|
3028
|
+
* order the leaves as $order reads them out, which is the column
|
|
3029
|
+
* order itself.
|
|
3030
|
+
*
|
|
3031
|
+
* Each step merges the closest two clusters; a cluster is labelled by its
|
|
3032
|
+
* lowest member index and a tie goes to the first pair the (i, j) scan
|
|
3033
|
+
* finds, which is what R's nearest-neighbour scan does. The merged
|
|
3034
|
+
* cluster's distance to every other is the LARGER of the two (complete
|
|
3035
|
+
* linkage, the Lance-Williams update R uses).
|
|
3036
|
+
*/
|
|
3037
|
+
forester.heatmapCompleteLinkage = function (d) {
|
|
3038
|
+
let n = d.length;
|
|
3039
|
+
if (n === 0) {
|
|
3040
|
+
return {left: [], right: [], height: [], order: []};
|
|
3041
|
+
}
|
|
3042
|
+
if (n === 1) {
|
|
3043
|
+
return {left: [], right: [], height: [], order: [0]};
|
|
3044
|
+
}
|
|
3045
|
+
let dist = d.map(function (row) {
|
|
3046
|
+
return row.slice();
|
|
3047
|
+
});
|
|
3048
|
+
let active = new Array(n).fill(true);
|
|
3049
|
+
// node id of the cluster currently labelled i: -(i+1) for a singleton,
|
|
3050
|
+
// (stage+1) for a merged cluster
|
|
3051
|
+
let node = new Array(n);
|
|
3052
|
+
for (let i = 0; i < n; ++i) {
|
|
3053
|
+
node[i] = -(i + 1);
|
|
3054
|
+
}
|
|
3055
|
+
let left = new Array(n - 1);
|
|
3056
|
+
let right = new Array(n - 1);
|
|
3057
|
+
let height = new Array(n - 1);
|
|
3058
|
+
for (let stage = 0; stage < (n - 1); ++stage) {
|
|
3059
|
+
let bi = -1;
|
|
3060
|
+
let bj = -1;
|
|
3061
|
+
let best = NaN;
|
|
3062
|
+
for (let i = 0; i < n; ++i) {
|
|
3063
|
+
if (!active[i]) {
|
|
3064
|
+
continue;
|
|
3065
|
+
}
|
|
3066
|
+
for (let j = i + 1; j < n; ++j) {
|
|
3067
|
+
if (!active[j]) {
|
|
3068
|
+
continue;
|
|
3069
|
+
}
|
|
3070
|
+
// strictly smaller only: the FIRST pair in (i, j) order
|
|
3071
|
+
// wins a tie, as in R's scan
|
|
3072
|
+
if ((bi < 0) || (dist[i][j] < best)) {
|
|
3073
|
+
best = dist[i][j];
|
|
3074
|
+
bi = i;
|
|
3075
|
+
bj = j;
|
|
3076
|
+
}
|
|
3077
|
+
}
|
|
3078
|
+
}
|
|
3079
|
+
// hcass2's orientation, as one rule: singletons are negative,
|
|
3080
|
+
// clusters positive by stage
|
|
3081
|
+
let a = node[bi];
|
|
3082
|
+
let b = node[bj];
|
|
3083
|
+
let l;
|
|
3084
|
+
let r;
|
|
3085
|
+
if ((a < 0) && (b < 0)) {
|
|
3086
|
+
l = (-a < -b) ? a : b;
|
|
3087
|
+
r = (-a < -b) ? b : a;
|
|
3088
|
+
} else if ((a < 0) || (b < 0)) {
|
|
3089
|
+
l = (a < 0) ? a : b;
|
|
3090
|
+
r = (a < 0) ? b : a;
|
|
3091
|
+
} else {
|
|
3092
|
+
l = Math.min(a, b);
|
|
3093
|
+
r = Math.max(a, b);
|
|
3094
|
+
}
|
|
3095
|
+
left[stage] = l;
|
|
3096
|
+
right[stage] = r;
|
|
3097
|
+
height[stage] = best;
|
|
3098
|
+
// complete linkage: the merged cluster (kept under the lower
|
|
3099
|
+
// label, bi) is as far as its farther half
|
|
3100
|
+
for (let k = 0; k < n; ++k) {
|
|
3101
|
+
if (active[k] && (k !== bi) && (k !== bj)) {
|
|
3102
|
+
let m = Math.max(dist[bi][k], dist[bj][k]);
|
|
3103
|
+
dist[bi][k] = m;
|
|
3104
|
+
dist[k][bi] = m;
|
|
3105
|
+
}
|
|
3106
|
+
}
|
|
3107
|
+
active[bj] = false;
|
|
3108
|
+
node[bi] = stage + 1;
|
|
3109
|
+
}
|
|
3110
|
+
// expand from the root, in place, left child before right -- hcass2's IORDER
|
|
3111
|
+
let order = [left[n - 2], right[n - 2]];
|
|
3112
|
+
for (let stage = n - 2; stage >= 1; --stage) {
|
|
3113
|
+
let pos = order.indexOf(stage); // the cluster formed at this stage (1-based node id)
|
|
3114
|
+
if (pos >= 0) {
|
|
3115
|
+
order[pos] = left[stage - 1];
|
|
3116
|
+
order.splice(pos + 1, 0, right[stage - 1]);
|
|
3117
|
+
}
|
|
3118
|
+
}
|
|
3119
|
+
return {
|
|
3120
|
+
left: left, right: right, height: height,
|
|
3121
|
+
order: order.map(function (v) {
|
|
3122
|
+
return -v - 1;
|
|
3123
|
+
})
|
|
3124
|
+
};
|
|
3125
|
+
};
|
|
3126
|
+
|
|
3127
|
+
/**
|
|
3128
|
+
* `columns` (the {ref, label} objects forester.heatmapColumns returned, in
|
|
3129
|
+
* DOCUMENT order) re-ordered for `mode`, with the dendrogram behind that
|
|
3130
|
+
* order when there is one.
|
|
3131
|
+
*
|
|
3132
|
+
* The dendrogram is returned only for a clustered mode with at least three
|
|
3133
|
+
* columns -- fewer than three have one order up to a flip and are left
|
|
3134
|
+
* alone, so there is no clustering behind them -- and only when its leaves
|
|
3135
|
+
* ARE the order being returned. A dendrogram drawn over a matrix it does
|
|
3136
|
+
* not describe would have connectors that cross: a picture asserting a
|
|
3137
|
+
* grouping the columns do not have. Checking the answer beats enumerating
|
|
3138
|
+
* the ways to get there.
|
|
3139
|
+
*/
|
|
3140
|
+
/**
|
|
3141
|
+
* `columns` put back into DOCUMENT order -- the consensus of what this
|
|
3142
|
+
* tree's tips actually said, read by heatmapConsensusOrder over exactly the
|
|
3143
|
+
* columns GIVEN. A column no tip carries casts no votes and cannot be
|
|
3144
|
+
* placed by them; it must not be dropped for that, so it lands at the end,
|
|
3145
|
+
* by ref.
|
|
3146
|
+
*
|
|
3147
|
+
* Every data-driven mode normalises through here precisely so a result
|
|
3148
|
+
* cannot depend on the order the columns happened to be in -- the previous
|
|
3149
|
+
* mode's, say. Going through heatmapColumns instead would drag in its
|
|
3150
|
+
* CANDIDACY rules (a constant column is refused as a visualization), and an
|
|
3151
|
+
* order must not move for a reason that has nothing to do with ordering.
|
|
3152
|
+
*/
|
|
3153
|
+
forester.heatmapInDocumentOrder = function (tree, columns) {
|
|
3154
|
+
let cols = (columns || []).slice();
|
|
3155
|
+
let want = Object.create(null);
|
|
3156
|
+
cols.forEach(function (c) {
|
|
3157
|
+
want[c.ref] = c;
|
|
3158
|
+
});
|
|
3159
|
+
let sequences = [];
|
|
3160
|
+
forester.preOrderTraversalAll(tree, function (n) {
|
|
3161
|
+
if (n.children && n.children.length > 0) {
|
|
3162
|
+
return;
|
|
3163
|
+
}
|
|
3164
|
+
let seq = [];
|
|
3165
|
+
(n.properties || []).forEach(function (p) {
|
|
3166
|
+
if (p && want[p.ref]) {
|
|
3167
|
+
seq.push(p.ref);
|
|
3168
|
+
}
|
|
3169
|
+
});
|
|
3170
|
+
if (seq.length > 0) {
|
|
3171
|
+
sequences.push(seq);
|
|
3172
|
+
}
|
|
3173
|
+
});
|
|
3174
|
+
// The same consensus forester.heatmapColumns reads, over exactly the
|
|
3175
|
+
// columns GIVEN, so it cannot be moved by a candidacy rule that has
|
|
3176
|
+
// nothing to do with ordering. A column no tip carries casts no votes
|
|
3177
|
+
// and has no position; it lands last, by ref, so the result depends
|
|
3178
|
+
// only on its inputs as a SET.
|
|
3179
|
+
return forester.heatmapConsensusOrder(sequences, cols.map(function (c) {
|
|
3180
|
+
return c.ref;
|
|
3181
|
+
})).map(function (ref) {
|
|
3182
|
+
return want[ref];
|
|
3183
|
+
});
|
|
3184
|
+
};
|
|
3185
|
+
|
|
3186
|
+
/**
|
|
3187
|
+
* The order a heat map opens in when nobody said: a CLUSTERED one, with the
|
|
3188
|
+
* distance picked from the data.
|
|
3189
|
+
*
|
|
3190
|
+
* Bray-Curtis is the better read on presence/absence and abundance data --
|
|
3191
|
+
* it is what the double-zero problem calls for -- but it is meant for
|
|
3192
|
+
* values 0 or more, and these columns are every numeric field the tree
|
|
3193
|
+
* happens to carry rather than a hand-picked matrix. Measured: on a
|
|
3194
|
+
* latitude/longitude matrix 2 of 6 pairs come out with no distance at all
|
|
3195
|
+
* (+Infinity, so they join last arbitrarily), and a year column among small
|
|
3196
|
+
* ones sits at 0.9995 from every one of them -- maximally distant for being
|
|
3197
|
+
* LARGE rather than for any pattern. Within a block of comparable magnitude
|
|
3198
|
+
* it is exactly right; across magnitudes it measures magnitude.
|
|
3199
|
+
*
|
|
3200
|
+
* So: Bray-Curtis where there are zeros to ignore and nothing negative,
|
|
3201
|
+
* Euclidean otherwise. A zero is precisely the precondition for a double
|
|
3202
|
+
* zero to exist -- without one there is nothing for Bray-Curtis to drop,
|
|
3203
|
+
* and a matrix of years or coordinates is the safer read on Euclidean.
|
|
3204
|
+
*/
|
|
3205
|
+
/**
|
|
3206
|
+
* How far the INPUT agrees with itself about the column order: {tips,
|
|
3207
|
+
* conflicting}, where a tip conflicts when its own property list is not in
|
|
3208
|
+
* the order `columns` gives (gaps are fine -- a tip that simply lacks a
|
|
3209
|
+
* column is not a disagreement).
|
|
3210
|
+
*
|
|
3211
|
+
* This is what lets the "As in the input" control say whether that order is
|
|
3212
|
+
* the file's or only a consensus. phyloXML gives every node its OWN
|
|
3213
|
+
* property list, unlike a table, so tips CAN list the same columns in
|
|
3214
|
+
* different orders, and a reader has no way of knowing without being told.
|
|
3215
|
+
* Measured on the shipped demos: 0 of 50 and 0 of 100 tips conflict, while
|
|
3216
|
+
* influenza.xml has 2 of 6.
|
|
3217
|
+
*/
|
|
3218
|
+
|
|
3219
|
+
/**
|
|
3220
|
+
* ONE column order out of many tips that each list their own columns in
|
|
3221
|
+
* their own order. `sequences` is one array of refs per tip -- that tip's
|
|
3222
|
+
* own order, gaps and all -- and `refs` is the set to place.
|
|
3223
|
+
*
|
|
3224
|
+
* Averaging each column's position was the obvious rule and it fails on a
|
|
3225
|
+
* shape that is not at all exotic: two groups of columns that no tip ever
|
|
3226
|
+
* carries TOGETHER. Tips holding A,B and tips holding X,Y put A and X both
|
|
3227
|
+
* at position 0, so the average interleaves them into X A Y B instead of
|
|
3228
|
+
* leaving the two blocks whole. No tie-break can mend that -- the average
|
|
3229
|
+
* itself is what is wrong, because it compares positions measured on tips
|
|
3230
|
+
* that share no frame of reference.
|
|
3231
|
+
*
|
|
3232
|
+
* So the order is read off a PRECEDENCE GRAPH instead. Each tip votes on
|
|
3233
|
+
* the pairs it actually lists next to each other, the majority direction
|
|
3234
|
+
* of each pair becomes an edge, and the columns come out of a topological
|
|
3235
|
+
* sort of that graph. Two blocks with no tip in common cast no votes about
|
|
3236
|
+
* each other, so nothing forces them apart, and the sort emits one and then
|
|
3237
|
+
* the other.
|
|
3238
|
+
*
|
|
3239
|
+
* Only ADJACENT pairs are looked at -- both the votes and the neighbour
|
|
3240
|
+
* sets the block rule reads -- which keeps this linear in the data. Every
|
|
3241
|
+
* pair would be quadratic in the column count, and a wide matrix is exactly
|
|
3242
|
+
* where that hurts: measured at 42 seconds on 2000 columns across 200 tips,
|
|
3243
|
+
* against 0.2 for adjacency. Transitivity comes from the sort rather than
|
|
3244
|
+
* from the votes, and a block stays whole through its chain of neighbours. The cost is that adjacency can manufacture a cycle where all
|
|
3245
|
+
* pairs would not -- tips A,B,C and C,A give A->B->C->A -- so cycles are
|
|
3246
|
+
* broken rather than assumed away.
|
|
3247
|
+
*
|
|
3248
|
+
* Where the graph says nothing, the old rule decides, so every case it
|
|
3249
|
+
* already got right is unchanged: mean position, then the better-attested
|
|
3250
|
+
* column, then the ref. What is new is that a column is preferred while it
|
|
3251
|
+
* still shares a tip with the one just placed, which is what keeps a block
|
|
3252
|
+
* together once it has been started.
|
|
3253
|
+
*/
|
|
3254
|
+
forester.heatmapConsensusOrder = function (sequences, refs) {
|
|
3255
|
+
let all = (refs || []).slice();
|
|
3256
|
+
let n = all.length;
|
|
3257
|
+
if (n < 2) {
|
|
3258
|
+
return all;
|
|
3259
|
+
}
|
|
3260
|
+
let idx = Object.create(null);
|
|
3261
|
+
all.forEach(function (r, i) {
|
|
3262
|
+
idx[r] = i;
|
|
3263
|
+
});
|
|
3264
|
+
let sum = new Array(n).fill(0);
|
|
3265
|
+
let tips = new Array(n).fill(0);
|
|
3266
|
+
let votes = new Map(); // i*n + j -> tips putting i just before j
|
|
3267
|
+
let near = []; // i -> Set of columns some tip lists NEXT to i
|
|
3268
|
+
for (let i = 0; i < n; ++i) {
|
|
3269
|
+
near.push(new Set());
|
|
3270
|
+
}
|
|
3271
|
+
(sequences || []).forEach(function (seq) {
|
|
3272
|
+
let at = 0;
|
|
3273
|
+
let prev = -1;
|
|
3274
|
+
for (let k = 0; k < seq.length; ++k) {
|
|
3275
|
+
let j = idx[seq[k]];
|
|
3276
|
+
if (j === undefined) {
|
|
3277
|
+
continue;
|
|
3278
|
+
}
|
|
3279
|
+
sum[j] += at;
|
|
3280
|
+
tips[j]++;
|
|
3281
|
+
at++;
|
|
3282
|
+
if (prev >= 0 && prev !== j) {
|
|
3283
|
+
let key = (prev * n) + j;
|
|
3284
|
+
votes.set(key, (votes.get(key) || 0) + 1);
|
|
3285
|
+
// neighbours, for the block rule below. Adjacency again,
|
|
3286
|
+
// not every pair on the tip: every pair is quadratic in the
|
|
3287
|
+
// column count, and measured, it was 42 SECONDS on 2000
|
|
3288
|
+
// columns across 200 tips. A block stays whole through its
|
|
3289
|
+
// chain of neighbours, so adjacency is all it needs.
|
|
3290
|
+
near[prev].add(j);
|
|
3291
|
+
near[j].add(prev);
|
|
3292
|
+
}
|
|
3293
|
+
prev = j;
|
|
3294
|
+
}
|
|
3295
|
+
});
|
|
3296
|
+
let mean = new Array(n);
|
|
3297
|
+
for (let i = 0; i < n; ++i) {
|
|
3298
|
+
mean[i] = (tips[i] > 0) ? (sum[i] / tips[i]) : Infinity;
|
|
3299
|
+
}
|
|
3300
|
+
// the majority direction of each voted pair becomes an edge; a pair its
|
|
3301
|
+
// tips split evenly says nothing and gets none
|
|
3302
|
+
let out = [];
|
|
3303
|
+
let indeg = new Array(n).fill(0);
|
|
3304
|
+
for (let i = 0; i < n; ++i) {
|
|
3305
|
+
out.push([]);
|
|
3306
|
+
}
|
|
3307
|
+
votes.forEach(function (count, key) {
|
|
3308
|
+
let i = Math.floor(key / n);
|
|
3309
|
+
let j = key % n;
|
|
3310
|
+
if (count > (votes.get((j * n) + i) || 0)) {
|
|
3311
|
+
out[i].push(j);
|
|
3312
|
+
indeg[j]++;
|
|
3313
|
+
}
|
|
3314
|
+
});
|
|
3315
|
+
// the static key, used wherever the graph is silent: the rule this
|
|
3316
|
+
// replaced, so nothing it already ordered correctly moves
|
|
3317
|
+
function better(a, b, last) {
|
|
3318
|
+
if (last >= 0) {
|
|
3319
|
+
let na = near[last].has(a);
|
|
3320
|
+
let nb = near[last].has(b);
|
|
3321
|
+
if (na !== nb) {
|
|
3322
|
+
return na; // keep the block that is already open
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3325
|
+
if (mean[a] !== mean[b]) {
|
|
3326
|
+
return mean[a] < mean[b];
|
|
3327
|
+
}
|
|
3328
|
+
if (tips[a] !== tips[b]) {
|
|
3329
|
+
return tips[a] > tips[b];
|
|
3330
|
+
}
|
|
3331
|
+
return all[a] < all[b];
|
|
3332
|
+
}
|
|
3333
|
+
let placed = new Array(n).fill(false);
|
|
3334
|
+
let order = [];
|
|
3335
|
+
let last = -1;
|
|
3336
|
+
for (let step = 0; step < n; ++step) {
|
|
3337
|
+
let pick = -1;
|
|
3338
|
+
for (let i = 0; i < n; ++i) {
|
|
3339
|
+
if (placed[i] || indeg[i] > 0) {
|
|
3340
|
+
continue;
|
|
3341
|
+
}
|
|
3342
|
+
if (pick < 0 || better(i, pick, last)) {
|
|
3343
|
+
pick = i;
|
|
3344
|
+
}
|
|
3345
|
+
}
|
|
3346
|
+
if (pick < 0) {
|
|
3347
|
+
// a cycle: no column is free. Break it at the column the fewest
|
|
3348
|
+
// others are waiting on, then by the same static key -- never
|
|
3349
|
+
// leave a column undrawn because its votes disagreed.
|
|
3350
|
+
for (let i = 0; i < n; ++i) {
|
|
3351
|
+
if (placed[i]) {
|
|
3352
|
+
continue;
|
|
3353
|
+
}
|
|
3354
|
+
if (pick < 0 || indeg[i] < indeg[pick]
|
|
3355
|
+
|| (indeg[i] === indeg[pick] && better(i, pick, last))) {
|
|
3356
|
+
pick = i;
|
|
3357
|
+
}
|
|
3358
|
+
}
|
|
3359
|
+
}
|
|
3360
|
+
placed[pick] = true;
|
|
3361
|
+
indeg[pick] = 0;
|
|
3362
|
+
order.push(all[pick]);
|
|
3363
|
+
out[pick].forEach(function (j) {
|
|
3364
|
+
if (!placed[j] && indeg[j] > 0) {
|
|
3365
|
+
indeg[j]--;
|
|
3366
|
+
}
|
|
3367
|
+
});
|
|
3368
|
+
last = pick;
|
|
3369
|
+
}
|
|
3370
|
+
return order;
|
|
3371
|
+
};
|
|
3372
|
+
|
|
3373
|
+
forester.heatmapInputOrderAgreement = function (tree, columns) {
|
|
3374
|
+
let out = {tips: 0, conflicting: 0};
|
|
3375
|
+
if (!tree || !columns || columns.length < 1) {
|
|
3376
|
+
return out;
|
|
3377
|
+
}
|
|
3378
|
+
let rank = Object.create(null);
|
|
3379
|
+
columns.forEach(function (c, i) {
|
|
3380
|
+
rank[c.ref] = i;
|
|
3381
|
+
});
|
|
3382
|
+
forester.preOrderTraversalAll(tree, function (n) {
|
|
3383
|
+
if (n.children && n.children.length > 0) {
|
|
3384
|
+
return;
|
|
3385
|
+
}
|
|
3386
|
+
let highest = -1;
|
|
3387
|
+
let ordered = true;
|
|
3388
|
+
let carries = false;
|
|
3389
|
+
(n.properties || []).forEach(function (p) {
|
|
3390
|
+
if (!p || !(p.ref in rank)) {
|
|
3391
|
+
return;
|
|
3392
|
+
}
|
|
3393
|
+
carries = true;
|
|
3394
|
+
let r = rank[p.ref];
|
|
3395
|
+
if (r < highest) {
|
|
3396
|
+
ordered = false;
|
|
3397
|
+
} else {
|
|
3398
|
+
highest = r;
|
|
3399
|
+
}
|
|
3400
|
+
});
|
|
3401
|
+
if (!carries) {
|
|
3402
|
+
return;
|
|
3403
|
+
}
|
|
3404
|
+
out.tips++;
|
|
3405
|
+
if (!ordered) {
|
|
3406
|
+
out.conflicting++;
|
|
3407
|
+
}
|
|
3408
|
+
});
|
|
3409
|
+
return out;
|
|
3410
|
+
};
|
|
3411
|
+
|
|
3412
|
+
forester.heatmapDefaultOrder = function (tree, columns) {
|
|
3413
|
+
if (!tree || !columns || columns.length < 1) {
|
|
3414
|
+
return 'document';
|
|
3415
|
+
}
|
|
3416
|
+
let v = forester.heatmapValueMatrix(tree, columns.map(function (c) {
|
|
3417
|
+
return c.ref;
|
|
3418
|
+
}));
|
|
3419
|
+
let any = false;
|
|
3420
|
+
let zero = false;
|
|
3421
|
+
let negative = false;
|
|
3422
|
+
v.forEach(function (row) {
|
|
3423
|
+
row.forEach(function (x) {
|
|
3424
|
+
if (x === null) {
|
|
3425
|
+
return;
|
|
3426
|
+
}
|
|
3427
|
+
any = true;
|
|
3428
|
+
if (x === 0) {
|
|
3429
|
+
zero = true;
|
|
3430
|
+
} else if (x < 0) {
|
|
3431
|
+
negative = true;
|
|
3432
|
+
}
|
|
3433
|
+
});
|
|
3434
|
+
});
|
|
3435
|
+
if (!any) {
|
|
3436
|
+
return 'document'; // nothing to cluster on
|
|
3437
|
+
}
|
|
3438
|
+
return (zero && !negative) ? 'clustered-presence' : 'clustered';
|
|
3439
|
+
};
|
|
3440
|
+
|
|
3441
|
+
/**
|
|
3442
|
+
* `columns` in the order `wanted` names them, which is a reader's own
|
|
3443
|
+
* arrangement and is never second-guessed.
|
|
3444
|
+
*
|
|
3445
|
+
* A column `wanted` does not name is NEW to it -- the tree was edited, a
|
|
3446
|
+
* table was joined since, the order came from a shared link made against a
|
|
3447
|
+
* different file. It cannot be placed by a list that has never heard of it,
|
|
3448
|
+
* so it goes after the ones that can, in the order the tips themselves give
|
|
3449
|
+
* (heatmapInDocumentOrder). A name for a column that is gone is ignored
|
|
3450
|
+
* rather than treated as an error: an order outliving one of its columns is
|
|
3451
|
+
* ordinary, not a fault.
|
|
3452
|
+
*/
|
|
3453
|
+
forester.heatmapManualOrder = function (tree, columns, wanted) {
|
|
3454
|
+
let cols = (columns || []).slice();
|
|
3455
|
+
let by = Object.create(null);
|
|
3456
|
+
cols.forEach(function (c) {
|
|
3457
|
+
by[c.ref] = c;
|
|
3458
|
+
});
|
|
3459
|
+
let taken = Object.create(null);
|
|
3460
|
+
let out = [];
|
|
3461
|
+
(wanted || []).forEach(function (ref) {
|
|
3462
|
+
if (by[ref] && !taken[ref]) {
|
|
3463
|
+
taken[ref] = true;
|
|
3464
|
+
out.push(by[ref]);
|
|
3465
|
+
}
|
|
3466
|
+
});
|
|
3467
|
+
let rest = cols.filter(function (c) {
|
|
3468
|
+
return !taken[c.ref];
|
|
3469
|
+
});
|
|
3470
|
+
return (rest.length > 0) ? out.concat(forester.heatmapInDocumentOrder(tree, rest)) : out;
|
|
3471
|
+
};
|
|
3472
|
+
|
|
3473
|
+
forester.heatmapOrder = function (tree, columns, mode, wanted) {
|
|
3474
|
+
let out = {columns: (columns || []).slice(), dendrogram: null};
|
|
3475
|
+
if (!tree || out.columns.length < 1 || !mode || mode === 'document') {
|
|
3476
|
+
return out;
|
|
3477
|
+
}
|
|
3478
|
+
if (mode === 'manual') {
|
|
3479
|
+
// a reader's own order: nothing re-sorts it, and nothing clustered
|
|
3480
|
+
// it, so there is no tree behind it to draw
|
|
3481
|
+
out.columns = forester.heatmapManualOrder(tree, out.columns, wanted);
|
|
3482
|
+
return out;
|
|
3483
|
+
}
|
|
3484
|
+
// Every data-driven mode starts from document order, so switching
|
|
3485
|
+
// Alphabetical -> Clustered and Document -> Clustered must give the
|
|
3486
|
+
// same clustering. Measured: without this the 6x6 linkage fixture fed
|
|
3487
|
+
// in reverse clustered to h6,h5,h3,h4,h2,h1 instead of R's
|
|
3488
|
+
// h6,h3,h5,h4,h1,h2 -- the index order drives the tie-break and the
|
|
3489
|
+
// hcass2 leaf order, so the incoming order really does leak through.
|
|
3490
|
+
let cols = forester.heatmapInDocumentOrder(tree, out.columns);
|
|
3491
|
+
out.columns = cols;
|
|
3492
|
+
if (mode === 'alphabetical') {
|
|
3493
|
+
out.columns = cols.slice().sort(function (a, b) {
|
|
3494
|
+
let la = String(a.label || a.ref).toLowerCase();
|
|
3495
|
+
let lb = String(b.label || b.ref).toLowerCase();
|
|
3496
|
+
// the raw ref breaks a tie, as the desktop's chooser sorts
|
|
3497
|
+
return (la < lb) ? -1 : ((la > lb) ? 1
|
|
3498
|
+
: ((a.ref < b.ref) ? -1 : ((a.ref > b.ref) ? 1 : 0)));
|
|
3499
|
+
});
|
|
3500
|
+
return out;
|
|
3501
|
+
}
|
|
3502
|
+
let v = forester.heatmapValueMatrix(tree, cols.map(function (c) {
|
|
3503
|
+
return c.ref;
|
|
3504
|
+
}));
|
|
3505
|
+
if (mode === 'frequency') {
|
|
3506
|
+
// by the mean over the tips that HAVE a value, highest first -- on
|
|
3507
|
+
// 0/1 data exactly the fraction of tips carrying it. A blank is
|
|
3508
|
+
// left out, never averaged in as 0; a column with no value at all
|
|
3509
|
+
// goes last; ties keep document order (the sort is stable).
|
|
3510
|
+
let mean = v.map(function (row) {
|
|
3511
|
+
let sum = 0;
|
|
3512
|
+
let n = 0;
|
|
3513
|
+
row.forEach(function (x) {
|
|
3514
|
+
if (x !== null) {
|
|
3515
|
+
sum += x;
|
|
3516
|
+
++n;
|
|
3517
|
+
}
|
|
3518
|
+
});
|
|
3519
|
+
return (n > 0) ? (sum / n) : -Infinity;
|
|
3520
|
+
});
|
|
3521
|
+
out.columns = cols.map(function (c, i) {
|
|
3522
|
+
return {c: c, i: i};
|
|
3523
|
+
}).sort(function (a, b) {
|
|
3524
|
+
return (mean[b.i] - mean[a.i]) || (a.i - b.i);
|
|
3525
|
+
}).map(function (e) {
|
|
3526
|
+
return e.c;
|
|
3527
|
+
});
|
|
3528
|
+
return out;
|
|
3529
|
+
}
|
|
3530
|
+
if (mode !== 'clustered' && mode !== 'clustered-presence') {
|
|
3531
|
+
return out;
|
|
3532
|
+
}
|
|
3533
|
+
if (cols.length < 3) {
|
|
3534
|
+
return out; // one order up to a flip, and R puts index 1 first
|
|
3535
|
+
}
|
|
3536
|
+
let d = (mode === 'clustered')
|
|
3537
|
+
? forester.heatmapEuclideanDistances(v)
|
|
3538
|
+
: forester.heatmapBrayCurtisDistances(v);
|
|
3539
|
+
let dendro = forester.heatmapCompleteLinkage(d);
|
|
3540
|
+
let ordered = dendro.order.map(function (g) {
|
|
3541
|
+
return cols[g];
|
|
3542
|
+
});
|
|
3543
|
+
out.columns = ordered;
|
|
3544
|
+
// the self-check: the picture is offered only if it describes the order
|
|
3545
|
+
if (dendro.order.length === cols.length) {
|
|
3546
|
+
out.dendrogram = dendro;
|
|
3547
|
+
}
|
|
3548
|
+
return out;
|
|
3549
|
+
};
|
|
3550
|
+
|
|
3551
|
+
|
|
2778
3552
|
forester.collectBasicTreeProperties = function (tree) {
|
|
2779
3553
|
let properties = {};
|
|
2780
3554
|
properties.internalNodeData = false;
|
|
@@ -5863,12 +6637,18 @@
|
|
|
5863
6637
|
// so the two programs write the same table: name (always), the first
|
|
5864
6638
|
// taxonomy's scientific name, common name, code, id and rank, the first
|
|
5865
6639
|
// sequence's name, gene name, symbol, accession and type, the branch
|
|
5866
|
-
// length, then one column per property ref, sorted, holding its
|
|
5867
|
-
//
|
|
5868
|
-
// cannot key the rows (one blank or
|
|
5869
|
-
// first, from idOf(tip, index) or the
|
|
5870
|
-
// inside a value become spaces. A
|
|
5871
|
-
// so the table joins back onto a
|
|
6640
|
+
// length, then one column per property ref, sorted, holding its value and
|
|
6641
|
+
// joining repeats of that ref with '; '. A column no tip has a value for
|
|
6642
|
+
// is left out. When the tip names cannot key the rows (one blank or
|
|
6643
|
+
// repeated), a node_id column comes first, from idOf(tip, index) or the
|
|
6644
|
+
// row number. Tabs and line breaks inside a value become spaces. A
|
|
6645
|
+
// property keeps its ref as the header, so the table joins back onto a
|
|
6646
|
+
// tree with joinMetadataTable. One KNOWN divergence as of 2026-09-17: a
|
|
6647
|
+
// ref REPEATED on one node. We join every value with '; ' in document
|
|
6648
|
+
// order; the desktop's NodeDataExporter.propertyValue takes ps.get(0) and
|
|
6649
|
+
// drops the rest. Both programs wrote that bug independently, so the shape
|
|
6650
|
+
// of the API invited it rather than either side being careless. They mean
|
|
6651
|
+
// to follow us here (Christian deciding), so this is us ahead, not adrift.
|
|
5872
6652
|
forester.externalNodeDataTable = function (tips, idOf) {
|
|
5873
6653
|
tips = tips || [];
|
|
5874
6654
|
if (tips.length === 0) {
|
|
@@ -5925,8 +6705,17 @@
|
|
|
5925
6705
|
});
|
|
5926
6706
|
Array.from(refs).sort().forEach(function (ref) {
|
|
5927
6707
|
add(ref, function (n) {
|
|
5928
|
-
|
|
5929
|
-
|
|
6708
|
+
// Every value, not just the first: phyloXML lets a ref repeat
|
|
6709
|
+
// on a node, and until 2026-09-17 this kept only [0], so a tip
|
|
6710
|
+
// carrying meta:zeta twice exported one of them and lost the
|
|
6711
|
+
// other with nothing to show it had happened. Joined in
|
|
6712
|
+
// document order, which is the order the reader and the writer
|
|
6713
|
+
// both preserve.
|
|
6714
|
+
return (n.properties || []).filter(function (q) {
|
|
6715
|
+
return q.ref === ref;
|
|
6716
|
+
}).map(function (q) {
|
|
6717
|
+
return q.value;
|
|
6718
|
+
}).join('; ');
|
|
5930
6719
|
});
|
|
5931
6720
|
});
|
|
5932
6721
|
return {
|
|
@@ -7651,6 +8440,70 @@
|
|
|
7651
8440
|
return {scores: scores, consensus: consensus};
|
|
7652
8441
|
};
|
|
7653
8442
|
|
|
8443
|
+
// Per-column sequence logo over the given rows -- the stack of letters
|
|
8444
|
+
// whose total height is the column's information content in BITS and whose
|
|
8445
|
+
// shares are the residue frequencies (Schneider & Stephens 1990), which is
|
|
8446
|
+
// the display the MEME Suite and WebLogo draw.
|
|
8447
|
+
//
|
|
8448
|
+
// No small-sample correction. Schneider's e_n = (K-1)/(2 ln2 n) is meant
|
|
8449
|
+
// for a motif sampled from many sequences; here the rows are the tips
|
|
8450
|
+
// currently on screen, so n is routinely 3 or 4 -- and for protein at n=3
|
|
8451
|
+
// the correction (4.57 bits) EXCEEDS the maximum (4.32), so a perfectly
|
|
8452
|
+
// conserved column of a small clade would draw nothing at all, exactly
|
|
8453
|
+
// when the reader has asked about that clade. The caller names n instead.
|
|
8454
|
+
//
|
|
8455
|
+
// Gaps are not a letter: frequencies are taken over the non-gap residues,
|
|
8456
|
+
// and the stack is then scaled by the column's occupancy, so a column held
|
|
8457
|
+
// up by two residues out of fifty is short rather than perfectly conserved.
|
|
8458
|
+
// `bits` and `occupancy` are returned separately so a caller can say which
|
|
8459
|
+
// of the two made a column short.
|
|
8460
|
+
//
|
|
8461
|
+
// Letters come back most frequent FIRST, ties broken alphabetically, so a
|
|
8462
|
+
// figure is reproducible rather than at the mercy of key order.
|
|
8463
|
+
forester.msaLogo = function (rows, length, nucleotide) {
|
|
8464
|
+
let maxBits = Math.log(nucleotide ? 4 : 20) / Math.LN2;
|
|
8465
|
+
if (!(length >= 0) || !isFinite(length)) {
|
|
8466
|
+
return {columns: [], maxBits: maxBits};
|
|
8467
|
+
}
|
|
8468
|
+
length = Math.floor(length);
|
|
8469
|
+
let n = rows.length;
|
|
8470
|
+
let columns = new Array(length);
|
|
8471
|
+
for (let c = 0; c < length; ++c) {
|
|
8472
|
+
let counts = {};
|
|
8473
|
+
let nonGap = 0;
|
|
8474
|
+
for (let r = 0; r < n; ++r) {
|
|
8475
|
+
let row = rows[r];
|
|
8476
|
+
let ch = (row && c < row.length) ? row.charAt(c) : '-';
|
|
8477
|
+
if (forester.isMsaGap(ch)) {
|
|
8478
|
+
continue;
|
|
8479
|
+
}
|
|
8480
|
+
ch = ch.toUpperCase();
|
|
8481
|
+
++nonGap;
|
|
8482
|
+
counts[ch] = (counts[ch] || 0) + 1;
|
|
8483
|
+
}
|
|
8484
|
+
if (n < 1 || nonGap < 1) {
|
|
8485
|
+
columns[c] = {bits: 0, occupancy: 0, height: 0, letters: []};
|
|
8486
|
+
continue;
|
|
8487
|
+
}
|
|
8488
|
+
let H = 0;
|
|
8489
|
+
Object.keys(counts).forEach(function (ch) {
|
|
8490
|
+
let p = counts[ch] / nonGap;
|
|
8491
|
+
H -= p * (Math.log(p) / Math.LN2);
|
|
8492
|
+
});
|
|
8493
|
+
let bits = Math.max(0, maxBits - H);
|
|
8494
|
+
let occupancy = nonGap / n;
|
|
8495
|
+
let height = bits * occupancy;
|
|
8496
|
+
let letters = Object.keys(counts).sort().sort(function (a, b) {
|
|
8497
|
+
return counts[b] - counts[a]; // count first, alphabetical within a tie
|
|
8498
|
+
}).map(function (ch) {
|
|
8499
|
+
let p = counts[ch] / nonGap;
|
|
8500
|
+
return {ch: ch, p: p, h: p * height};
|
|
8501
|
+
});
|
|
8502
|
+
columns[c] = {bits: bits, occupancy: occupancy, height: height, letters: letters};
|
|
8503
|
+
}
|
|
8504
|
+
return {columns: columns, maxBits: maxBits};
|
|
8505
|
+
};
|
|
8506
|
+
|
|
7654
8507
|
// The hover readout's description of one residue: full name, class (amino
|
|
7655
8508
|
// acids), Kyte-Doolittle hydropathy. Returns null for a gap.
|
|
7656
8509
|
forester.msaResidueInfo = function (ch, nucleotide) {
|