archaeopteryx 3.13.0 → 3.14.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 CHANGED
@@ -1059,7 +1059,38 @@ never at all on a tall screen. Growing the window back leaves the folds where
1059
1059
  they are: what is open is your choice, and only running out of room overrules
1060
1060
  it. Anything that puts something into a folded section opens it, so jumping to
1061
1061
  the search box (⌘F / Ctrl+F) or a tool reporting its result as search hits
1062
- unfolds Search. A host with little room to give can
1062
+ unfolds Search. The **ⓘ** button in the panel header (or ⌘I / Ctrl+I) opens **Tree
1063
+ properties**, modelled on the desktop Archaeopteryx's *View › Tree
1064
+ Properties* and read-only:
1065
+
1066
+ * **Tree** — name, description, identifier, type, branch-length unit,
1067
+ rootedness and rerootability (each shown only where the file actually says
1068
+ so), and "tree 2 of 3" when the file held several.
1069
+ * **Structure** — tips, internal nodes, total nodes, branches, whether the
1070
+ tree is fully binary or how many polytomies it has and the widest of them,
1071
+ depth from root to deepest tip, the longest tip label, and how many internal
1072
+ nodes are named.
1073
+ * **Branch lengths** — how many branches carry one, then **minimum, median,
1074
+ maximum and mean**, the sum as total tree length, the height from root to
1075
+ farthest tip, any zero-length or negative branches, and whether the tree is
1076
+ ultrametric.
1077
+ * **Support** — the same n / minimum / median / maximum / mean, **one section
1078
+ per kind**. A bootstrap and a posterior on the same branch are not on one
1079
+ scale, so they are never pooled; MAD values are not support and are counted
1080
+ with the annotations instead.
1081
+ * **The tree carries** — taxonomies (and how many are distinct), taxonomy
1082
+ identifiers, sequences, molecular sequences with the alignment width, domain
1083
+ architectures, dates, distributions, events, branch colours, and **every
1084
+ phyloXML property `ref` with the number of nodes carrying it** — each as
1085
+ "n of m tips" or "n of m nodes".
1086
+ * **Time** — geologic ages or calendar years, dated nodes, the oldest node or
1087
+ most recent tip, and whether there are confidence intervals.
1088
+
1089
+ It describes what is on screen, so inside a subtree it describes the subtree
1090
+ and says so, and it is recomputed each time it opens rather than cached (24 ms
1091
+ on the 13,246-tip demo). There is no histogram.
1092
+
1093
+ A host with little room to give can
1063
1094
  also start the whole panel tighter and narrower with
1064
1095
  [`panelDensity: 'compact'`](#configuration), or collapsed to its header bar
1065
1096
  with `collapseControlPanel`.
package/archaeopteryx.js CHANGED
@@ -20,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.13.0
23
+ // v 3.14.0
24
24
  // 2026-09-17
25
25
  //
26
26
  // Archaeopteryx.js is a software tool for the visualization and
@@ -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.13.0';
106
+ const VERSION = '3.14.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';
@@ -414,6 +414,7 @@ function (root, d3, forester, phyloXml) {
414
414
  const BRANCH_SCALE_TIME_BUTTON = 'branch_scale_time_b';
415
415
  const BRANCH_SCALE_DIV_BUTTON = 'branch_scale_div_b';
416
416
  const ABOUT_DIALOG = 'aptx_about';
417
+ const TREE_PROPERTIES_DIALOG = 'aptx_tree_props';
417
418
  const PROG_NAME = 'progname';
418
419
  const PROGNAMELINK = 'prognamelink';
419
420
  const TREE_DESC = 'tree_desc';
@@ -9575,6 +9576,7 @@ function (root, d3, forester, phyloXml) {
9575
9576
  {key: 'g', label: 'G', shift: true, what: 'Previous search hit', run: function () { stepToFoundNode(-1); }, typing: true},
9576
9577
  {key: ',', label: '<', aliases: ['<'], shift: true, what: 'Previous tree of the file', run: function () { stepTree(-1); }},
9577
9578
  {key: '.', label: '>', aliases: ['>'], shift: true, what: 'Next tree of the file', run: function () { stepTree(1); }},
9579
+ {key: 'i', label: 'I', what: 'Tree properties and statistics', run: showTreePropertiesDialog},
9578
9580
  {key: '/', label: '/', aliases: ['?'], what: 'This list', run: showShortcutsDialog, typing: true}
9579
9581
  ];
9580
9582
 
@@ -11738,7 +11740,7 @@ function (root, d3, forester, phyloXml) {
11738
11740
  if (matches.length > SUGGEST_MAX_ROWS) {
11739
11741
  let more = document.createElement('div');
11740
11742
  more.className = 'aptx-suggest-more';
11741
- more.textContent = (matches.length - SUGGEST_MAX_ROWS) + ' more — keep typing';
11743
+ more.textContent = (matches.length - SUGGEST_MAX_ROWS) + ' more, keep typing';
11742
11744
  box.appendChild(more);
11743
11745
  }
11744
11746
  document.body.appendChild(box);
@@ -12165,7 +12167,7 @@ function (root, d3, forester, phyloXml) {
12165
12167
  }
12166
12168
  item.classList.toggle('aptx-check-active', !!active);
12167
12169
  item.title = active
12168
- ? item.dataset.baseTitle + ' — hiding now: 1 in ' + _dynahide_factor + ' labels shown'
12170
+ ? item.dataset.baseTitle + ', hiding now: 1 in ' + _dynahide_factor + ' labels shown'
12169
12171
  : item.dataset.baseTitle;
12170
12172
  }
12171
12173
 
@@ -12795,6 +12797,15 @@ function (root, d3, forester, phyloXml) {
12795
12797
  }
12796
12798
 
12797
12799
  // The theme toggle, showing the theme it will switch TO.
12800
+ // A circled "i": the tree-properties button. The stem is a stroke and the
12801
+ // tittle a filled dot, so both keep their shape at the 20px the header
12802
+ // button draws them at.
12803
+ function glyphInfo() {
12804
+ return '<circle cx="50" cy="50" r="40"/>'
12805
+ + glyphLine(50, 45, 50, 71)
12806
+ + glyphDot(50, 29, 5.5);
12807
+ }
12808
+
12798
12809
  function glyphSun() {
12799
12810
  let s = glyphDot(50, 50, 20);
12800
12811
  for (let i = 0; i < 8; ++i) {
@@ -12862,6 +12873,7 @@ function (root, d3, forester, phyloXml) {
12862
12873
  case 'uncollapse_all': sw = 8.5; cap = 'round'; body = glyphUncollapseAll(); break;
12863
12874
  case 'tree_prev': sw = 9; cap = 'round'; body = glyphChevron(false); break;
12864
12875
  case 'tree_next': sw = 9; cap = 'round'; body = glyphChevron(true); break;
12876
+ case 'info': sw = 7; cap = 'round'; body = glyphInfo(); break;
12865
12877
  case 'sun': body = glyphSun(); break;
12866
12878
  case 'moon': body = glyphMoon(); break;
12867
12879
  default: throw new Error('unknown control-panel glyph: ' + kind);
@@ -13000,7 +13012,14 @@ function (root, d3, forester, phyloXml) {
13000
13012
  + '@media (prefers-reduced-motion:reduce){ .aptx-busy-dot { animation:none;'
13001
13013
  + ' border-color:var(--p-accent); } }'
13002
13014
  + '.aptx-busy-sub { color:var(--p-muted); }'
13015
+ // The same frosted glass as the node menu and the suggestion list:
13016
+ // --p-bg is deliberately translucent (0.86) so panels float over
13017
+ // the tree, but without the blur a dialog puts dense text straight
13018
+ // on top of whatever is behind it -- and over an alignment or a
13019
+ // heat map that is a rainbow. The menus had the blur from the
13020
+ // start; the dialogs were simply missed.
13003
13021
  + '.aptx-dialog { padding:0; border:1px solid var(--p-line-strong); border-radius:12px;'
13022
+ + ' -webkit-backdrop-filter:blur(8px); backdrop-filter:blur(8px);'
13004
13023
  + ' background:var(--p-bg); color:var(--p-ink); max-width:92vw;'
13005
13024
  + ' box-shadow:0 24px 48px -16px rgba(23,34,46,0.45),0 4px 12px -4px rgba(23,34,46,0.22);'
13006
13025
  + ' font-family:system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;'
@@ -13045,6 +13064,9 @@ function (root, d3, forester, phyloXml) {
13045
13064
  + '.aptx-dialog-key { flex:0 0 42%; color:var(--p-muted); }'
13046
13065
  + '.aptx-dialog-val { flex:1 1 auto; min-width:0; overflow-wrap:anywhere; }'
13047
13066
  // the shortcuts cheat sheet: key chips in the key column
13067
+ + '.aptx-props .aptx-props-head { font-size:10px; letter-spacing:.08em; text-transform:uppercase; color:var(--p-muted); margin:12px 0 3px; }'
13068
+ + '.aptx-props > div:first-child { margin-top:0; }'
13069
+ + '.aptx-props .aptx-dialog-key { flex-basis:46%; }'
13048
13070
  + '.aptx-shortcuts .aptx-dialog-key { flex-basis:38%; display:flex; align-items:center; }'
13049
13071
  + '.aptx-shortcuts .aptx-dialog-val { align-self:center; }'
13050
13072
  + '.aptx-keys { display:inline-flex; gap:3px; flex-wrap:wrap; }'
@@ -13199,7 +13221,7 @@ function (root, d3, forester, phyloXml) {
13199
13221
  + '.aptx-panel .aptx-seg:has(> input:checked) { background:var(--p-accent); color:#fff; }'
13200
13222
  + '.aptx-panel .aptx-seg:has(> input:disabled) { opacity:0.4; cursor:default; }'
13201
13223
  + '.aptx-panel .aptx-actions { margin-left:auto; display:flex; align-items:center; gap:5px; }'
13202
- + '.aptx-panel .aptx-theme-btn { flex:none; width:20px; height:20px; display:grid; place-items:center; padding:0; border:1px solid var(--p-line-strong); border-radius:6px; background:var(--p-surface2); color:var(--p-muted); cursor:pointer; font-size:12px; line-height:1; }'
13224
+ + '.aptx-panel .aptx-theme-btn, .aptx-panel .aptx-info-btn { flex:none; width:20px; height:20px; display:grid; place-items:center; padding:0; border:1px solid var(--p-line-strong); border-radius:6px; background:var(--p-surface2); color:var(--p-muted); cursor:pointer; font-size:12px; line-height:1; }'
13203
13225
  + '.aptx-panel .aptx-theme-btn:hover { background:var(--p-accent-weak); color:var(--p-accent-ink); border-color:var(--p-accent); }'
13204
13226
  + '.aptx-panel input[type=range] { -webkit-appearance:none; appearance:none; display:block; width:100%; height:15px; margin:2px 0 9px; padding:0; background:transparent; cursor:pointer; }'
13205
13227
  + '.aptx-panel input[type=range]::-webkit-slider-runnable-track { height:4px; border-radius:999px; background:var(--p-line-strong); }'
@@ -13354,6 +13376,24 @@ function (root, d3, forester, phyloXml) {
13354
13376
  let actions = document.createElement('div');
13355
13377
  actions.className = 'aptx-actions';
13356
13378
 
13379
+ let infoBtn = document.createElement('button');
13380
+ infoBtn.type = 'button';
13381
+ // NOT aptx-theme-btn: applyPanelTheme repaints the innerHTML of
13382
+ // every element carrying that class with the sun/moon, so
13383
+ // borrowing it for the chrome replaced this glyph the instant a
13384
+ // theme was applied. The two share their styling through the CSS
13385
+ // selector instead, which is all the class was wanted for.
13386
+ infoBtn.className = 'aptx-info-btn';
13387
+ infoBtn.title = 'Tree properties and statistics ('
13388
+ + (IS_MAC ? '\u2318I' : 'Ctrl+I') + ')';
13389
+ infoBtn.setAttribute('aria-label', 'Tree properties and statistics');
13390
+ infoBtn.innerHTML = makeGlyph('info');
13391
+ infoBtn.addEventListener('click', function (e) {
13392
+ e.stopPropagation();
13393
+ showTreePropertiesDialog();
13394
+ });
13395
+ actions.appendChild(infoBtn);
13396
+
13357
13397
  let progName = header.querySelector('.' + PROGNAMELINK);
13358
13398
  if (progName) {
13359
13399
  // Clicking the title used to navigate away to the project site;
@@ -13927,6 +13967,257 @@ function (root, d3, forester, phyloXml) {
13927
13967
  shell.dialog.showModal();
13928
13968
  }
13929
13969
 
13970
+ // ===================== Tree properties =====================
13971
+ // What the tree IS and what it carries: the desktop's View > Tree
13972
+ // Properties, read-only (Christian, 2026-09-23 -- the editable half waits
13973
+ // for demand). Nothing else in the viewer answers "what is actually in
13974
+ // this file", which is the first question a tree raises.
13975
+ //
13976
+ // Computed FRESH every time it opens rather than cached: the whole of
13977
+ // collectBasicTreeProperties costs 7ms on the 13,246-tip H5N1 demo, and a
13978
+ // cache would go stale the moment the user re-roots or enters a subtree.
13979
+ // It describes what is ON SCREEN, so in a subtree it describes the
13980
+ // subtree, and says so.
13981
+ //
13982
+ // Every value goes in through textContent. Tree names, descriptions and
13983
+ // identifiers are file data, and file data never becomes markup.
13984
+
13985
+ function treePropRow(parent, label, value) {
13986
+ if (value === null || value === undefined || value === '') {
13987
+ return;
13988
+ }
13989
+ let line = document.createElement('div');
13990
+ line.className = 'aptx-dialog-line';
13991
+ let k = document.createElement('span');
13992
+ k.className = 'aptx-dialog-key';
13993
+ k.textContent = label;
13994
+ let v = document.createElement('span');
13995
+ v.className = 'aptx-dialog-val';
13996
+ v.textContent = String(value);
13997
+ line.appendChild(k);
13998
+ line.appendChild(v);
13999
+ parent.appendChild(line);
14000
+ }
14001
+
14002
+ function treePropSection(body, title) {
14003
+ let h = document.createElement('div');
14004
+ h.className = 'aptx-props-head';
14005
+ h.textContent = title;
14006
+ body.appendChild(h);
14007
+ let box = document.createElement('div');
14008
+ body.appendChild(box);
14009
+ return box;
14010
+ }
14011
+
14012
+ function countNumber(n) {
14013
+ return (typeof n === 'number' && isFinite(n)) ? n.toLocaleString() : null;
14014
+ }
14015
+
14016
+ // A number for a fact row: thousands separators for counts, and enough
14017
+ // significant digits for a branch length, which can be 5e-9.
14018
+ function statNumber(v, digits) {
14019
+ if (typeof v !== 'number' || !isFinite(v)) {
14020
+ return null;
14021
+ }
14022
+ if (v === 0 || (Math.abs(v) >= 0.001 && Math.abs(v) < 1e7)) {
14023
+ return String(forester.roundNumber(v, digits === undefined ? 6 : digits));
14024
+ }
14025
+ return v.toExponential(3);
14026
+ }
14027
+
14028
+ // "1,234 of 5,678 tips", the desktop's coverage phrasing.
14029
+ function statOf(part, whole, noun) {
14030
+ if (!(whole > 0)) {
14031
+ return null;
14032
+ }
14033
+ return countNumber(part) + ' of ' + countNumber(whole) + (noun ? (' ' + noun) : '');
14034
+ }
14035
+
14036
+ // n / minimum / median / maximum / mean, the block Christian asked for in
14037
+ // place of a histogram. One shape, used for branch lengths and for each
14038
+ // kind of support, so the two read the same way.
14039
+ function statSpread(box, d, digits) {
14040
+ treePropRow(box, 'Minimum', statNumber(d.min, digits));
14041
+ treePropRow(box, 'Median', statNumber(d.median, digits));
14042
+ treePropRow(box, 'Maximum', statNumber(d.max, digits));
14043
+ treePropRow(box, 'Mean', statNumber(d.mean, digits));
14044
+ }
14045
+
14046
+ function showTreePropertiesDialog() {
14047
+ if (!_root) {
14048
+ return;
14049
+ }
14050
+ let shell = makeDialogShell(TREE_PROPERTIES_DIALOG, 'Tree properties', 420);
14051
+ shell.body.classList.add('aptx-props');
14052
+ // A description is free text and can be a paragraph, and the coverage
14053
+ // section grows with the annotations, so the body scrolls rather than
14054
+ // running off the screen.
14055
+ shell.body.style.maxHeight = '66vh';
14056
+ shell.body.style.overflowY = 'auto';
14057
+ let st = forester.treeStatistics(_root);
14058
+ let bp = forester.collectBasicTreeProperties(_root);
14059
+ let time = forester.timeAxisInfo(_root);
14060
+ let phy = _treeData || {};
14061
+
14062
+ // --- what the tree says it is ---------------------------------
14063
+ let box = treePropSection(shell.body, 'Tree');
14064
+ treePropRow(box, 'Name', phy.name);
14065
+ treePropRow(box, 'Description', phy.description);
14066
+ if (phy.id && phy.id.value) {
14067
+ treePropRow(box, 'Identifier', phy.id.provider
14068
+ ? (phy.id.value + ' (' + phy.id.provider + ')') : phy.id.value);
14069
+ }
14070
+ treePropRow(box, 'Type', phy.type);
14071
+ treePropRow(box, 'Branch-length unit', phy.branch_length_unit);
14072
+ // Only when the FILE said so. phyloXML has a rooted attribute and a
14073
+ // Nexus tree statement has [&R]/[&U]; plain Newick says nothing, and a
14074
+ // properties dialog that answered "yes" there would be reporting our
14075
+ // writing default as though the file had stated it.
14076
+ if (typeof phy.rooted === 'boolean') {
14077
+ treePropRow(box, 'Rooted', phy.rooted ? 'yes' : 'no');
14078
+ }
14079
+ if (typeof phy.rerootable === 'boolean') {
14080
+ treePropRow(box, 'Rerootable', phy.rerootable ? 'yes' : 'no');
14081
+ }
14082
+ if (_trees && _trees.length > 1) {
14083
+ treePropRow(box, 'In this file', 'tree ' + (_treeIndex + 1) + ' of ' + _trees.length);
14084
+ }
14085
+ if (_in_subtree) {
14086
+ treePropRow(box, 'Showing', 'a subtree, not the whole tree');
14087
+ }
14088
+ if (!box.firstChild) {
14089
+ treePropRow(box, 'Name', '(the tree carries no name or metadata)');
14090
+ }
14091
+
14092
+ // --- shape ----------------------------------------------------
14093
+ box = treePropSection(shell.body, 'Structure');
14094
+ treePropRow(box, 'Tips', countNumber(st.tips));
14095
+ treePropRow(box, 'Internal nodes', countNumber(st.internal));
14096
+ treePropRow(box, 'Nodes, total', countNumber(st.nodes));
14097
+ treePropRow(box, 'Branches', countNumber(st.branches));
14098
+ treePropRow(box, 'Branching', st.polytomies === 0 ? 'fully binary'
14099
+ : (countNumber(st.polytomies) + (st.polytomies === 1 ? ' polytomy' : ' polytomies')
14100
+ + ', widest ' + countNumber(st.maxChildren) + ' children'));
14101
+ treePropRow(box, 'Depth', countNumber(st.depth) + ' (root to deepest tip)');
14102
+ // ours, not the desktop's, and worth keeping: the label width is what
14103
+ // decides how much room the tree needs, and whether internal nodes are
14104
+ // named decides whether their labels are worth turning on
14105
+ treePropRow(box, 'Longest tip label', bp.longestNodeName > 0
14106
+ ? (countNumber(bp.longestNodeName)
14107
+ + (bp.longestNodeName === 1 ? ' character' : ' characters')) : null);
14108
+ treePropRow(box, 'Internal nodes named', st.internal > 0
14109
+ ? (st.namedInternal > 0 ? statOf(st.namedInternal, st.internal) : 'none') : null);
14110
+
14111
+ // --- branch lengths -------------------------------------------
14112
+ box = treePropSection(shell.body, 'Branch lengths');
14113
+ if (st.branchLengths) {
14114
+ treePropRow(box, 'Branches with a length',
14115
+ statOf(st.branchLengths.n, st.branches));
14116
+ statSpread(box, st.branchLengths);
14117
+ treePropRow(box, 'Sum', statNumber(st.branchLengths.sum) + ' (total tree length)');
14118
+ treePropRow(box, 'Height', statNumber(st.height) + ' (root to farthest tip)');
14119
+ if (st.branchLengths.zero > 0) {
14120
+ treePropRow(box, 'Zero-length', countNumber(st.branchLengths.zero));
14121
+ }
14122
+ if (st.branchLengths.negative > 0) {
14123
+ treePropRow(box, 'Negative', countNumber(st.branchLengths.negative)
14124
+ + ' (not drawn to scale)');
14125
+ }
14126
+ treePropRow(box, 'Ultrametric', st.ultrametric
14127
+ ? 'yes (every tip the same distance from the root)' : 'no');
14128
+ } else {
14129
+ treePropRow(box, 'Branches with a length', 'none, the tree has no lengths');
14130
+ }
14131
+
14132
+ // --- support, one section per kind ----------------------------
14133
+ // A bootstrap and a posterior on the same branch are not on one scale,
14134
+ // so they are never pooled: each type gets its own n and spread.
14135
+ st.support.forEach(function (d) {
14136
+ // 'unknown' is what the Newick and Nexus readers put on a bare
14137
+ // [95]: those formats have one support slot and nowhere to name
14138
+ // what it is, so it is an absence of type rather than a type
14139
+ // called unknown, and it should not head a section as one.
14140
+ let untyped = !d.type || d.type === 'unknown';
14141
+ let title = (st.support.length === 1 && untyped)
14142
+ ? 'Support values' : ('Support: ' + (untyped ? 'untyped' : d.type));
14143
+ let sbox = treePropSection(shell.body, title);
14144
+ treePropRow(sbox, 'Branches with support', statOf(d.n, st.branches));
14145
+ statSpread(sbox, d);
14146
+ });
14147
+
14148
+ // --- what came with it ----------------------------------------
14149
+ box = treePropSection(shell.body, 'The tree carries');
14150
+ let carried = 0;
14151
+ function carries(label, present, detail) {
14152
+ if (!present) {
14153
+ return;
14154
+ }
14155
+ ++carried;
14156
+ treePropRow(box, label, detail || 'yes');
14157
+ }
14158
+ carries('Taxonomies', st.tipsWithTaxonomy > 0, statOf(st.tipsWithTaxonomy, st.tips, 'tips')
14159
+ + (st.distinctTaxonomies > 0 ? (', ' + countNumber(st.distinctTaxonomies) + ' distinct') : ''));
14160
+ carries('Taxonomy identifiers', st.tipsWithTaxonomyId > 0,
14161
+ statOf(st.tipsWithTaxonomyId, st.tips, 'tips'));
14162
+ carries('Sequences', st.tipsWithSequence > 0, statOf(st.tipsWithSequence, st.tips, 'tips'));
14163
+ // alignedMolSeqs starts TRUE and is only ever set false, so it means
14164
+ // "no UNALIGNED sequence was seen" -- a tree carrying no sequences at
14165
+ // all reports true. Read alone it told the flu and ammonite demos they
14166
+ // had an alignment. Every other reader in this file pairs it with a
14167
+ // length for the same reason.
14168
+ carries('Molecular sequences', st.tipsWithMolSeq > 0,
14169
+ statOf(st.tipsWithMolSeq, st.tips, 'tips')
14170
+ + (bp.alignedMolSeqs && bp.maxMolSeqLength > 0
14171
+ ? (', aligned, ' + countNumber(bp.maxMolSeqLength) + ' columns') : ''));
14172
+ carries('Domain architectures', st.tipsWithDomains > 0,
14173
+ statOf(st.tipsWithDomains, st.tips, 'tips')
14174
+ + (bp.maxDomainArchitectureLength > 0
14175
+ ? (', up to ' + countNumber(bp.maxDomainArchitectureLength) + ' residues') : ''));
14176
+ carries('Dates', st.nodesWithDate > 0, statOf(st.nodesWithDate, st.nodes, 'nodes'));
14177
+ carries('Distributions', st.tipsWithDistribution > 0,
14178
+ statOf(st.tipsWithDistribution, st.tips, 'tips'));
14179
+ carries('MAD values', bp.madValues);
14180
+ carries('Branch colours', bp.branchColors);
14181
+ if (st.nodesWithEvents > 0) {
14182
+ let parts = [];
14183
+ if (st.duplications > 0) {
14184
+ parts.push(countNumber(st.duplications)
14185
+ + (st.duplications === 1 ? ' duplication' : ' duplications'));
14186
+ }
14187
+ if (st.speciations > 0) {
14188
+ parts.push(countNumber(st.speciations)
14189
+ + (st.speciations === 1 ? ' speciation' : ' speciations'));
14190
+ }
14191
+ parts.push(statOf(st.nodesWithEvents, st.nodes, 'nodes'));
14192
+ carries('Events', true, parts.join(', '));
14193
+ }
14194
+ st.propertyRefs.forEach(function (r) {
14195
+ carries('Property ' + r.ref, true, statOf(r.nodes, st.nodes, 'nodes'));
14196
+ });
14197
+ if (carried === 0) {
14198
+ treePropRow(box, 'Annotations', 'none, names and topology only');
14199
+ }
14200
+
14201
+ // --- time -----------------------------------------------------
14202
+ if (time && (time.type || time.dated)) {
14203
+ box = treePropSection(shell.body, 'Time');
14204
+ treePropRow(box, 'Dates', time.type === 'geologic'
14205
+ ? 'geologic ages' : (time.type === 'calendar' ? 'calendar years' : 'yes'));
14206
+ treePropRow(box, 'Dated nodes', statOf(st.nodesWithDate, st.nodes, 'nodes')
14207
+ + (time.dated ? ' (a dated tree)' : ''));
14208
+ if (time.type === 'geologic' && time.rootAge > 0) {
14209
+ treePropRow(box, 'Oldest node', statNumber(time.rootAge, 4) + ' Ma');
14210
+ }
14211
+ if (time.type === 'calendar' && time.presentDate > 0) {
14212
+ treePropRow(box, 'Most recent tip', statNumber(time.presentDate, 4));
14213
+ }
14214
+ treePropRow(box, 'Confidence intervals',
14215
+ (time.hasInternalIntervals || time.hasExternalIntervals) ? 'yes' : null);
14216
+ }
14217
+
14218
+ shell.dialog.showModal();
14219
+ }
14220
+
13930
14221
  // The About box behind the panel's title. Everything here is fixed text and
13931
14222
  // fixed URLs -- no tree data reaches it -- so the links are built as real
13932
14223
  // elements rather than markup.
package/forester.js CHANGED
@@ -20,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.13.0
23
+ // v 3.14.0
24
24
  // 2026-09-17
25
25
  //
26
26
  // forester.js is a general suite for dealing with phylogenetic trees.
@@ -3814,6 +3814,230 @@
3814
3814
  };
3815
3815
 
3816
3816
 
3817
+ // Everything the Tree Properties dialog reports, gathered in ONE pass:
3818
+ // the desktop's TreeFacts.scan, minus its histogram (Christian, 2026-09-23:
3819
+ // "we don't need a histogram ... but a more detailed stats would be
3820
+ // useful, min, median, max, n, also type ... also need to include the
3821
+ // properties").
3822
+ //
3823
+ // Side-effect free and DOM-free, so it can be tested in Node -- the whole
3824
+ // reason it lives here rather than in the dialog that renders it, which is
3825
+ // the same split the desktop makes between TreeFacts and
3826
+ // TreePropertiesForm.
3827
+ //
3828
+ // Support values are grouped BY TYPE, because a tree can carry a bootstrap
3829
+ // and a posterior on the same branch and their ranges are not comparable
3830
+ // (0-100 against 0-1); averaging them together would produce a number that
3831
+ // describes neither. MAD values are excluded for the same reason they are
3832
+ // excluded from the Newick support slot: they rate a root position, not a
3833
+ // clade.
3834
+ forester.treeStatistics = function (phyOrNode) {
3835
+ // Callers hand this either a phylogeny wrapper or the root node, and
3836
+ // the difference is one extra internal node, one extra branch and one
3837
+ // more of depth -- small enough to read as plausible. getTreeRoot
3838
+ // normalizes both, as collectBasicTreeProperties does.
3839
+ let root = phyOrNode ? forester.getTreeRoot(phyOrNode) : null;
3840
+ let stats = {
3841
+ tips: 0, internal: 0, nodes: 0, branches: 0,
3842
+ polytomies: 0, maxChildren: 0,
3843
+ depth: 0, height: 0, heightKnown: false,
3844
+ collapsed: 0,
3845
+ branchLengths: null,
3846
+ support: [],
3847
+ ultrametric: false,
3848
+ tipsWithTaxonomy: 0, distinctTaxonomies: 0, tipsWithTaxonomyId: 0,
3849
+ tipsWithSequence: 0, tipsWithMolSeq: 0, tipsWithDomains: 0,
3850
+ nodesWithDate: 0, tipsWithDistribution: 0, namedInternal: 0,
3851
+ duplications: 0, speciations: 0, nodesWithEvents: 0,
3852
+ propertyRefs: []
3853
+ };
3854
+ if (!root) {
3855
+ return stats;
3856
+ }
3857
+ let lengths = [];
3858
+ let zeroLengths = 0;
3859
+ let negativeLengths = 0;
3860
+ let byType = Object.create(null);
3861
+ let taxa = Object.create(null);
3862
+ let refs = Object.create(null);
3863
+ let refOrder = [];
3864
+ let tipDepths = [];
3865
+ let maxDepth = 0;
3866
+
3867
+ (function walk(n, depth, distance) {
3868
+ ++stats.nodes;
3869
+ let kids = n.children || [];
3870
+ if (depth > maxDepth) {
3871
+ maxDepth = depth;
3872
+ }
3873
+ if (n !== root) {
3874
+ ++stats.branches;
3875
+ let b = n.branch_length;
3876
+ if (typeof b === 'number' && isFinite(b)) {
3877
+ lengths.push(b);
3878
+ if (b === 0) {
3879
+ ++zeroLengths;
3880
+ } else if (b < 0) {
3881
+ ++negativeLengths;
3882
+ }
3883
+ distance += b;
3884
+ }
3885
+ }
3886
+ // support, by type, MAD left out
3887
+ if (n.confidences) {
3888
+ for (let i = 0; i < n.confidences.length; ++i) {
3889
+ let c = n.confidences[i];
3890
+ if (c.type === forester.MAD_CONFIDENCE_TYPE) {
3891
+ continue;
3892
+ }
3893
+ if (typeof c.value !== 'number' || !isFinite(c.value)) {
3894
+ continue;
3895
+ }
3896
+ let key = c.type || '';
3897
+ if (!byType[key]) {
3898
+ byType[key] = [];
3899
+ stats.support.push({type: key, values: byType[key]});
3900
+ }
3901
+ byType[key].push(c.value);
3902
+ }
3903
+ }
3904
+ if (n.date && typeof n.date.value === 'number' && isFinite(n.date.value)) {
3905
+ ++stats.nodesWithDate;
3906
+ }
3907
+ if (n.properties) {
3908
+ let seen = Object.create(null);
3909
+ for (let i = 0; i < n.properties.length; ++i) {
3910
+ let ref = n.properties[i] && n.properties[i].ref;
3911
+ if (!ref || seen[ref]) {
3912
+ continue; // a node carrying a ref twice counts once
3913
+ }
3914
+ seen[ref] = true;
3915
+ if (refs[ref] === undefined) {
3916
+ refs[ref] = 0;
3917
+ refOrder.push(ref);
3918
+ }
3919
+ ++refs[ref];
3920
+ }
3921
+ }
3922
+ if (n.events) {
3923
+ ++stats.nodesWithEvents;
3924
+ if (n.events.duplications > 0) {
3925
+ stats.duplications += n.events.duplications;
3926
+ }
3927
+ if (n.events.speciations > 0) {
3928
+ stats.speciations += n.events.speciations;
3929
+ }
3930
+ }
3931
+ if (kids.length > 0) {
3932
+ ++stats.internal;
3933
+ if (kids.length > stats.maxChildren) {
3934
+ stats.maxChildren = kids.length;
3935
+ }
3936
+ if (kids.length > 2) {
3937
+ ++stats.polytomies;
3938
+ }
3939
+ if (n !== root && n.name) {
3940
+ ++stats.namedInternal;
3941
+ }
3942
+ for (let i = 0; i < kids.length; ++i) {
3943
+ walk(kids[i], depth + 1, distance);
3944
+ }
3945
+ return;
3946
+ }
3947
+ ++stats.tips;
3948
+ tipDepths.push(distance);
3949
+ if (n.taxonomies && n.taxonomies.length > 0) {
3950
+ ++stats.tipsWithTaxonomy;
3951
+ let t = n.taxonomies[0];
3952
+ let label = t.scientific_name || t.code || t.common_name || '';
3953
+ if (label) {
3954
+ taxa[label] = true;
3955
+ }
3956
+ if (t.id && t.id.value) {
3957
+ ++stats.tipsWithTaxonomyId;
3958
+ }
3959
+ }
3960
+ if (n.sequences && n.sequences.length > 0) {
3961
+ ++stats.tipsWithSequence;
3962
+ let q = n.sequences[0];
3963
+ if (q.mol_seq && q.mol_seq.value) {
3964
+ ++stats.tipsWithMolSeq;
3965
+ }
3966
+ if (forester.domainArchitectureOf(n)) {
3967
+ ++stats.tipsWithDomains;
3968
+ }
3969
+ }
3970
+ if (n.distributions && n.distributions.length > 0) {
3971
+ ++stats.tipsWithDistribution;
3972
+ }
3973
+ })(root, 0, 0);
3974
+
3975
+ stats.depth = maxDepth;
3976
+ stats.distinctTaxonomies = Object.keys(taxa).length;
3977
+ stats.propertyRefs = refOrder.map(function (r) {
3978
+ return {ref: r, nodes: refs[r]};
3979
+ });
3980
+ stats.branchLengths = forester.describeValues(lengths);
3981
+ if (stats.branchLengths) {
3982
+ stats.branchLengths.zero = zeroLengths;
3983
+ stats.branchLengths.negative = negativeLengths;
3984
+ }
3985
+ stats.support = stats.support.map(function (g) {
3986
+ let d = forester.describeValues(g.values);
3987
+ d.type = g.type;
3988
+ return d;
3989
+ }).filter(function (d) {
3990
+ return d.n > 0;
3991
+ });
3992
+ if (tipDepths.length > 0) {
3993
+ // running, not Math.min.apply: the spread of 150k tip depths
3994
+ // overflows the call stack, as timeAxisInfo found before this
3995
+ let lo = Infinity;
3996
+ let hi = -Infinity;
3997
+ for (let i = 0; i < tipDepths.length; ++i) {
3998
+ if (tipDepths[i] < lo) {
3999
+ lo = tipDepths[i];
4000
+ }
4001
+ if (tipDepths[i] > hi) {
4002
+ hi = tipDepths[i];
4003
+ }
4004
+ }
4005
+ stats.height = hi;
4006
+ stats.heightKnown = lengths.length > 0;
4007
+ // ultrametric: every tip the same distance from the root, to a
4008
+ // tolerance scaled to the tree, since branch lengths are floats
4009
+ stats.ultrametric = stats.heightKnown && (hi - lo) <= (Math.max(hi, 1e-12) * 1e-6);
4010
+ }
4011
+ return stats;
4012
+ };
4013
+
4014
+ // n, minimum, median, maximum, mean and sum of a list of numbers, or null
4015
+ // when there are none. The median is the middle value of the sorted list,
4016
+ // or the mean of the two middle ones -- taken on a COPY, because sorting
4017
+ // the caller's array would reorder data it still owns.
4018
+ forester.describeValues = function (values) {
4019
+ if (!values || values.length === 0) {
4020
+ return null;
4021
+ }
4022
+ let v = values.slice().sort(function (a, b) {
4023
+ return a - b;
4024
+ });
4025
+ let n = v.length;
4026
+ let sum = 0;
4027
+ for (let i = 0; i < n; ++i) {
4028
+ sum += v[i];
4029
+ }
4030
+ let mid = Math.floor(n / 2);
4031
+ return {
4032
+ n: n,
4033
+ min: v[0],
4034
+ max: v[n - 1],
4035
+ median: (n % 2 === 1) ? v[mid] : ((v[mid - 1] + v[mid]) / 2),
4036
+ mean: sum / n,
4037
+ sum: sum
4038
+ };
4039
+ };
4040
+
3817
4041
  forester.calcMaxBranchLength = function (node) {
3818
4042
  let max = 0;
3819
4043
  forester.preOrderTraversalAll(node, function (n) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archaeopteryx",
3
- "version": "3.13.0",
3
+ "version": "3.14.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",