archaeopteryx 3.8.0 → 3.9.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/archaeopteryx.js CHANGED
@@ -20,8 +20,8 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.8.0
24
- // 2026-09-10
23
+ // v 3.9.0
24
+ // 2026-09-17
25
25
  //
26
26
  // Archaeopteryx.js is a software tool for the visualization and
27
27
  // analysis of highly annotated phylogenetic trees.
@@ -103,7 +103,7 @@ function (root, d3, forester, phyloXml) {
103
103
  // IIFE's own function name -- a plain object says what it is.)
104
104
  let archaeopteryx = {};
105
105
 
106
- const VERSION = '3.8.0';
106
+ const VERSION = '3.9.0';
107
107
  const WEBSITE = 'https://cmzmasek.github.io/archaeopteryx-js/';
108
108
  const DESKTOP_WEBSITE = 'https://cmzmasek.github.io/archaeopteryx/';
109
109
  const SOURCE_WEBSITE = 'https://github.com/cmzmasek/archaeopteryx-js';
@@ -5642,6 +5642,16 @@ function (root, d3, forester, phyloXml) {
5642
5642
  + ' is empty or illegally formatted');
5643
5643
  }
5644
5644
  });
5645
+ // A tip-dated BEAST tree states its node ages as unitless `height`s --
5646
+ // time before the youngest tip -- which say nothing about WHEN. Where
5647
+ // the tip labels carry sampling dates and they agree on the date of
5648
+ // height 0, the tree is placed in calendar time here, before anything
5649
+ // reads a date: the axis, Color-by and the visualization candidates all
5650
+ // depend on it. A tree that does not qualify is left exactly as it was,
5651
+ // and a tree whose dates carry a unit is never touched. The desktop
5652
+ // converts at the same point, next to its internal-label policy
5653
+ // (their HeightDateConverter, 0.11.151).
5654
+ forester.convertLoadedHeightsToDates(trees);
5645
5655
  // the two view keys are checked up here with the other arguments,
5646
5656
  // before anything is touched (and where Node can test it)
5647
5657
  if (config && config.view !== undefined && config.view !== null && typeof config.view !== 'object') {
@@ -9122,8 +9132,11 @@ function (root, d3, forester, phyloXml) {
9122
9132
 
9123
9133
  // ---- HPD age bars (internal) + tip bars: fossil ranges, or sampling dates ----
9124
9134
  forEachDisplayed(function (d) {
9125
- if (!d.date || typeof d.date.minimum !== 'number' || typeof d.date.maximum !== 'number'
9126
- || d.y === undefined) {
9135
+ // forester.isGenuineDateInterval, not a bare min/max test: the
9136
+ // bars are floored at 1px below, so a bound pair that differs only
9137
+ // by floating-point noise would draw as a visible bracket. This
9138
+ // guard is on EVERY node, tip and internal, geologic and calendar.
9139
+ if (!forester.isGenuineDateInterval(d.date) || d.y === undefined) {
9127
9140
  return;
9128
9141
  }
9129
9142
  let min = d.date.minimum;
@@ -9144,14 +9157,12 @@ function (root, d3, forester, phyloXml) {
9144
9157
  // as one below. On CALENDAR time it is the uncertainty of a
9145
9158
  // sampling date -- a virus sample dated only to its month or year
9146
9159
  // -- which is the same kind of thing as an internal node's age
9147
- // interval and is drawn the same way, slimmer, and only when it
9148
- // HAS a width: a tip dated to the day states {d,d}, and a capped
9149
- // tick on every such tip is what made the readers throw tip
9150
- // intervals away until 2026-09-17 (Christian; the desktop alike).
9160
+ // interval and is drawn the same way, slimmer. Either way it is
9161
+ // drawn only when it HAS a width: a tip dated to the day states
9162
+ // {d,d}, and a capped tick on every such tip is what made the
9163
+ // readers throw tip intervals away until 2026-09-17 (Christian;
9164
+ // the desktop alike). The width test is the entry guard above.
9151
9165
  let sampledTip = !d.children && info.type === 'calendar';
9152
- if (sampledTip && !(max > min)) {
9153
- return;
9154
- }
9155
9166
  if (d.children) {
9156
9167
  g.append('rect').attr('x', left).attr('y', y - 3.5)
9157
9168
  .attr('width', w).attr('height', 7)
@@ -12191,7 +12202,10 @@ function (root, d3, forester, phyloXml) {
12191
12202
  return null;
12192
12203
  }
12193
12204
  let hasValue = typeof date.value === 'number';
12194
- let hasRange = typeof date.minimum === 'number' && typeof date.maximum === 'number';
12205
+ // the same predicate the bars use: a bound pair that differs only by
12206
+ // floating-point noise is one number printed twice, and reading it as a
12207
+ // range put "[12.059999999999942 - 12.059999999999949]" in the tooltip
12208
+ let hasRange = forester.isGenuineDateInterval(date);
12195
12209
  let s = hasValue ? String(date.value) : '';
12196
12210
  if (hasRange) {
12197
12211
  s += ' [' + date.minimum + ' - ' + date.maximum + ']';
package/forester.js CHANGED
@@ -20,8 +20,8 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.8.0
24
- // 2026-09-10
23
+ // v 3.9.0
24
+ // 2026-09-17
25
25
  //
26
26
  // forester.js is a general suite for dealing with phylogenetic trees.
27
27
  //
@@ -6519,9 +6519,17 @@
6519
6519
  // ---- time-tree detection -------------------------------------------
6520
6520
  // Two date conventions: GEOLOGIC ages (Ma before present, decreasing
6521
6521
  // toward the tips) and CALENDAR years (increasing toward the tips).
6522
- // Decided from the <date> unit attributes, with a magnitude fallback for
6523
- // unitless dates: values mostly in [1500, 2200] read as years; values
6524
- // spanning from large down toward ~0 read as ages.
6522
+ // Decided from the <date> unit attributes and NOTHING ELSE. A unitless
6523
+ // tree gets no axis: guessing from magnitude read every tip-dated BEAST
6524
+ // tree as geologic, because BEAST states a node's age as a unitless
6525
+ // `height` and an influenza tree spanning 12 years looks exactly like one
6526
+ // spanning 12 Ma. It drew Miocene/Pliocene bands and a "Ma" ruler under
6527
+ // tips labelled 1993..2005 -- a confident false claim across the whole
6528
+ // figure, which is worse than no axis. Measured over every tree we ship:
6529
+ // the guess decided 3 trees and got all 3 wrong, every other axis comes
6530
+ // from a unit, and none relied on the old [1500, 2200] year rule. The
6531
+ // desktop dropped the same rule on 2026-09-10 (their e9ac62ec) on the
6532
+ // same evidence; Christian, 2026-09-17, both programs.
6525
6533
  const GEO_DATE_UNITS = {
6526
6534
  mya: 1, ma: 1, myr: 1, myrs: 1, my: 1, ga: 1, gya: 1, bya: 1, kya: 1,
6527
6535
  'million years': 1, 'billion years': 1
@@ -6531,6 +6539,40 @@
6531
6539
  'calendar year': 1, 'calendar years': 1
6532
6540
  };
6533
6541
 
6542
+ // A date's bounds state a real interval only when they differ by more than
6543
+ // floating-point noise. TreeAnnotator writes
6544
+ // height_95%_HPD={9.0,9.000000000000004} on a tip it dated EXACTLY: one
6545
+ // number printed twice through binary floating point, not a width. Read as
6546
+ // a width it put a fossil-range bracket on 686 of influenza.tree's 687
6547
+ // tips. The tolerance is RELATIVE because the noise is: a bound near 2000
6548
+ // carries more of it than one near 0. Nothing real is this narrow -- a
6549
+ // single day is 0.0027 of a calendar year, while 1e-9 of 2000 is about a
6550
+ // minute. Both programs had this gap, on two paths each (an auto-enable
6551
+ // predicate with no tolerance, and a painter with no width test at all).
6552
+ const DATE_INTERVAL_REL_TOL = 1e-9;
6553
+
6554
+ /**
6555
+ * Whether a date's minimum/maximum are a genuine interval rather than one
6556
+ * value printed twice. Every reader of date bounds must ask this, drawing
6557
+ * included: a bar floored at 1px draws noise as a visible bracket.
6558
+ *
6559
+ * @param date a node's date object
6560
+ * @returns {boolean}
6561
+ */
6562
+ forester.isGenuineDateInterval = function (date) {
6563
+ if (!date || typeof date.minimum !== 'number' || typeof date.maximum !== 'number'
6564
+ || !isFinite(date.minimum) || !isFinite(date.maximum)) {
6565
+ return false;
6566
+ }
6567
+ let scale = Math.max(Math.abs(date.minimum), Math.abs(date.maximum), 1);
6568
+ // the ABSOLUTE difference: bounds handed over in the wrong order still
6569
+ // state a width, and every painter already normalises the order before
6570
+ // drawing. The desktop's AptxUtil.hasDateIntervalWidth reads it the
6571
+ // same way, down to this constant -- the two programs must not draw
6572
+ // different figures from one file (their message, 2026-09-17).
6573
+ return Math.abs(date.maximum - date.minimum) > (scale * DATE_INTERVAL_REL_TOL);
6574
+ };
6575
+
6534
6576
  /**
6535
6577
  * Whether the tree's branch lengths are time: most of its internal nodes
6536
6578
  * carry a date, and at least two do -- BEAST node heights, Nextstrain
@@ -6563,9 +6605,8 @@
6563
6605
  // presentDate (calendar) are both the LARGEST date value -- the oldest
6564
6606
  // node for ages, the most recent tip for years.
6565
6607
  forester.timeAxisInfo = function (root) {
6566
- let values = [];
6608
+ let valued = 0;
6567
6609
  let maxVal = -Infinity; // running, not Math.max.apply: 150k dated tips overflow the call stack
6568
- let minVal = Infinity;
6569
6610
  let geoUnits = 0;
6570
6611
  let calUnits = 0;
6571
6612
  let internal = 0;
@@ -6585,7 +6626,7 @@
6585
6626
  if (!d) {
6586
6627
  return;
6587
6628
  }
6588
- let interval = (typeof d.minimum === 'number') && (typeof d.maximum === 'number');
6629
+ let interval = forester.isGenuineDateInterval(d);
6589
6630
  if (interval) {
6590
6631
  if (isExt) {
6591
6632
  hasExternalIntervals = true;
@@ -6596,13 +6637,10 @@
6596
6637
  if (typeof d.value !== 'number' || !isFinite(d.value)) {
6597
6638
  return; // 1e400 parses to Infinity and must never reach the tick loops
6598
6639
  }
6599
- values.push(d.value);
6640
+ ++valued;
6600
6641
  if (d.value > maxVal) {
6601
6642
  maxVal = d.value;
6602
6643
  }
6603
- if (d.value < minVal) {
6604
- minVal = d.value;
6605
- }
6606
6644
  if (isExt) {
6607
6645
  ++datedExternal;
6608
6646
  } else {
@@ -6618,25 +6656,16 @@
6618
6656
  }
6619
6657
  });
6620
6658
  let type = null;
6621
- if (values.length > 0) {
6659
+ if (valued > 0) {
6622
6660
  if (geoUnits > 0 && geoUnits >= calUnits) {
6623
6661
  type = 'geologic';
6624
6662
  } else if (calUnits > 0) {
6625
6663
  type = 'calendar';
6626
- } else {
6627
- let calendarish = values.filter(function (v) {
6628
- return v >= 1500 && v <= 2200;
6629
- }).length;
6630
- if (calendarish * 2 > values.length) {
6631
- type = 'calendar';
6632
- } else if (maxVal > 10 && minVal <= maxVal * 0.05) {
6633
- type = 'geologic';
6634
- }
6635
6664
  }
6636
6665
  }
6637
6666
  let dated = (datedInternal >= 2 && (datedInternal * 2) > internal)
6638
6667
  || (datedExternal >= 2 && (datedExternal * 2) > external);
6639
- let maxValue = values.length > 0 ? maxVal : 0;
6668
+ let maxValue = valued > 0 ? maxVal : 0;
6640
6669
  return {
6641
6670
  type: type,
6642
6671
  rootAge: type === 'geologic' ? maxValue : 0,
@@ -6647,6 +6676,540 @@
6647
6676
  };
6648
6677
  };
6649
6678
 
6679
+ // ---- dates in a tip's LABEL, and BEAST heights ----------------------
6680
+ // A tip-dated BEAST tree states a node's age as a unitless `height`: time
6681
+ // before the youngest tip, in whatever unit the run happened to use. The
6682
+ // sampling dates are usually in the tip labels themselves
6683
+ // (A_duck_Guangdong_12_2000, NewYork_705_1994.1, EBOV|KR817226|2014-06-10).
6684
+ // If the heights are YEARS, every tip's label date plus its height is the
6685
+ // same calendar date -- the date of height 0. That agreement is the whole
6686
+ // evidence: heights in months or days, or a strain number mistaken for a
6687
+ // date, break it and the tree is left alone.
6688
+ //
6689
+ // Ported from the desktop's TipDateExtractor and HeightDateConverter
6690
+ // (their 0.11.151, commit 6755ba12), whose rules and constants these are.
6691
+ // Christian, 2026-09-17: port it, after they commit.
6692
+
6693
+ // A BARE year or decimal year has to look like a plausible sampling year or
6694
+ // a 4-digit strain number would date the tip. An explicit ISO / month-name
6695
+ // / slash date is strong evidence and gets a looser window.
6696
+ const TIP_DATE_MIN_BARE_YEAR = 1900;
6697
+ const TIP_DATE_MAX_BARE_YEAR = 2100;
6698
+ const TIP_DATE_MIN_YEAR = 1000;
6699
+ const TIP_DATE_MAX_YEAR = 2999;
6700
+
6701
+ // Global, so each matcher can take the RIGHTMOST match; lastIndex is reset
6702
+ // before every use, because these are shared.
6703
+ const TIP_DATE_ISO_FULL = /(?<![0-9])(\d{4})-(\d{1,2})-(\d{1,2})(?![0-9])/g;
6704
+ const TIP_DATE_MONTH_FULL = /(?<![A-Za-z0-9])(\d{1,2})[-\s]([A-Za-z]{3,9})[-\s](\d{4})(?![0-9])/g;
6705
+ const TIP_DATE_SLASH_FULL = /(?<![0-9])(\d{1,4})[/.](\d{1,2})[/.](\d{1,4})(?![0-9])/g;
6706
+ const TIP_DATE_ISO_PARTIAL = /(?<![0-9])(\d{4})-(\d{1,2})(?![0-9-])/g;
6707
+ const TIP_DATE_MONTH_PARTIAL = /(?<![A-Za-z0-9])([A-Za-z]{3,9})[-\s](\d{4})(?![0-9])/g;
6708
+ const TIP_DATE_DECIMAL_YEAR = /(?<![0-9.])(\d{4}\.\d+)(?![0-9])/g;
6709
+ const TIP_DATE_BARE_YEAR = /(?<![0-9.])(\d{4})(?![0-9.])/g;
6710
+
6711
+ const TIP_DATE_MONTHS_ABBR = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
6712
+ const TIP_DATE_MONTHS_FULL = ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august',
6713
+ 'september', 'october', 'november', 'december'];
6714
+
6715
+ function tipDateYearLength(y) {
6716
+ return (((y % 4) === 0 && (y % 100) !== 0) || (y % 400) === 0) ? 366 : 365;
6717
+ }
6718
+
6719
+ function tipDateMonthLength(y, m) {
6720
+ if (m === 2) {
6721
+ return tipDateYearLength(y) === 366 ? 29 : 28;
6722
+ }
6723
+ return ((m === 4) || (m === 6) || (m === 9) || (m === 11)) ? 30 : 31;
6724
+ }
6725
+
6726
+ function tipDateDayOfYear(y, m, d) {
6727
+ let doy = d;
6728
+ for (let i = 1; i < m; ++i) {
6729
+ doy += tipDateMonthLength(y, i);
6730
+ }
6731
+ return doy;
6732
+ }
6733
+
6734
+ // 1-12 for an English month name -- the EXACT 3-letter abbreviation or the
6735
+ // full name, case-insensitively; 0 otherwise. Exact, not a prefix, is what
6736
+ // stops "Marburg", "Junin" or "Decatur" being read as a month.
6737
+ function tipDateMonthNumber(name) {
6738
+ if (!name) {
6739
+ return 0;
6740
+ }
6741
+ let key = String(name).toLowerCase();
6742
+ for (let i = 0; i < 12; ++i) {
6743
+ if (TIP_DATE_MONTHS_ABBR[i] === key || TIP_DATE_MONTHS_FULL[i] === key) {
6744
+ return i + 1;
6745
+ }
6746
+ }
6747
+ return 0;
6748
+ }
6749
+
6750
+ /**
6751
+ * The decimal year of a (year, month, day): a month or day of 0 means "not
6752
+ * given" and maps to the middle of the interval it leaves open (a year to
6753
+ * .5, a month to mid-month, a day to mid-day). Leap-year aware. The
6754
+ * midpoint convention is BEAST's and TreeTime's.
6755
+ *
6756
+ * @param year
6757
+ * @param month 1-12, or <= 0 for "not given"
6758
+ * @param day 1-31, or <= 0 for "not given"
6759
+ * @returns {number}
6760
+ */
6761
+ forester.tipDateToDecimalYear = function (year, month, day) {
6762
+ if (month <= 0) {
6763
+ return year + 0.5;
6764
+ }
6765
+ let len = tipDateYearLength(year);
6766
+ if (day <= 0) {
6767
+ let firstDoy = tipDateDayOfYear(year, month, 1);
6768
+ return year + (((firstDoy - 1) + (tipDateMonthLength(year, month) / 2)) / len);
6769
+ }
6770
+ return year + ((tipDateDayOfYear(year, month, day) - 0.5) / len);
6771
+ };
6772
+
6773
+ // A full date, validated (an impossible one like 2021-13-40 or Feb 30 is
6774
+ // not a date). The RANGE is the whole day the label names.
6775
+ function tipDateFromYmd(year, month, day, matched, format, ambiguous) {
6776
+ if ((year < TIP_DATE_MIN_YEAR) || (year > TIP_DATE_MAX_YEAR) || (month < 1) || (month > 12)
6777
+ || (day < 1) || (day > tipDateMonthLength(year, month))) {
6778
+ return null;
6779
+ }
6780
+ let len = tipDateYearLength(year);
6781
+ let doy = tipDateDayOfYear(year, month, day);
6782
+ return {
6783
+ decimalYear: forester.tipDateToDecimalYear(year, month, day),
6784
+ matchedText: matched, precision: 'day', formatLabel: format, ambiguous: ambiguous === true,
6785
+ rangeStart: year + ((doy - 1) / len), rangeEnd: year + (doy / len)
6786
+ };
6787
+ }
6788
+
6789
+ // A year-month date: the day is unknown, so the range is the whole month.
6790
+ function tipDateFromYm(year, month, matched, format) {
6791
+ if ((year < TIP_DATE_MIN_YEAR) || (year > TIP_DATE_MAX_YEAR) || (month < 1) || (month > 12)) {
6792
+ return null;
6793
+ }
6794
+ let len = tipDateYearLength(year);
6795
+ let start = year + ((tipDateDayOfYear(year, month, 1) - 1) / len);
6796
+ return {
6797
+ decimalYear: forester.tipDateToDecimalYear(year, month, 0),
6798
+ matchedText: matched, precision: 'month', formatLabel: format, ambiguous: false,
6799
+ rangeStart: start, rangeEnd: start + (tipDateMonthLength(year, month) / len)
6800
+ };
6801
+ }
6802
+
6803
+ // Every matcher keeps the LAST (rightmost) valid match in the label.
6804
+ function tipDateEachMatch(re, s, fn) {
6805
+ let found = null;
6806
+ let m;
6807
+ re.lastIndex = 0;
6808
+ while ((m = re.exec(s)) !== null) {
6809
+ let d = fn(m);
6810
+ if (d) {
6811
+ found = d;
6812
+ }
6813
+ if (m.index === re.lastIndex) {
6814
+ ++re.lastIndex; // a zero-width match would spin forever
6815
+ }
6816
+ }
6817
+ return found;
6818
+ }
6819
+
6820
+ /**
6821
+ * The date a tip label states, or null. The most SPECIFIC format wins
6822
+ * (a full date before a year-month before a bare year), and within one
6823
+ * format the rightmost match does -- a label usually ends with its date.
6824
+ *
6825
+ * The result carries the decimal year (midpoint convention) AND the
6826
+ * calendar RANGE the label actually states: `2021` is all of 2021,
6827
+ * `2021-03` all of March, a day that whole day, and a decimal year its
6828
+ * last written digit (`1993.1` is 1993.05 to 1993.15). The range is what
6829
+ * makes different programs' conventions comparable -- BEAST counts a day
6830
+ * from its start, we read a label at its middle, half a day apart.
6831
+ *
6832
+ * @param label a tip's name
6833
+ * @param monthFirst true to read an ambiguous numeric date (both fields
6834
+ * <= 12) month-first; the default is day-first, as the desktop's
6835
+ * converter always asks for
6836
+ * @returns {object|null}
6837
+ */
6838
+ forester.parseTipLabelDate = function (label, monthFirst) {
6839
+ if (!label) {
6840
+ return null;
6841
+ }
6842
+ let s = String(label);
6843
+ let m = tipDateEachMatch(TIP_DATE_ISO_FULL, s, function (g) {
6844
+ return tipDateFromYmd(parseInt(g[1], 10), parseInt(g[2], 10), parseInt(g[3], 10), g[0],
6845
+ 'ISO date (YYYY-MM-DD)', false);
6846
+ });
6847
+ if (m) {
6848
+ return m;
6849
+ }
6850
+ m = tipDateEachMatch(TIP_DATE_MONTH_FULL, s, function (g) {
6851
+ let mon = tipDateMonthNumber(g[2]);
6852
+ return mon > 0
6853
+ ? tipDateFromYmd(parseInt(g[3], 10), mon, parseInt(g[1], 10), g[0], 'month-name date', false)
6854
+ : null;
6855
+ });
6856
+ if (m) {
6857
+ return m;
6858
+ }
6859
+ m = tipDateEachMatch(TIP_DATE_SLASH_FULL, s, function (g) {
6860
+ let a = parseInt(g[1], 10);
6861
+ let b = parseInt(g[2], 10);
6862
+ let c = parseInt(g[3], 10);
6863
+ let year = -1;
6864
+ let month = -1;
6865
+ let day = -1;
6866
+ let ambiguous = false;
6867
+ if (g[1].length === 4) { // YYYY/MM/DD -- unambiguous
6868
+ year = a;
6869
+ month = b;
6870
+ day = c;
6871
+ } else if (g[3].length === 4) { // D/M/YYYY or M/D/YYYY
6872
+ year = c;
6873
+ if (a > 12) {
6874
+ day = a;
6875
+ month = b;
6876
+ } else if (b > 12) {
6877
+ month = a;
6878
+ day = b;
6879
+ } else { // both <= 12: genuinely ambiguous
6880
+ ambiguous = true;
6881
+ if (monthFirst === true) {
6882
+ month = a;
6883
+ day = b;
6884
+ } else {
6885
+ day = a;
6886
+ month = b;
6887
+ }
6888
+ }
6889
+ }
6890
+ return year > 0 ? tipDateFromYmd(year, month, day, g[0], 'numeric date', ambiguous) : null;
6891
+ });
6892
+ if (m) {
6893
+ return m;
6894
+ }
6895
+ m = tipDateEachMatch(TIP_DATE_ISO_PARTIAL, s, function (g) {
6896
+ return tipDateFromYm(parseInt(g[1], 10), parseInt(g[2], 10), g[0], 'ISO year-month (YYYY-MM)');
6897
+ });
6898
+ if (m) {
6899
+ return m;
6900
+ }
6901
+ m = tipDateEachMatch(TIP_DATE_MONTH_PARTIAL, s, function (g) {
6902
+ let mon = tipDateMonthNumber(g[1]);
6903
+ return mon > 0 ? tipDateFromYm(parseInt(g[2], 10), mon, g[0], 'month-name year') : null;
6904
+ });
6905
+ if (m) {
6906
+ return m;
6907
+ }
6908
+ m = tipDateEachMatch(TIP_DATE_DECIMAL_YEAR, s, function (g) {
6909
+ let v = parseFloat(g[1]);
6910
+ let y = Math.floor(v);
6911
+ if ((y < TIP_DATE_MIN_BARE_YEAR) || (y > TIP_DATE_MAX_BARE_YEAR)) {
6912
+ return null;
6913
+ }
6914
+ // the range is the last digit the label actually wrote
6915
+ let half = 0.5 * Math.pow(10, -(g[1].length - g[1].indexOf('.') - 1));
6916
+ return {
6917
+ decimalYear: v, matchedText: g[1], precision: 'day', formatLabel: 'decimal year',
6918
+ ambiguous: false, rangeStart: v - half, rangeEnd: v + half
6919
+ };
6920
+ });
6921
+ if (m) {
6922
+ return m;
6923
+ }
6924
+ return tipDateEachMatch(TIP_DATE_BARE_YEAR, s, function (g) {
6925
+ let y = parseInt(g[1], 10);
6926
+ if ((y < TIP_DATE_MIN_BARE_YEAR) || (y > TIP_DATE_MAX_BARE_YEAR)) {
6927
+ return null;
6928
+ }
6929
+ return {
6930
+ decimalYear: y + 0.5, matchedText: g[1], precision: 'year', formatLabel: 'year',
6931
+ ambiguous: false, rangeStart: y, rangeEnd: y + 1
6932
+ };
6933
+ });
6934
+ };
6935
+
6936
+ // ---- unitless BEAST heights -> calendar dates -----------------------
6937
+ // How far a tip's label date plus its height may miss, in years: enough for
6938
+ // the one-day differences between programs' decimal-year conventions, and
6939
+ // far below the gaps that heights in months or days open up.
6940
+ const HEIGHT_DATE_TOLERANCE_YEARS = 0.01;
6941
+ // at least 19 of every 20 compared tips must agree on the date of height 0
6942
+ const HEIGHT_DATE_MIN_AGREEING_NUM = 19;
6943
+ const HEIGHT_DATE_MIN_AGREEING_DEN = 20;
6944
+
6945
+ // 5 decimals, HALF_UP -- away from zero, which Math.round is not for a
6946
+ // negative half (it takes -0.5 to -0). The desktop rounds with BigDecimal
6947
+ // HALF_UP, and the anchor has to land on the same digits or every date on
6948
+ // the tree shifts.
6949
+ function heightDateRound5(x) {
6950
+ let s = x < 0 ? -1 : 1;
6951
+ return (s * Math.round(Math.abs(x) * 100000)) / 100000;
6952
+ }
6953
+
6954
+ // Whether a compared tip allows height 0 at calendar date x. The tip's
6955
+ // allowed stretch is its LABEL RANGE plus its height, widened by the
6956
+ // tolerance at each end.
6957
+ function heightDateAllows(r, x) {
6958
+ return (x >= ((r[0] + r[2]) - HEIGHT_DATE_TOLERANCE_YEARS))
6959
+ && (x <= (r[1] + r[2] + HEIGHT_DATE_TOLERANCE_YEARS));
6960
+ }
6961
+
6962
+ // The stretch of calendar dates allowed by the MOST tips, as [start, end],
6963
+ // or null when a separate stretch is allowed by as many -- then which date
6964
+ // height 0 is, is undecided, and a tree we cannot place we leave alone. A
6965
+ // sweep over the tips' allowed intervals, closed at both ends.
6966
+ //
6967
+ // The `tied` refusal is LIVE, and a test pins it. I first argued it was
6968
+ // unreachable -- two disjoint stretches would each need 19 tips in 20, so
6969
+ // 18 in 20 would allow both, and since a tip's allowed dates are one
6970
+ // interval those 18 allow everything between, making it one stretch. That
6971
+ // last step is wrong, and the desktop caught it: the 18 do cover the gap,
6972
+ // but a stretch is the MAXIMUM coverage, which is 19, and 18 < 19, so the
6973
+ // gap joins neither maximum and the two stay separate. The shape is
6974
+ // 19 / 18 / 19.
6975
+ //
6976
+ // It is reached by an ordinary curation error: a mostly year-labelled tree
6977
+ // (a bare year is ~1 year wide, so those tips span both candidates) with
6978
+ // two tips at height 0 whose day labels disagree -- a file cannot have two
6979
+ // different youngest tips. Remove this check and such a tree converts at a
6980
+ // fabricated anchor, silently. My own fixture had all its year tips in ONE
6981
+ // year, which made the different-sampling-times rule fire first and hide
6982
+ // the whole thing.
6983
+ function heightDateMostAllowed(ranges) {
6984
+ let events = [];
6985
+ ranges.forEach(function (r) {
6986
+ events.push([(r[0] + r[2]) - HEIGHT_DATE_TOLERANCE_YEARS, 1]);
6987
+ events.push([r[1] + r[2] + HEIGHT_DATE_TOLERANCE_YEARS, -1]);
6988
+ });
6989
+ // a start sorts before an end at the same x, so intervals that merely
6990
+ // touch do overlap
6991
+ events.sort(function (a, b) {
6992
+ return a[0] !== b[0] ? (a[0] - b[0]) : (b[1] - a[1]);
6993
+ });
6994
+ let depth = 0;
6995
+ let best = 0;
6996
+ let start = 0;
6997
+ let end = 0;
6998
+ let open = false;
6999
+ let tied = false;
7000
+ events.forEach(function (e) {
7001
+ if (e[1] > 0) {
7002
+ ++depth;
7003
+ if (depth > best) {
7004
+ best = depth;
7005
+ start = e[0];
7006
+ open = true;
7007
+ tied = false;
7008
+ } else if ((depth === best) && !open) {
7009
+ tied = true;
7010
+ }
7011
+ } else {
7012
+ if (open) {
7013
+ end = e[0];
7014
+ open = false;
7015
+ }
7016
+ --depth;
7017
+ }
7018
+ });
7019
+ return ((best === 0) || tied) ? null : [start, end];
7020
+ }
7021
+
7022
+ // Where the most precisely dated tips put height 0: the median of
7023
+ // (label date + height) over the tips whose label RANGE is at most twice
7024
+ // the narrowest. The middle of the allowed stretch would be pulled about by
7025
+ // the coarse labels -- influenza.tree mixes `1993.11` with `1997` (meaning
7026
+ // the whole of 1997), and that middle, 2005.2525, showed the tip labelled
7027
+ // 1994.1 as 1994.1025.
7028
+ function heightDatePrecisestMedian(tips) {
7029
+ let narrowest = Infinity;
7030
+ tips.forEach(function (r) {
7031
+ narrowest = Math.min(narrowest, r[1] - r[0]);
7032
+ });
7033
+ let values = [];
7034
+ tips.forEach(function (r) {
7035
+ if ((r[1] - r[0]) <= (2 * narrowest)) {
7036
+ values.push(r[3] + r[2]);
7037
+ }
7038
+ });
7039
+ values.sort(function (a, b) {
7040
+ return a - b;
7041
+ });
7042
+ let n = values.length;
7043
+ return ((n % 2) === 1) ? values[(n - 1) / 2] : ((values[(n / 2) - 1] + values[n / 2]) / 2);
7044
+ }
7045
+
7046
+ /**
7047
+ * The calendar date (decimal year) of height 0 for a tip-dated BEAST tree,
7048
+ * or null when the tree does not qualify and must be left alone. Pure.
7049
+ *
7050
+ * Every condition has to hold: no node's date carries a unit (a unit says
7051
+ * what the numbers already are, and this never overrides one); the tree is
7052
+ * a time tree; a strict majority of tips carry both a height and a date in
7053
+ * the label; the date allowed by the most of those tips is allowed by at
7054
+ * least 19 in 20 of them and no separate stretch of dates is allowed by as
7055
+ * many; and the agreeing tips were sampled at different times -- tips all
7056
+ * from one year agree with heights in ANY unit and so prove nothing.
7057
+ *
7058
+ * @param phy a tree
7059
+ * @returns {{present: number, agreeing: number, compared: number}|null}
7060
+ */
7061
+ forester.inferHeightDateAnchor = function (phy) {
7062
+ if (!phy) {
7063
+ return null;
7064
+ }
7065
+ let root = forester.getTreeRoot(phy);
7066
+ if (!root) {
7067
+ return null;
7068
+ }
7069
+ let united = false;
7070
+ forester.preOrderTraversalAll(root, function (n) {
7071
+ if (n.date && n.date.unit && String(n.date.unit).trim().length > 0) {
7072
+ united = true;
7073
+ }
7074
+ });
7075
+ if (united || !forester.isTimeTree(phy)) {
7076
+ return null;
7077
+ }
7078
+ let tips = 0;
7079
+ let ranges = []; // per compared tip: [label start, label end, height, label date]
7080
+ forester.preOrderTraversalAll(root, function (n) {
7081
+ if (n.children && n.children.length > 0) {
7082
+ return;
7083
+ }
7084
+ ++tips;
7085
+ // not "has a date": a value of 0 -- the youngest tip's height -- is
7086
+ // a height, not an absence
7087
+ if (!n.date || typeof n.date.value !== 'number' || !isFinite(n.date.value)) {
7088
+ return;
7089
+ }
7090
+ let m = forester.parseTipLabelDate(n.name);
7091
+ if (m) {
7092
+ ranges.push([m.rangeStart, m.rangeEnd, n.date.value, m.decimalYear]);
7093
+ }
7094
+ });
7095
+ let compared = ranges.length;
7096
+ if ((compared * 2) <= tips) {
7097
+ return null;
7098
+ }
7099
+ let best = heightDateMostAllowed(ranges);
7100
+ if (best === null) {
7101
+ return null;
7102
+ }
7103
+ let mid = (best[0] + best[1]) / 2;
7104
+ let agreeingTips = [];
7105
+ let latestStart = -Infinity;
7106
+ let earliestEnd = Infinity;
7107
+ ranges.forEach(function (r) {
7108
+ if (heightDateAllows(r, mid)) {
7109
+ agreeingTips.push(r);
7110
+ latestStart = Math.max(latestStart, r[0]);
7111
+ earliestEnd = Math.min(earliestEnd, r[1]);
7112
+ }
7113
+ });
7114
+ let agreeing = agreeingTips.length;
7115
+ if ((agreeing * HEIGHT_DATE_MIN_AGREEING_DEN) < (compared * HEIGHT_DATE_MIN_AGREEING_NUM)) {
7116
+ return null;
7117
+ }
7118
+ if (latestStart <= earliestEnd) {
7119
+ return null; // every agreeing label range overlaps every other
7120
+ }
7121
+ let present = Math.max(best[0], Math.min(best[1], heightDatePrecisestMedian(agreeingTips)));
7122
+ return {present: heightDateRound5(present), agreeing: agreeing, compared: compared};
7123
+ };
7124
+
7125
+ /**
7126
+ * Rewrites every node's date as `present - height`, in years, with the
7127
+ * interval bounds SWAPPED: the older height bound is the earlier date.
7128
+ *
7129
+ * @param phy a tree
7130
+ * @param present the date of height 0, from forester.inferHeightDateAnchor
7131
+ */
7132
+ forester.convertHeightsToDates = function (phy, present) {
7133
+ forester.preOrderTraversalAll(forester.getTreeRoot(phy), function (n) {
7134
+ let d = n.date;
7135
+ if (!d) {
7136
+ return;
7137
+ }
7138
+ let hasValue = typeof d.value === 'number' && isFinite(d.value);
7139
+ let value = hasValue ? heightDateRound5(present - d.value) : undefined;
7140
+ // the swap: a LARGER height is further back, so it becomes the
7141
+ // SMALLER (earlier) date
7142
+ let min = (typeof d.maximum === 'number' && isFinite(d.maximum))
7143
+ ? heightDateRound5(present - d.maximum) : undefined;
7144
+ let max = (typeof d.minimum === 'number' && isFinite(d.minimum))
7145
+ ? heightDateRound5(present - d.minimum) : undefined;
7146
+ let next = {};
7147
+ if (d.desc !== undefined) {
7148
+ next.desc = d.desc;
7149
+ }
7150
+ if (value !== undefined) {
7151
+ next.value = value;
7152
+ }
7153
+ if (min !== undefined) {
7154
+ next.minimum = min;
7155
+ }
7156
+ if (max !== undefined) {
7157
+ next.maximum = max;
7158
+ }
7159
+ // a date with no point value keeps an empty unit, as the reader
7160
+ // writes it
7161
+ next.unit = hasValue ? 'year' : '';
7162
+ n.date = next;
7163
+ });
7164
+ };
7165
+
7166
+ /**
7167
+ * The desktop's provenance sentence for a converted tree, which it appends
7168
+ * to the tree's description.
7169
+ *
7170
+ * @param anchor from forester.inferHeightDateAnchor
7171
+ * @param treeName
7172
+ * @param tipCount
7173
+ * @returns {string}
7174
+ */
7175
+ forester.heightDateDescription = function (anchor, treeName, tipCount) {
7176
+ let phrase = treeName ? ('tree named "' + treeName + '" with ') : 'a tree with ';
7177
+ let present = String(anchor.present);
7178
+ return 'Converted the node heights of ' + phrase + tipCount + (tipCount === 1 ? ' tip' : ' tips')
7179
+ + ' to calendar dates: the sampling dates in ' + anchor.agreeing + ' of ' + anchor.compared
7180
+ + ' tip labels put height 0 at ' + present + ', so each date is ' + present + ' minus the height.';
7181
+ };
7182
+
7183
+ /**
7184
+ * Converts every tree of a load whose unitless heights can be placed in
7185
+ * calendar time, appending the provenance sentence to each converted
7186
+ * tree's description. A tree that does not qualify is left exactly as it
7187
+ * was. Returns the number of trees converted.
7188
+ *
7189
+ * @param phys one tree or an array of them
7190
+ * @returns {number}
7191
+ */
7192
+ forester.convertLoadedHeightsToDates = function (phys) {
7193
+ if (!phys) {
7194
+ return 0;
7195
+ }
7196
+ let list = Array.isArray(phys) ? phys : [phys];
7197
+ let converted = 0;
7198
+ list.forEach(function (phy) {
7199
+ let anchor = forester.inferHeightDateAnchor(phy);
7200
+ if (anchor === null) {
7201
+ return;
7202
+ }
7203
+ forester.convertHeightsToDates(phy, anchor.present);
7204
+ let prov = forester.heightDateDescription(anchor, phy.name,
7205
+ forester.getAllExternalNodes(forester.getTreeRoot(phy)).length);
7206
+ phy.description = (phy.description && String(phy.description).trim().length > 0)
7207
+ ? (phy.description + ' ' + prov) : prov;
7208
+ ++converted;
7209
+ });
7210
+ return converted;
7211
+ };
7212
+
6650
7213
  // ---- axis tick mathematics -----------------------------------------
6651
7214
  // The smallest 1/2/5 x 10^k (k may be negative) step >= target.
6652
7215
  forester.niceAxisStep = function (target) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archaeopteryx",
3
- "version": "3.8.0",
3
+ "version": "3.9.0",
4
4
  "description": "Archaeopteryx.js is a software tool for the visualization and analysis of highly annotated phylogenetic trees.",
5
5
  "main": "archaeopteryx.js",
6
6
  "types": "archaeopteryx.d.ts",
@@ -32,7 +32,7 @@
32
32
  "url": "git+https://github.com/cmzmasek/archaeopteryx-js.git"
33
33
  },
34
34
  "dependencies": {
35
- "phyloxml": "^1.0.2",
35
+ "phyloxml": "^1.1.0",
36
36
  "d3": "^7.9.0"
37
37
  },
38
38
  "devDependencies": {