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/forester.js CHANGED
@@ -20,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.9.0
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 null;
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
- // True when the tree carries BOTH a time signal (a dated node) AND a
5055
- // divergence signal (a nextstrain:div property), so the toggle is
5056
- // meaningful at all.
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
- return auspiceHasAnyDate(root) && auspiceHasAnyDiv(root);
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 first
5673
- // value. A column no tip has a value for is left out. When the tip names
5674
- // cannot key the rows (one blank or repeated), a node_id column comes
5675
- // first, from idOf(tip, index) or the row number. Tabs and line breaks
5676
- // inside a value become spaces. A property keeps its ref as the header,
5677
- // so the table joins back onto a tree with joinMetadataTable.
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
- let p = (n.properties || []).filter(function (q) { return q.ref === ref; })[0];
5735
- return p ? p.value : '';
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
- let present = Math.max(best[0], Math.min(best[1], heightDatePrecisestMedian(agreeingTips)));
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