archaeopteryx 3.9.0 → 3.11.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 +280 -4
- package/archaeopteryx.d.ts +17 -0
- package/archaeopteryx.js +1474 -30
- package/forester.js +1017 -15
- 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.11.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;
|
|
@@ -4988,6 +5762,11 @@
|
|
|
4988
5762
|
? node.date.value : null;
|
|
4989
5763
|
}
|
|
4990
5764
|
|
|
5765
|
+
// A node's CUMULATIVE divergence from the root. Auspice states it outright
|
|
5766
|
+
// as nextstrain:div; every other tree states it as branch lengths, which
|
|
5767
|
+
// are the same thing in difference form, so it is summed at load and kept
|
|
5768
|
+
// on the node (see forester.captureDivergence). The property wins where it
|
|
5769
|
+
// exists, so an Auspice tree behaves exactly as it always did.
|
|
4991
5770
|
function auspiceNodeDiv(node) {
|
|
4992
5771
|
if (node.properties) {
|
|
4993
5772
|
for (let i = 0; i < node.properties.length; ++i) {
|
|
@@ -4997,7 +5776,8 @@
|
|
|
4997
5776
|
}
|
|
4998
5777
|
}
|
|
4999
5778
|
}
|
|
5000
|
-
return
|
|
5779
|
+
return (typeof node._divergence === 'number' && isFinite(node._divergence))
|
|
5780
|
+
? node._divergence : null;
|
|
5001
5781
|
}
|
|
5002
5782
|
|
|
5003
5783
|
function auspiceHasAnyDate(node) {
|
|
@@ -5051,12 +5831,200 @@
|
|
|
5051
5831
|
setDeltaBranchLengths(forester.getTreeRoot(phy), null, auspiceNodeDiv);
|
|
5052
5832
|
};
|
|
5053
5833
|
|
|
5054
|
-
|
|
5055
|
-
|
|
5056
|
-
|
|
5834
|
+
/**
|
|
5835
|
+
* Records each node's cumulative divergence from the root, summed from the
|
|
5836
|
+
* branch lengths AS LOADED, so the divergence view survives the time view
|
|
5837
|
+
* overwriting `branch_length`. Kept in `_divergence`, which is private:
|
|
5838
|
+
* no writer emits it, and a re-read of our own output re-derives it.
|
|
5839
|
+
*
|
|
5840
|
+
* A tree whose branch lengths ARE its dates (an Auspice time tree, say)
|
|
5841
|
+
* gets the snapshot too; it is `hasTimeAndDivergence` that decides whether
|
|
5842
|
+
* the two views differ enough to be worth offering.
|
|
5843
|
+
*
|
|
5844
|
+
* Call once per tree at load, before anything rewrites a branch length.
|
|
5845
|
+
*
|
|
5846
|
+
* @param phy the tree
|
|
5847
|
+
*/
|
|
5848
|
+
forester.captureDivergence = function (phy) {
|
|
5849
|
+
let root = forester.getTreeRoot(phy);
|
|
5850
|
+
if (!root) {
|
|
5851
|
+
return;
|
|
5852
|
+
}
|
|
5853
|
+
(function walk(node, cumulative) {
|
|
5854
|
+
node._divergence = cumulative;
|
|
5855
|
+
let children = node.children;
|
|
5856
|
+
if (children) {
|
|
5857
|
+
for (let i = 0; i < children.length; ++i) {
|
|
5858
|
+
let bl = children[i].branch_length;
|
|
5859
|
+
let step = (typeof bl === 'number' && isFinite(bl) && bl > 0) ? bl : 0;
|
|
5860
|
+
walk(children[i], cumulative + step);
|
|
5861
|
+
}
|
|
5862
|
+
}
|
|
5863
|
+
}(root, 0));
|
|
5864
|
+
};
|
|
5865
|
+
|
|
5866
|
+
// How much the tree's SHAPE would change between the two metrics, as a
|
|
5867
|
+
// fraction of its width: each tip's distance from the root under each
|
|
5868
|
+
// metric, normalised by the deepest tip, and the largest disagreement.
|
|
5869
|
+
//
|
|
5870
|
+
// Comparing branch lengths PAIR BY PAIR is the wrong question and gave the
|
|
5871
|
+
// wrong answer: on influenza.tree the branch lengths differ from the date
|
|
5872
|
+
// gaps by a median of 7% per branch, which looks like a separate measure,
|
|
5873
|
+
// but the differences cancel along every path and all 687 tips land within
|
|
5874
|
+
// 0.3% of where the other metric puts them. Its branch lengths ARE time --
|
|
5875
|
+
// a BEAST time tree states time -- so a toggle would redraw one picture
|
|
5876
|
+
// twice. A real Nextstrain build, where divergence is genuinely a
|
|
5877
|
+
// different measurement, moves tips by 24.8% of the tree's width.
|
|
5878
|
+
//
|
|
5879
|
+
// Both metrics are read WITHOUT touching branch_length, so this can be
|
|
5880
|
+
// asked at load without disturbing the tree.
|
|
5881
|
+
function layoutShiftBetweenMetrics(root) {
|
|
5882
|
+
let rootDate = auspiceNodeDate(root);
|
|
5883
|
+
let time = [];
|
|
5884
|
+
let div = [];
|
|
5885
|
+
let ok = true;
|
|
5886
|
+
(function walk(n, t, d) {
|
|
5887
|
+
let nt = auspiceNodeDate(n);
|
|
5888
|
+
let nd = auspiceNodeDiv(n);
|
|
5889
|
+
let tHere = (nt !== null && rootDate !== null) ? Math.abs(nt - rootDate) : t;
|
|
5890
|
+
let dHere = (nd !== null) ? nd : d;
|
|
5891
|
+
if (!n.children || n.children.length === 0) {
|
|
5892
|
+
if (nt === null || nd === null) {
|
|
5893
|
+
ok = false;
|
|
5894
|
+
}
|
|
5895
|
+
time.push(tHere);
|
|
5896
|
+
div.push(dHere);
|
|
5897
|
+
return;
|
|
5898
|
+
}
|
|
5899
|
+
for (let i = 0; i < n.children.length; ++i) {
|
|
5900
|
+
walk(n.children[i], tHere, dHere);
|
|
5901
|
+
}
|
|
5902
|
+
}(root, 0, 0));
|
|
5903
|
+
if (!ok || time.length === 0) {
|
|
5904
|
+
return 0;
|
|
5905
|
+
}
|
|
5906
|
+
let tMax = Math.max.apply(null, time.length > 50000 ? time.slice(0, 50000) : time);
|
|
5907
|
+
let dMax = Math.max.apply(null, div.length > 50000 ? div.slice(0, 50000) : div);
|
|
5908
|
+
if (!(tMax > 0) || !(dMax > 0)) {
|
|
5909
|
+
return 0;
|
|
5910
|
+
}
|
|
5911
|
+
let worst = 0;
|
|
5912
|
+
for (let i = 0; i < time.length; ++i) {
|
|
5913
|
+
worst = Math.max(worst, Math.abs((time[i] / tMax) - (div[i] / dMax)));
|
|
5914
|
+
}
|
|
5915
|
+
return worst;
|
|
5916
|
+
}
|
|
5917
|
+
|
|
5918
|
+
// Below this the two views are the same picture and the switch is noise.
|
|
5919
|
+
// influenza.tree measures 0.003, nextstrain-ncov.json 0.248.
|
|
5920
|
+
const BRANCH_METRIC_SHIFT_MIN = 0.02;
|
|
5921
|
+
|
|
5922
|
+
function divergenceDiffersFromTime(root) {
|
|
5923
|
+
return layoutShiftBetweenMetrics(root) > BRANCH_METRIC_SHIFT_MIN;
|
|
5924
|
+
}
|
|
5925
|
+
|
|
5926
|
+
// Which metric the CURRENT branch lengths hold -- a different question
|
|
5927
|
+
// from whether the two metrics differ, and it needs the pair-by-pair
|
|
5928
|
+
// comparison: after forester.applyTimeBranchLengths every branch length is
|
|
5929
|
+
// exactly its ends' date gap, and after applyDivergenceBranchLengths it is
|
|
5930
|
+
// not. Asked so a switch starts from what is on screen.
|
|
5931
|
+
function branchLengthsAreTime(root) {
|
|
5932
|
+
let pairs = 0;
|
|
5933
|
+
let same = 0;
|
|
5934
|
+
forester.preOrderTraversalAll(root, function (n) {
|
|
5935
|
+
if (!n.children) {
|
|
5936
|
+
return;
|
|
5937
|
+
}
|
|
5938
|
+
let pv = auspiceNodeDate(n);
|
|
5939
|
+
if (pv === null) {
|
|
5940
|
+
return;
|
|
5941
|
+
}
|
|
5942
|
+
for (let i = 0; i < n.children.length; ++i) {
|
|
5943
|
+
let c = n.children[i];
|
|
5944
|
+
let cv = auspiceNodeDate(c);
|
|
5945
|
+
let bl = c.branch_length;
|
|
5946
|
+
if (cv === null || typeof bl !== 'number' || !isFinite(bl)) {
|
|
5947
|
+
continue;
|
|
5948
|
+
}
|
|
5949
|
+
++pairs;
|
|
5950
|
+
let scale = Math.max(Math.abs(bl), Math.abs(cv - pv), 1e-9);
|
|
5951
|
+
if (Math.abs(Math.abs(cv - pv) - bl) <= (scale * 1e-6)) {
|
|
5952
|
+
++same;
|
|
5953
|
+
}
|
|
5954
|
+
}
|
|
5955
|
+
});
|
|
5956
|
+
return pairs > 0 && (same * 20) >= (pairs * 19);
|
|
5957
|
+
}
|
|
5958
|
+
|
|
5959
|
+
// Whether the dates run the way the time view needs them to: a CALENDAR
|
|
5960
|
+
// date increases toward the tips, and setDeltaBranchLengths takes
|
|
5961
|
+
// child - parent and clamps at 0. A BEAST height runs the other way -- it
|
|
5962
|
+
// is an age, largest at the root -- so a tree still stating heights would
|
|
5963
|
+
// get a branch length of 0 everywhere and collapse to a point. Measured,
|
|
5964
|
+
// not assumed: forester.convertHeightsToDates turns heights into calendar
|
|
5965
|
+
// dates, and a tree it REFUSED still states ages.
|
|
5966
|
+
function timeIncreasesTowardTips(root) {
|
|
5967
|
+
let up = 0;
|
|
5968
|
+
let pairs = 0;
|
|
5969
|
+
forester.preOrderTraversalAll(root, function (n) {
|
|
5970
|
+
if (!n.children) {
|
|
5971
|
+
return;
|
|
5972
|
+
}
|
|
5973
|
+
let pv = auspiceNodeDate(n);
|
|
5974
|
+
if (pv === null) {
|
|
5975
|
+
return;
|
|
5976
|
+
}
|
|
5977
|
+
for (let i = 0; i < n.children.length; ++i) {
|
|
5978
|
+
let cv = auspiceNodeDate(n.children[i]);
|
|
5979
|
+
if (cv === null || cv === pv) {
|
|
5980
|
+
continue;
|
|
5981
|
+
}
|
|
5982
|
+
++pairs;
|
|
5983
|
+
if (cv > pv) {
|
|
5984
|
+
++up;
|
|
5985
|
+
}
|
|
5986
|
+
}
|
|
5987
|
+
});
|
|
5988
|
+
return pairs > 0 && (up * 2) > pairs;
|
|
5989
|
+
}
|
|
5990
|
+
|
|
5991
|
+
/**
|
|
5992
|
+
* Which metric the tree's branch lengths currently state. An Auspice build
|
|
5993
|
+
* arrives in the time view (its parser writes date deltas); a BEAST or
|
|
5994
|
+
* Newick file arrives stating whatever it was written with, which is the
|
|
5995
|
+
* divergence view. Asked rather than assumed, so a toggle starts from
|
|
5996
|
+
* what is actually on screen.
|
|
5997
|
+
*
|
|
5998
|
+
* @param phy the tree
|
|
5999
|
+
* @returns {string} 'time' or 'divergence'
|
|
6000
|
+
*/
|
|
6001
|
+
forester.branchLengthScale = function (phy) {
|
|
6002
|
+
let root = forester.getTreeRoot(phy);
|
|
6003
|
+
return (root && branchLengthsAreTime(root)) ? 'time' : 'divergence';
|
|
6004
|
+
};
|
|
6005
|
+
|
|
6006
|
+
/**
|
|
6007
|
+
* True when the tree carries BOTH a time signal (dated nodes, running the
|
|
6008
|
+
* calendar way) and a divergence signal that says something different, so
|
|
6009
|
+
* a time <-> divergence toggle is meaningful. Auspice states divergence as
|
|
6010
|
+
* nextstrain:div; a BEAST tree states it as its branch lengths, which on a
|
|
6011
|
+
* real one differ from the dates on nearly every branch (influenza.tree:
|
|
6012
|
+
* 1344 of 1372).
|
|
6013
|
+
*
|
|
6014
|
+
* @param phy the tree
|
|
6015
|
+
* @returns {boolean}
|
|
6016
|
+
*/
|
|
5057
6017
|
forester.hasTimeAndDivergence = function (phy) {
|
|
5058
6018
|
let root = forester.getTreeRoot(phy);
|
|
5059
|
-
|
|
6019
|
+
if (!root || !auspiceHasAnyDate(root) || !timeIncreasesTowardTips(root)) {
|
|
6020
|
+
return false;
|
|
6021
|
+
}
|
|
6022
|
+
// auspiceHasAnyDiv is true for every tree once the divergence has been
|
|
6023
|
+
// captured, so it cannot decide this on its own -- it only says a
|
|
6024
|
+
// divergence measure EXISTS. Whether it says anything different is the
|
|
6025
|
+
// shift test, and that is the question worth asking of both kinds of
|
|
6026
|
+
// tree.
|
|
6027
|
+
return auspiceHasAnyDiv(root) && divergenceDiffersFromTime(root);
|
|
5060
6028
|
};
|
|
5061
6029
|
|
|
5062
6030
|
// A number that is not NaN. It used to test only for null, undefined and
|
|
@@ -5669,12 +6637,18 @@
|
|
|
5669
6637
|
// so the two programs write the same table: name (always), the first
|
|
5670
6638
|
// taxonomy's scientific name, common name, code, id and rank, the first
|
|
5671
6639
|
// sequence's name, gene name, symbol, accession and type, the branch
|
|
5672
|
-
// length, then one column per property ref, sorted, holding its
|
|
5673
|
-
//
|
|
5674
|
-
// cannot key the rows (one blank or
|
|
5675
|
-
// first, from idOf(tip, index) or the
|
|
5676
|
-
// inside a value become spaces. A
|
|
5677
|
-
// 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.
|
|
5678
6652
|
forester.externalNodeDataTable = function (tips, idOf) {
|
|
5679
6653
|
tips = tips || [];
|
|
5680
6654
|
if (tips.length === 0) {
|
|
@@ -5731,8 +6705,17 @@
|
|
|
5731
6705
|
});
|
|
5732
6706
|
Array.from(refs).sort().forEach(function (ref) {
|
|
5733
6707
|
add(ref, function (n) {
|
|
5734
|
-
|
|
5735
|
-
|
|
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('; ');
|
|
5736
6719
|
});
|
|
5737
6720
|
});
|
|
5738
6721
|
return {
|
|
@@ -7104,11 +8087,17 @@
|
|
|
7104
8087
|
let agreeingTips = [];
|
|
7105
8088
|
let latestStart = -Infinity;
|
|
7106
8089
|
let earliestEnd = Infinity;
|
|
8090
|
+
// the dates the agreeing tips actually STATE, un-widened: their labels
|
|
8091
|
+
// plus their heights, with no tolerance added
|
|
8092
|
+
let offerLo = -Infinity;
|
|
8093
|
+
let offerHi = Infinity;
|
|
7107
8094
|
ranges.forEach(function (r) {
|
|
7108
8095
|
if (heightDateAllows(r, mid)) {
|
|
7109
8096
|
agreeingTips.push(r);
|
|
7110
8097
|
latestStart = Math.max(latestStart, r[0]);
|
|
7111
8098
|
earliestEnd = Math.min(earliestEnd, r[1]);
|
|
8099
|
+
offerLo = Math.max(offerLo, r[0] + r[2]);
|
|
8100
|
+
offerHi = Math.min(offerHi, r[1] + r[2]);
|
|
7112
8101
|
}
|
|
7113
8102
|
});
|
|
7114
8103
|
let agreeing = agreeingTips.length;
|
|
@@ -7118,7 +8107,20 @@
|
|
|
7118
8107
|
if (latestStart <= earliestEnd) {
|
|
7119
8108
|
return null; // every agreeing label range overlaps every other
|
|
7120
8109
|
}
|
|
7121
|
-
|
|
8110
|
+
// The anchor is clamped into what the agreeing tips actually STATE,
|
|
8111
|
+
// not into the stretch the tolerance opened up. The tolerance decides
|
|
8112
|
+
// AGREEMENT -- how far a tip may miss and still count -- and must not
|
|
8113
|
+
// then place the date: clamping into the widened stretch let the answer
|
|
8114
|
+
// drift by up to the tolerance, about four days. Found by the desktop
|
|
8115
|
+
// on ground truth: three Nextstrain .nwk trees whose .nexus siblings
|
|
8116
|
+
// carry the real dates were out by 4.15, 3.96 and 2.92 days, and land
|
|
8117
|
+
// within half a day after this (2026-09-17).
|
|
8118
|
+
//
|
|
8119
|
+
// Where the agreeing tips overlap only BECAUSE of the tolerance and
|
|
8120
|
+
// state nothing in common, there is nothing to clamp into and the
|
|
8121
|
+
// precise-label median stands.
|
|
8122
|
+
let median = heightDatePrecisestMedian(agreeingTips);
|
|
8123
|
+
let present = (offerLo <= offerHi) ? Math.max(offerLo, Math.min(offerHi, median)) : median;
|
|
7122
8124
|
return {present: heightDateRound5(present), agreeing: agreeing, compared: compared};
|
|
7123
8125
|
};
|
|
7124
8126
|
|