archaeopteryx 3.26.0 → 3.27.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,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- // v 3.26.0
23
+ // v 3.27.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.26.0';
106
+ const VERSION = '3.27.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';
@@ -303,6 +303,52 @@ function (root, d3, forester, phyloXml) {
303
303
  const MESSAGE = 'ArchaeopteryxJS: ';
304
304
  const ERROR = 'ArchaeopteryxJS: ERROR: ';
305
305
 
306
+ // What this launch had to say, kept as well as printed. The console is
307
+ // where a warning goes and where nobody embedding the viewer looks: a
308
+ // site passed a retired key for a month and a forced visualization for
309
+ // longer (BV-BRC, 2026-10-08), each announced on every page load. The
310
+ // Diagnostics report (archaeopteryx.doctor, further down) reads this
311
+ // list; 'problem' entries are somebody's mistake and light the dot on
312
+ // the program name, 'note' entries are only stated. The same line twice
313
+ // is one entry with a count.
314
+ //
315
+ // The list belongs to the tree on view. It starts afresh whenever the
316
+ // launch proper (launchInto) begins for a tree -- a new launch, or
317
+ // showTree() moving to another tree of the file, whose config warnings
318
+ // are then said again and whose per-tree ones are its own. A launch that
319
+ // is REJECTED while an earlier viewer is still on the page gives that
320
+ // viewer its list back and adds one line saying what failed (the shell
321
+ // round launch()): the page still shows the earlier tree, and the report
322
+ // describes the page.
323
+ const DIAGNOSTICS_MAX = 40;
324
+ let _diagnostics = [];
325
+ // What is on view, noted at the moment a launch takes the page over (it
326
+ // clears the container): the report's "launch" section and the dot read
327
+ // this, never the module's working state, which a launch writes to
328
+ // before the checks that can still reject it.
329
+ let _onView = null;
330
+ let _tookOver = false;
331
+ let _recordedErrors = (typeof WeakSet === 'function') ? new WeakSet() : null;
332
+ function recordDiagnostic(level, line) {
333
+ let text = String(line).replace(/^ArchaeopteryxJS: (WARNING: |ERROR: )?/, '');
334
+ let seen = _diagnostics.find(function (d) {
335
+ return d.level === level && d.text === text;
336
+ });
337
+ if (seen) {
338
+ seen.count += 1;
339
+ } else if (_diagnostics.length < DIAGNOSTICS_MAX) {
340
+ _diagnostics.push({level: level, text: text, count: 1});
341
+ }
342
+ if (level === 'problem') {
343
+ syncDoctorDot();
344
+ }
345
+ }
346
+ // Print as before (same console method, same line) and keep it.
347
+ function say(level, method, line) {
348
+ console[method](line);
349
+ recordDiagnostic(level, line);
350
+ }
351
+
306
352
  // Call-time dependency guards (the UMD header explains why they are not
307
353
  // load-time): each failure names exactly what is missing.
308
354
  function requireD3() {
@@ -440,6 +486,7 @@ function (root, d3, forester, phyloXml) {
440
486
  const CLOCK_PLOT_BUTTON = 'clock_plot_b';
441
487
  const CLOCK_PLOT_DIALOG = 'aptx_clock_plot';
442
488
  const ABOUT_DIALOG = 'aptx_about';
489
+ const DOCTOR_DIALOG = 'aptx_doctor';
443
490
  const TREE_PROPERTIES_DIALOG = 'aptx_tree_props';
444
491
  const PROG_NAME = 'progname';
445
492
  const PROGNAMELINK = 'prognamelink';
@@ -1938,7 +1985,7 @@ function (root, d3, forester, phyloXml) {
1938
1985
  || (c.label && String(c.label).toLowerCase() === want);
1939
1986
  }) || null;
1940
1987
  if (!requested) {
1941
- console.warn(WARNING + ': initialVisualization "'
1988
+ say('problem', 'warn', WARNING + ': initialVisualization "'
1942
1989
  + _settings.initialVisualization
1943
1990
  + '" names no visualization of this tree; using the automatic choice');
1944
1991
  }
@@ -1968,7 +2015,7 @@ function (root, d3, forester, phyloXml) {
1968
2015
  ? requested.values.length + ' distinct values on this tree, more than the '
1969
2016
  + LEGEND_MAX_ROWS + ' the legend lists'
1970
2017
  : 'a value on ' + requested.coverage + ' of the tree\'s ' + requested.total + ' tips';
1971
- console.warn(WARNING + ': initialVisualization "' + _settings.initialVisualization
2018
+ say('problem', 'warn', WARNING + ': initialVisualization "' + _settings.initialVisualization
1972
2019
  + '" names a field with ' + why + '; left to itself the viewer would '
1973
2020
  + (auto ? 'open with "' + auto.label + '" (' + auto.values.length + ' values)'
1974
2021
  : 'open uncoloured'));
@@ -5825,14 +5872,14 @@ function (root, d3, forester, phyloXml) {
5825
5872
  return;
5826
5873
  }
5827
5874
  if (dep.to) {
5828
- console.warn(WARNING + ': "' + k + '" was replaced by "' + dep.to
5875
+ say('problem', 'warn', WARNING + ': "' + k + '" was replaced by "' + dep.to
5829
5876
  + '": ' + dep.note);
5830
5877
  // An explicit new key always wins over the old alias.
5831
5878
  if (given[dep.to] === undefined) {
5832
5879
  given[dep.to] = dep.map(given[k]);
5833
5880
  }
5834
5881
  } else {
5835
- console.warn(WARNING + ': "' + k + '" is retired and has no effect: ' + dep.note);
5882
+ say('problem', 'warn', WARNING + ': "' + k + '" is retired and has no effect: ' + dep.note);
5836
5883
  }
5837
5884
  delete given[k];
5838
5885
  });
@@ -6078,7 +6125,7 @@ function (root, d3, forester, phyloXml) {
6078
6125
  // malformed domains are skipped, never fatal -- said once, here
6079
6126
  let ignored = forester.domainArchitectureStats(_treeData).ignored;
6080
6127
  if (ignored > 0) {
6081
- console.warn(MESSAGE + ignored + ' protein domain' + (ignored === 1 ? '' : 's')
6128
+ say('note', 'warn', MESSAGE + ignored + ' protein domain' + (ignored === 1 ? '' : 's')
6082
6129
  + ' with a missing or impossible from / to / E-value ignored');
6083
6130
  }
6084
6131
  }
@@ -6111,11 +6158,11 @@ function (root, d3, forester, phyloXml) {
6111
6158
  // are left out, never fatal -- said once, here
6112
6159
  let gs = forester.geneRegionStats(_treeData);
6113
6160
  if (gs.ignored > 0) {
6114
- console.warn(MESSAGE + gs.ignored + ' sequence location' + (gs.ignored === 1 ? '' : 's')
6161
+ say('note', 'warn', MESSAGE + gs.ignored + ' sequence location' + (gs.ignored === 1 ? '' : 's')
6115
6162
  + ' not in the form contig:start-end(+) ignored');
6116
6163
  }
6117
6164
  if (gs.otherContigs > 0) {
6118
- console.warn(MESSAGE + gs.otherContigs + ' gene' + (gs.otherContigs === 1 ? '' : 's')
6165
+ say('note', 'warn', MESSAGE + gs.otherContigs + ' gene' + (gs.otherContigs === 1 ? '' : 's')
6119
6166
  + ' on a genome\'s other contigs not drawn: the gene track shows one contig per genome');
6120
6167
  }
6121
6168
  }
@@ -6347,11 +6394,11 @@ function (root, d3, forester, phyloXml) {
6347
6394
  _settings.enableDynamicSizing = true;
6348
6395
  }
6349
6396
  if (_settings.displayWidth && _settings.enableDynamicSizing === true) {
6350
- console.log(WARNING + ': dynamic sizing is turned on, will ignore displayWidth setting');
6397
+ say('note', 'log', WARNING + ': dynamic sizing is turned on, will ignore displayWidth setting');
6351
6398
  _settings.displayWidth = 0;
6352
6399
  }
6353
6400
  if (_settings.displayHeight && _settings.enableDynamicSizing === true) {
6354
- console.log(WARNING + ': dynamic sizing is turned on, will ignore displayHeight setting');
6401
+ say('note', 'log', WARNING + ': dynamic sizing is turned on, will ignore displayHeight setting');
6355
6402
  _settings.displayHeight = 0;
6356
6403
  }
6357
6404
  if ((!_settings.displayWidth) && (!_settings.enableDynamicSizing)) {
@@ -6640,7 +6687,40 @@ function (root, d3, forester, phyloXml) {
6640
6687
 
6641
6688
  // launch(container, tree, config) -> a viewer handle {getSelectedNodes,
6642
6689
  // destroy}. Exactly three arguments; config is THE one config object.
6643
- archaeopteryx.launch = function (container, phylo, config) {
6690
+ // launch() proper is launchChecked below. This shell keeps what a
6691
+ // rejected launch said: the thrown message reaches whoever called, which
6692
+ // on an embedding site is often a catch block nobody reads, and
6693
+ // archaeopteryx.doctor() can still tell. (launchArchaeopteryx has its own
6694
+ // failures -- a file that does not parse -- and reports them through the
6695
+ // same function; an error is recorded once, whoever catches it first.)
6696
+ function recordLaunchFailure(e) {
6697
+ if (_recordedErrors && e && typeof e === 'object') {
6698
+ if (_recordedErrors.has(e)) {
6699
+ return;
6700
+ }
6701
+ _recordedErrors.add(e);
6702
+ }
6703
+ recordDiagnostic('problem', 'launch failed: '
6704
+ + String(e && e.message ? e.message : e).replace(/^ArchaeopteryxJS: ERROR: /, ''));
6705
+ }
6706
+ archaeopteryx.launch = function () {
6707
+ let before = _diagnostics;
6708
+ let wasOnView = _onView;
6709
+ _tookOver = false;
6710
+ try {
6711
+ return launchChecked.apply(null, arguments);
6712
+ } catch (e) {
6713
+ if (wasOnView && !_tookOver) {
6714
+ // rejected before it touched the page: the earlier viewer is
6715
+ // still there, and so is everything said about it
6716
+ _diagnostics = before;
6717
+ }
6718
+ recordLaunchFailure(e);
6719
+ throw e;
6720
+ }
6721
+ };
6722
+
6723
+ function launchChecked(container, phylo, config) {
6644
6724
 
6645
6725
  // Bad input is the caller's bug, so it is thrown at the caller. It used
6646
6726
  // to pop a browser alert and return, which blocks the whole tab and
@@ -6715,12 +6795,14 @@ function (root, d3, forester, phyloXml) {
6715
6795
  let view = config && config.view;
6716
6796
  let start = (view && Number.isInteger(view.tree) && view.tree > 0 && view.tree < trees.length) ? view.tree : 0;
6717
6797
  return launchInto(container, trees, start, config);
6718
- };
6798
+ }
6719
6799
 
6720
6800
  // The launch proper, for one tree of the list: launch() validates and
6721
6801
  // starts at the first, showTree() re-enters here for another.
6722
6802
  function launchInto(container, trees, index, config) {
6723
6803
  let phylo = trees[index];
6804
+ // what is said from here on is said about THIS tree (see _diagnostics)
6805
+ _diagnostics = [];
6724
6806
  let cfg = readConfig(config);
6725
6807
  requireForester();
6726
6808
  requireD3();
@@ -6767,6 +6849,11 @@ function (root, d3, forester, phyloXml) {
6767
6849
  checkLayoutValue(cfg.state ? cfg.state.layout : undefined);
6768
6850
  initializeSettings(cfg.settings);
6769
6851
 
6852
+ // From here the page is this launch's: nothing below is expected to
6853
+ // throw, and what a previous one drew goes now.
6854
+ _tookOver = true;
6855
+ _onView = {config: config, container: containerEl, tips: _basicTreeProperties.externalNodesCount,
6856
+ tree: index + 1, trees: trees.length};
6770
6857
  // whatever a previous launch left in this container (or in the
6771
6858
  // container before it) goes now, so the old tree does not sit under
6772
6859
  // the card while the new one is prepared
@@ -6977,6 +7064,11 @@ function (root, d3, forester, phyloXml) {
6977
7064
  cfg.view = view || viewForTree(_trees[index]);
6978
7065
  let handle = launchInto(_container, _trees, index, cfg);
6979
7066
  _launchConfig = launchConfig;
7067
+ // the report shows the config the SITE passed, not this copy with
7068
+ // the tree's own view added
7069
+ if (_onView) {
7070
+ _onView.config = launchConfig;
7071
+ }
6980
7072
  return handle;
6981
7073
  }
6982
7074
 
@@ -10370,7 +10462,7 @@ function (root, d3, forester, phyloXml) {
10370
10462
  try {
10371
10463
  _settings.onViewChange(state, encoded);
10372
10464
  } catch (e) {
10373
- console.error(ERROR + 'onViewChange threw: ' + (e && e.message ? e.message : e));
10465
+ say('problem', 'error', ERROR + 'onViewChange threw: ' + (e && e.message ? e.message : e));
10374
10466
  }
10375
10467
  }
10376
10468
 
@@ -15904,14 +15996,17 @@ function (root, d3, forester, phyloXml) {
15904
15996
  // The async clipboard API where the page allows it (a secure context);
15905
15997
  // otherwise the old select-and-copy, so a page served over plain http
15906
15998
  // still copies. Resolves to whether the text made it.
15907
- function copyText(text) {
15999
+ //
16000
+ // `host` is the open modal dialog the copy is made from, if any: under a
16001
+ // modal everything outside it is inert, a textarea on the body included.
16002
+ function copyText(text, host) {
15908
16003
  let legacy = function () {
15909
16004
  let ta = document.createElement('textarea');
15910
16005
  ta.value = text;
15911
16006
  ta.setAttribute('readonly', '');
15912
16007
  ta.style.position = 'fixed';
15913
16008
  ta.style.left = '-10000px';
15914
- document.body.appendChild(ta);
16009
+ (host || document.body).appendChild(ta);
15915
16010
  ta.select();
15916
16011
  let ok = false;
15917
16012
  try {
@@ -16817,6 +16912,11 @@ function (root, d3, forester, phyloXml) {
16817
16912
  + '.aptx-dialog-close:hover { background:var(--p-accent-weak); color:var(--p-accent-ink);'
16818
16913
  + ' border-color:var(--p-accent); }'
16819
16914
  + '.aptx-dialog-body { padding:10px 13px 12px; overflow:auto; }'
16915
+ // a dialog that scrolls gets the panel's own scrollbar: the
16916
+ // browser's default track is white in the dark theme too
16917
+ + '.aptx-dialog-body::-webkit-scrollbar { width:9px; height:9px; }'
16918
+ + '.aptx-dialog-body::-webkit-scrollbar-thumb { background:var(--p-line-strong); border-radius:9px; border:2px solid var(--p-bg); }'
16919
+ + '.aptx-dialog-body::-webkit-scrollbar-track, .aptx-dialog-body::-webkit-scrollbar-corner { background:transparent; }'
16820
16920
  + '.aptx-cp-sv { position:relative; width:100%; box-sizing:border-box; height:104px; border-radius:7px;'
16821
16921
  + ' border:1px solid var(--p-line-strong); cursor:crosshair; touch-action:none; }'
16822
16922
  + '.aptx-cp-hue { position:relative; width:100%; box-sizing:border-box; height:13px; margin-top:9px;'
@@ -16974,6 +17074,23 @@ function (root, d3, forester, phyloXml) {
16974
17074
  + '.aptx-panel .' + PROGNAMELINK + ' { background:none; padding:0; margin:0; cursor:pointer;'
16975
17075
  + ' font-family:inherit; font-weight:inherit; letter-spacing:inherit; }'
16976
17076
  + '.aptx-panel .' + PROGNAMELINK + ':hover { text-decoration:underline; }'
17077
+ + '.aptx-doctor-dot { display:inline-block; width:6px; height:6px; border-radius:50%; background:var(--p-muted); vertical-align:middle; }'
17078
+ + '.aptx-doctor-dot[hidden] { display:none; }'
17079
+ + '.aptx-panel .' + PROGNAMELINK + ' .aptx-doctor-dot { margin-left:6px; }'
17080
+ + '.aptx-about .aptx-doctor-dot { margin-right:6px; }'
17081
+ + '.aptx-dialog-body.aptx-doctor { max-height:min(70vh,560px); }'
17082
+ + '.aptx-doctor .aptx-dialog-key { flex-basis:34%; }'
17083
+ + '.aptx-doctor-head { display:flex; align-items:center; gap:10px; margin-bottom:6px; }'
17084
+ + '.aptx-doctor-status { flex:1 1 auto; min-width:0; font-weight:650; }'
17085
+ + '.aptx-doctor-status.aptx-doctor-bad { box-shadow:inset 3px 0 0 var(--p-warn); padding-left:9px; }'
17086
+ + '.aptx-doctor-item { position:relative; padding:2px 0 2px 14px; line-height:1.35; overflow-wrap:anywhere; }'
17087
+ + '.aptx-doctor-item::before { content:""; position:absolute; left:2px; top:0.62em; width:6px; height:6px; border-radius:50%; background:var(--p-faint); }'
17088
+ + '.aptx-doctor-item.aptx-doctor-bad::before { background:var(--p-warn); }'
17089
+ + '.aptx-doctor-item.aptx-doctor-note { color:var(--p-muted); }'
17090
+ + '.aptx-doctor-section { margin:11px 0 3px; font-size:10.5px; font-weight:650; letter-spacing:0.05em; text-transform:uppercase; color:var(--p-muted); }'
17091
+ + '.aptx-doctor-detail { display:block; color:var(--p-muted); line-height:1.35; }'
17092
+ + '.aptx-doctor .aptx-dialog-line.aptx-doctor-bad .aptx-doctor-value { color:var(--p-warn); font-weight:600; }'
17093
+ + '.aptx-doctor-copy { flex:none; }'
16977
17094
  + '.aptx-dialog-body.aptx-about { padding-top:13px; }'
16978
17095
  // the label column is narrower here than for node data: these values
16979
17096
  // are URLs, and they should not have to wrap mid-word
@@ -17255,6 +17372,16 @@ function (root, d3, forester, phyloXml) {
17255
17372
  e.stopPropagation();
17256
17373
  showAboutDialog();
17257
17374
  });
17375
+ // config warnings are recorded before this header exists
17376
+ syncDoctorDot();
17377
+ // ... and a library can arrive after it (see syncDoctorDot)
17378
+ let panel = header.closest('.aptx-panel');
17379
+ if (panel) {
17380
+ panel.addEventListener('pointerenter', syncDoctorDot);
17381
+ }
17382
+ if (document.readyState !== 'complete') {
17383
+ window.addEventListener('load', syncDoctorDot, {once: true});
17384
+ }
17258
17385
 
17259
17386
  let themeBtn = document.createElement('button');
17260
17387
  themeBtn.type = 'button';
@@ -18088,10 +18215,693 @@ function (root, d3, forester, phyloXml) {
18088
18215
  shell.dialog.showModal();
18089
18216
  }
18090
18217
 
18218
+ // ===================== Diagnostics =====================
18219
+ // "Dependencies are checked when used, never at load time" makes the
18220
+ // viewer hard to break and easy to run half-equipped without knowing: on
18221
+ // BV-BRC it ran for weeks with no PDF or PNG export, a retired config key
18222
+ // and a forced visualization, and every one of those was found by reading
18223
+ // the site's source (Christian, 2026-10-09: a "check engine light" and a
18224
+ // doctor behind it). This is the one place that says what is loaded, what
18225
+ // that switches off, and what the launch complained about.
18226
+ //
18227
+ // Two rules decide what is a PROBLEM (lights the dot) and what is only
18228
+ // stated:
18229
+ // * an optional library that is simply absent is a site's choice, and
18230
+ // is stated ("PDF export: off"), never flagged;
18231
+ // * something nobody chooses is a problem: half of a pair (jspdf
18232
+ // without svg2pdf.js), a library from the 2.x days under its old
18233
+ // global, a d3 that is not 7, forester.js from another release, a
18234
+ // phyloxml that no longer writes what this release expects, and every
18235
+ // warning or error the launch printed about its config.
18236
+ // Only d3, jsPDF and forester state a version, so everything else is
18237
+ // judged by what it can do.
18238
+ //
18239
+ // Nothing in here may throw: every check runs in its own try, and a check
18240
+ // that fails says so in its own row.
18241
+
18242
+ // phyloxml states no version, so a small tree goes through it and what
18243
+ // comes back is read for what an OLDER release is known to do. Each of
18244
+ // these is a named fault, and each published release before 1.1.7 shows
18245
+ // at least one (measured on 1.0.2, 1.1.3, 1.1.4, 1.1.5 and 1.1.6):
18246
+ // * it drops the domain architecture, or a <phylogeny>'s own property
18247
+ // (before 1.1.5 / 1.1.3): a saved tree LOSES data;
18248
+ // * it writes a style colour as it came, or the style property before
18249
+ // the node's other properties (before 1.1.7): the saved file differs
18250
+ // from the desktop program's.
18251
+ // Whether the output is byte for byte what 1.1.7 writes is looked at too,
18252
+ // but a difference there alone is only a NOTE. package.json admits any
18253
+ // 1.1.x from 1.1.7 up, a release of this viewer cannot be corrected
18254
+ // after it is published, and a newer phyloxml that lays a file out
18255
+ // differently must not light a dot on every page that has it. (A first
18256
+ // version demanded the bytes and called every difference "an older
18257
+ // copy".)
18258
+ const PHYLOXML_PROBE_IN = '<phyloxml xmlns="http://www.phyloxml.org"><phylogeny rooted="true"><clade>'
18259
+ + '<clade><name>a</name><branch_length>1</branch_length>'
18260
+ + '<sequence><domain_architecture length="9"><domain from="1" to="5" confidence="0.1">D</domain>'
18261
+ + '</domain_architecture></sequence>'
18262
+ + '<property ref="style:font_color" datatype="xsd:token" applies_to="node">#CE1616</property>'
18263
+ + '<property ref="x:y" datatype="xsd:string" applies_to="node">v</property></clade>'
18264
+ + '<clade><name>b</name><branch_length>2</branch_length></clade></clade>'
18265
+ + '<property ref="p:q" datatype="xsd:string" applies_to="phylogeny">w</property></phylogeny></phyloxml>';
18266
+ const PHYLOXML_PROBE_OUT = ['<?xml version="1.0" encoding="UTF-8"?>',
18267
+ '<phyloxml xmlns="http://www.phyloxml.org">',
18268
+ '<phylogeny rooted="true" rerootable="true">',
18269
+ ' <clade>',
18270
+ ' <clade>',
18271
+ ' <name>a</name>',
18272
+ ' <branch_length>1</branch_length>',
18273
+ ' <sequence>',
18274
+ ' <domain_architecture length="9">',
18275
+ ' <domain from="1" to="5" confidence="0.1">D</domain>',
18276
+ ' </domain_architecture>',
18277
+ ' </sequence>',
18278
+ ' <property ref="x:y" datatype="xsd:string" applies_to="node">v</property>',
18279
+ ' <property ref="style:font_color" datatype="xsd:token" applies_to="node">#ce1616</property>',
18280
+ ' </clade>',
18281
+ ' <clade>',
18282
+ ' <name>b</name>',
18283
+ ' <branch_length>2</branch_length>',
18284
+ ' </clade>',
18285
+ ' </clade>',
18286
+ ' <property ref="p:q" datatype="xsd:string" applies_to="phylogeny">w</property>',
18287
+ '</phylogeny>',
18288
+ '</phyloxml>'].join('\n');
18289
+ const PHYLOXML_EXPECTED = '1.1.7';
18290
+ const D3_TESTED_MINOR = 9; // package.json: d3 ^7.9.0
18291
+ const JSPDF_TESTED_MAJOR = 4; // package.json: jspdf ^4.2.1
18292
+
18293
+ // The probe's answer for the library object it was asked about: the
18294
+ // dot asks often, and the answer cannot change.
18295
+ // threw / stage reading ('read') or writing ('write') failed, with the message
18296
+ // lost what a saved tree no longer has
18297
+ // old what is written as releases before 1.1.7 wrote it
18298
+ // same the output is byte for byte 1.1.7's
18299
+ let _phyloXmlProbe = null;
18300
+ function probePhyloXml(px) {
18301
+ if (_phyloXmlProbe && _phyloXmlProbe.lib === px) {
18302
+ return _phyloXmlProbe.result;
18303
+ }
18304
+ let result = {threw: null, stage: null, lost: [], old: [], same: false};
18305
+ let message = function (e) {
18306
+ return String(e && e.message ? e.message : e);
18307
+ };
18308
+ let tree = null;
18309
+ try {
18310
+ tree = px.parse(PHYLOXML_PROBE_IN, {trim: true, normalize: true})[0];
18311
+ } catch (e) {
18312
+ result.threw = message(e);
18313
+ result.stage = 'read';
18314
+ }
18315
+ let written = null;
18316
+ if (!result.threw) {
18317
+ try {
18318
+ written = String(px.toPhyloXML(tree, 9));
18319
+ } catch (e) {
18320
+ result.threw = message(e);
18321
+ result.stage = 'write';
18322
+ }
18323
+ }
18324
+ if (written !== null) {
18325
+ result.same = written === PHYLOXML_PROBE_OUT;
18326
+ if (!/<domain from="1" to="5"[^>]*>D<\/domain>/.test(written)) {
18327
+ result.lost.push('protein domains');
18328
+ }
18329
+ if (written.indexOf('ref="p:q"') < 0) {
18330
+ result.lost.push('a tree\'s own properties');
18331
+ }
18332
+ if (written.indexOf('ref="x:y"') < 0) {
18333
+ result.lost.push('node properties');
18334
+ }
18335
+ let style = written.indexOf('ref="style:font_color"');
18336
+ if (style < 0) {
18337
+ result.lost.push('node styles');
18338
+ } else {
18339
+ if (written.indexOf('>#ce1616<') < 0) {
18340
+ result.old.push('a style colour as it came, not in lower case');
18341
+ }
18342
+ if (written.indexOf('ref="x:y"') > style) {
18343
+ result.old.push('a node\'s style before its other properties');
18344
+ }
18345
+ }
18346
+ }
18347
+ _phyloXmlProbe = {lib: px, result: result};
18348
+ return result;
18349
+ }
18350
+
18351
+ // One library's row. `judge` returns {loaded, version, status, detail};
18352
+ // status is 'ok', 'absent' (optional and not there: stated), 'note' or
18353
+ // 'problem'.
18354
+ function doctorLibrary(name, role, judge) {
18355
+ let row = {name: name, role: role, loaded: false, version: null, status: 'problem', detail: ''};
18356
+ try {
18357
+ let j = judge();
18358
+ row.loaded = !!j.loaded;
18359
+ row.version = j.version || null;
18360
+ row.status = j.status;
18361
+ row.detail = j.detail || '';
18362
+ } catch (e) {
18363
+ row.detail = 'this check failed: ' + (e && e.message ? e.message : e);
18364
+ }
18365
+ return row;
18366
+ }
18367
+
18368
+ function doctorLibraries() {
18369
+ let g = root || {};
18370
+ // without a document there is nothing to launch into: the parsers
18371
+ // work with no d3 at all, so its absence there is not a fault
18372
+ let inBrowser = typeof g.document !== 'undefined';
18373
+ let libs = [];
18374
+
18375
+ libs.push(doctorLibrary('forester.js', 'required', function () {
18376
+ let f = forester || g.forester;
18377
+ if (!f) {
18378
+ return {loaded: false, status: 'problem', detail: 'not loaded: nothing can be read or drawn'};
18379
+ }
18380
+ if (f.VERSION === VERSION) {
18381
+ return {loaded: true, version: f.VERSION, status: 'ok'};
18382
+ }
18383
+ return {loaded: true, version: f.VERSION || null, status: 'problem',
18384
+ detail: (f.VERSION ? 'forester.js is ' + f.VERSION : 'forester.js states no version, so it is an older file')
18385
+ + ', archaeopteryx.js is ' + VERSION + ': the two must come from the same release'};
18386
+ }));
18387
+
18388
+ libs.push(doctorLibrary('d3', 'required', function () {
18389
+ // THE d3 THE VIEWER USES, chosen as requireD3 chooses it: the one
18390
+ // it was handed at load, and the page's global only where that
18391
+ // one is missing or unusable. (A first version looked at the
18392
+ // global first and could report on a d3 nothing was drawn with.)
18393
+ let d = (g.d3 && (!d3 || typeof d3.zoom !== 'function')) ? g.d3 : d3;
18394
+ if (!d) {
18395
+ return inBrowser
18396
+ ? {loaded: false, status: 'problem', detail: 'not loaded: launch() fails without d3 version 7'}
18397
+ : {loaded: false, status: 'absent', detail: 'not loaded: only launch() needs it'};
18398
+ }
18399
+ let usable = typeof d.zoom === 'function' && typeof d.cluster === 'function';
18400
+ let version = typeof d.version === 'string' ? d.version : null;
18401
+ if (version === null) {
18402
+ // d3's ES-module build exports no `version`: a bundler's
18403
+ // `import * as d3`, jsdelivr's +esm, Node's require('d3'). It
18404
+ // is judged by what only d3 7 has -- InternMap arrived with
18405
+ // d3-array 3, which is d3 7.0.0 -- and never called outdated
18406
+ // for not saying its number.
18407
+ if (usable && typeof d.InternMap === 'function') {
18408
+ return {loaded: true, status: 'ok', detail: 'states no version (an ES-module build); has what d3 7 has'};
18409
+ }
18410
+ return {loaded: true, status: inBrowser ? 'problem' : 'absent',
18411
+ detail: inBrowser ? 'states no version and lacks what d3 7 has: Archaeopteryx.js needs d3 version 7'
18412
+ : 'not usable here as d3 version 7: only launch() needs it'};
18413
+ }
18414
+ let parts = version.split('.').map(Number);
18415
+ if (parts[0] !== 7 || !usable) {
18416
+ return {loaded: true, version: version, status: inBrowser ? 'problem' : 'absent',
18417
+ detail: inBrowser ? 'Archaeopteryx.js needs d3 version 7'
18418
+ : 'not usable here as d3 version 7: only launch() needs it'};
18419
+ }
18420
+ if (parts[1] < D3_TESTED_MINOR) {
18421
+ return {loaded: true, version: version, status: 'note',
18422
+ detail: 'older than the 7.' + D3_TESTED_MINOR + ' this release is tested with'};
18423
+ }
18424
+ return {loaded: true, version: version, status: 'ok'};
18425
+ }));
18426
+
18427
+ let px = null;
18428
+ let probe = null;
18429
+ libs.push(doctorLibrary('phyloxml', 'required', function () {
18430
+ px = phyloXml || g.phyloXml;
18431
+ probe = px ? probePhyloXml(px) : null;
18432
+ if (!px) {
18433
+ return {loaded: false, status: 'problem',
18434
+ detail: 'not loaded: phyloXML cannot be read, and the phyloXML download fails'};
18435
+ }
18436
+ if (probe.threw) {
18437
+ // on a page phyloxml takes sax from the global, and a missing
18438
+ // one is the usual reason reading fails; anywhere else, and
18439
+ // for any other failure, the message itself is the finding
18440
+ return {loaded: true, status: 'problem', detail: (probe.stage === 'read' && inBrowser && !g.sax)
18441
+ ? 'cannot read phyloXML: sax.js is not loaded'
18442
+ : (probe.stage === 'read' ? 'reading' : 'writing') + ' a small tree failed: ' + probe.threw};
18443
+ }
18444
+ if (probe.lost.length > 0) {
18445
+ return {loaded: true, status: 'problem', detail: 'an older copy: saving a tree as phyloXML drops '
18446
+ + probe.lost.join(', ') + ' (phyloxml ' + PHYLOXML_EXPECTED + ' keeps them)'};
18447
+ }
18448
+ if (probe.old.length > 0) {
18449
+ return {loaded: true, status: 'problem', detail: 'an older copy: it writes ' + probe.old.join(', and ')
18450
+ + ', so a saved tree differs from the desktop program\'s file (phyloxml ' + PHYLOXML_EXPECTED
18451
+ + ' does not)'};
18452
+ }
18453
+ if (!probe.same) {
18454
+ return {loaded: true, status: 'note', detail: 'writes a small tree differently from phyloxml '
18455
+ + PHYLOXML_EXPECTED + ', losing nothing: a newer release, or an altered copy'};
18456
+ }
18457
+ return {loaded: true, status: 'ok', detail: 'writes phyloXML as ' + PHYLOXML_EXPECTED + ' does'};
18458
+ }));
18459
+ libs.push(doctorLibrary('sax', 'required', function () {
18460
+ // A page hands sax over as a global. Anywhere else (Node, a
18461
+ // bundler) phyloxml gets it by require() when it loads, so a
18462
+ // phyloxml that loaded has it, and a tree it read proves it.
18463
+ if (g.sax || (probe && probe.stage !== 'read')) {
18464
+ return {loaded: true, status: 'ok'};
18465
+ }
18466
+ if (!inBrowser && px) {
18467
+ return {loaded: true, status: 'ok', detail: 'taken by phyloxml when it loaded'};
18468
+ }
18469
+ return {loaded: false, status: 'problem', detail: 'not loaded: phyloxml needs it to read phyloXML'};
18470
+ }));
18471
+
18472
+ libs.push(doctorLibrary('canvg', 'optional', function () {
18473
+ if (g.Canvg && typeof g.Canvg.fromString === 'function') {
18474
+ return {loaded: true, status: 'ok'};
18475
+ }
18476
+ if (g.Canvg) {
18477
+ return {loaded: true, status: 'problem',
18478
+ detail: 'window.Canvg has no fromString: not canvg 4, so PNG export is off'};
18479
+ }
18480
+ if (g.canvg) {
18481
+ return {loaded: true, status: 'problem', detail: 'loaded under its old global "canvg" (canvg 1 to 3);'
18482
+ + ' PNG export needs canvg 4 as window.Canvg, so it is off'};
18483
+ }
18484
+ return {loaded: false, status: 'absent', detail: 'not loaded: PNG export and Copy PNG are off'};
18485
+ }));
18486
+
18487
+ // The pair is read inside each row's own guard: whatever a page has
18488
+ // put under these names, a lookup that throws costs its row and no
18489
+ // other.
18490
+ let pdfPair = function () {
18491
+ let J = g.jspdf && g.jspdf.jsPDF;
18492
+ return {J: J, attached: !!(J && J.API && J.API.svg), svg2pdf: !!g.svg2pdf};
18493
+ };
18494
+ libs.push(doctorLibrary('jspdf', 'optional', function () {
18495
+ let pair = pdfPair();
18496
+ let J = pair.J;
18497
+ let attached = pair.attached;
18498
+ if (J) {
18499
+ let version = typeof J.version === 'string' ? J.version : null;
18500
+ if (!attached && !g.svg2pdf) {
18501
+ return {loaded: true, version: version, status: 'problem',
18502
+ detail: 'loaded without svg2pdf.js: PDF export needs both, so it is off'};
18503
+ }
18504
+ if (version && Number(version.split('.')[0]) !== JSPDF_TESTED_MAJOR) {
18505
+ return {loaded: true, version: version, status: 'note',
18506
+ detail: 'this release is tested with jspdf ' + JSPDF_TESTED_MAJOR};
18507
+ }
18508
+ return {loaded: true, version: version, status: 'ok'};
18509
+ }
18510
+ if (g.jsPDF) {
18511
+ return {loaded: true, status: 'problem', detail: 'loaded under its old global "jsPDF" (jsPDF 1);'
18512
+ + ' PDF export needs jspdf ' + JSPDF_TESTED_MAJOR + ' as window.jspdf, so it is off'};
18513
+ }
18514
+ return {loaded: false, status: 'absent', detail: 'not loaded: PDF export is off'};
18515
+ }));
18516
+ libs.push(doctorLibrary('svg2pdf.js', 'optional', function () {
18517
+ let pair = pdfPair();
18518
+ let J = pair.J;
18519
+ let attached = pair.attached;
18520
+ if (attached) {
18521
+ return {loaded: true, status: 'ok'};
18522
+ }
18523
+ if (g.svg2pdf && J) {
18524
+ return {loaded: true, status: 'problem', detail: 'loaded, but not attached to jspdf (jsPDF.API.svg'
18525
+ + ' is missing): load jspdf first. PDF export is off'};
18526
+ }
18527
+ if (g.svg2pdf) {
18528
+ return {loaded: true, status: 'problem',
18529
+ detail: 'loaded without jspdf: PDF export needs both, so it is off'};
18530
+ }
18531
+ return {loaded: false, status: 'absent',
18532
+ detail: J ? 'not loaded' : 'not loaded: PDF export is off'};
18533
+ }));
18534
+ return libs;
18535
+ }
18536
+
18537
+ // What the user gets or does not get, each with the reason.
18538
+ function doctorFunctions(libs) {
18539
+ let lib = function (name) {
18540
+ return libs.find(function (l) {
18541
+ return l.name === name;
18542
+ });
18543
+ };
18544
+ let inBrowser = typeof window !== 'undefined' && typeof document !== 'undefined';
18545
+ let fn = function (name, test, whyNot) {
18546
+ let available = false;
18547
+ let detail = '';
18548
+ try {
18549
+ available = inBrowser && !!test();
18550
+ detail = available ? '' : (inBrowser ? whyNot() : 'needs a browser');
18551
+ } catch (e) {
18552
+ detail = 'this check failed: ' + (e && e.message ? e.message : e);
18553
+ }
18554
+ return {name: name, available: available, detail: detail};
18555
+ };
18556
+ // why a function is off, in the library's name: its row's reason
18557
+ // reads "not loaded: ..." under its own name and needs the subject here
18558
+ let why = function (l) {
18559
+ return l.status === 'absent' ? l.name + ' is not loaded' : l.name + ': ' + l.detail;
18560
+ };
18561
+ let out = [];
18562
+ try {
18563
+ // ON whenever phyloxml reads and writes at all -- which is what
18564
+ // the Download menu's phyloXML entry does, and it is always
18565
+ // offered. An older copy still works: its row above says what a
18566
+ // saved file loses, and this row repeats it without turning off.
18567
+ let px = lib('phyloxml');
18568
+ let works = px.loaded && !/(cannot read phyloXML|a small tree failed|this check failed)/.test(px.detail);
18569
+ out.push({name: 'Read and save phyloXML', available: works,
18570
+ detail: px.status === 'ok' ? '' : (works ? px.detail : why(px))});
18571
+ } catch (e) {
18572
+ out.push({name: 'Read and save phyloXML', available: false,
18573
+ detail: 'this check failed: ' + (e && e.message ? e.message : e)});
18574
+ }
18575
+ out.push(fn('PNG export', pngExportAvailable, function () {
18576
+ return why(lib('canvg'));
18577
+ }));
18578
+ out.push(fn('Copy PNG', pngCopyAvailable, function () {
18579
+ if (!pngExportAvailable()) {
18580
+ return why(lib('canvg'));
18581
+ }
18582
+ return window.isSecureContext ? 'this browser cannot write an image to the clipboard'
18583
+ : 'needs a secure page (https, or localhost)';
18584
+ }));
18585
+ out.push(fn('PDF export', pdfExportAvailable, function () {
18586
+ let pair = [lib('jspdf'), lib('svg2pdf.js')];
18587
+ let bad = pair.find(function (l) {
18588
+ return l.status === 'problem';
18589
+ });
18590
+ if (bad) {
18591
+ return why(bad);
18592
+ }
18593
+ return pair.every(function (l) {
18594
+ return l.status === 'absent';
18595
+ }) ? 'jspdf and svg2pdf.js are not loaded' : why(pair.find(function (l) {
18596
+ return l.status !== 'ok';
18597
+ }) || pair[0]);
18598
+ }));
18599
+ return out;
18600
+ }
18601
+
18602
+ // A config value as one short word or number: enough to see what a site
18603
+ // passed, never the value itself where that could be a tree or a callback.
18604
+ function doctorConfigValue(v) {
18605
+ if (v === null || typeof v === 'boolean' || typeof v === 'number') {
18606
+ return v;
18607
+ }
18608
+ if (typeof v === 'string') {
18609
+ return v.length > 80 ? v.substring(0, 77) + '...' : v;
18610
+ }
18611
+ if (typeof v === 'function') {
18612
+ return '(function)';
18613
+ }
18614
+ if (Array.isArray(v)) {
18615
+ return '(list of ' + v.length + ')';
18616
+ }
18617
+ return '(' + typeof v + ')';
18618
+ }
18619
+
18620
+ // The tree on view, as noted when its launch took the page over
18621
+ // (_onView): null until one has, so a first launch that was rejected
18622
+ // describes no tree. The tip count is the one the launch computed.
18623
+ function doctorLaunch() {
18624
+ if (!_onView) {
18625
+ return null;
18626
+ }
18627
+ let launch = {config: {}, tips: null, tree: _onView.tree, trees: _onView.trees, container: null};
18628
+ try {
18629
+ Object.keys(_onView.config || {}).sort().forEach(function (k) {
18630
+ launch.config[k] = doctorConfigValue(_onView.config[k]);
18631
+ });
18632
+ } catch {
18633
+ launch.config = {};
18634
+ }
18635
+ launch.tips = (typeof _onView.tips === 'number') ? _onView.tips : null;
18636
+ try {
18637
+ launch.container = {width: _onView.container.clientWidth, height: _onView.container.clientHeight};
18638
+ } catch {
18639
+ launch.container = null;
18640
+ }
18641
+ return launch;
18642
+ }
18643
+
18644
+ function doctorText(rep) {
18645
+ let pad = function (text, n) {
18646
+ text = String(text);
18647
+ return text + ' '.repeat(Math.max(1, n - text.length));
18648
+ };
18649
+ let lines = [NAME + ' ' + rep.version + ' diagnostics', ''];
18650
+ lines.push('Status: ' + rep.summary);
18651
+ rep.problems.forEach(function (p) {
18652
+ lines.push(' ! ' + p);
18653
+ });
18654
+ lines.push('', 'Libraries');
18655
+ rep.libraries.forEach(function (l) {
18656
+ lines.push(' ' + pad(l.name, 13) + pad(l.version || (l.loaded ? 'loaded' : 'not loaded'), 12)
18657
+ + pad(l.status === 'absent' ? l.role : l.status, 9) + l.detail);
18658
+ });
18659
+ lines.push('', 'Functions');
18660
+ rep.functions.forEach(function (f) {
18661
+ lines.push(' ' + pad(f.name, 24) + (f.available ? 'on' : 'off') + (f.detail ? ' ' + f.detail : ''));
18662
+ });
18663
+ if (rep.notes.length > 0) {
18664
+ lines.push('', 'Notes');
18665
+ rep.notes.forEach(function (n) {
18666
+ lines.push(' - ' + n);
18667
+ });
18668
+ }
18669
+ lines.push('', 'Launch');
18670
+ if (rep.launch) {
18671
+ lines.push(' config: ' + JSON.stringify(rep.launch.config));
18672
+ if (rep.launch.tips !== null) {
18673
+ lines.push(' tree: ' + rep.launch.tips + ' tips' + (rep.launch.trees > 1
18674
+ ? ' (tree ' + rep.launch.tree + ' of ' + rep.launch.trees + ')' : ''));
18675
+ }
18676
+ if (rep.launch.container) {
18677
+ lines.push(' container: ' + rep.launch.container.width + ' x ' + rep.launch.container.height + ' px');
18678
+ }
18679
+ } else {
18680
+ lines.push(' no tree is on view');
18681
+ }
18682
+ lines.push('', 'Environment');
18683
+ lines.push(' ' + (rep.environment.userAgent || 'no browser'));
18684
+ if (rep.environment.secureContext !== null) {
18685
+ lines.push(' secure page: ' + (rep.environment.secureContext ? 'yes' : 'no'));
18686
+ }
18687
+ return lines.join('\n');
18688
+ }
18689
+
18690
+ // The problems and the notes: the libraries' and what the launch said.
18691
+ // This is all the dot needs, so it is kept apart from the report, which
18692
+ // also works out the functions, the launch section and the text.
18693
+ function doctorFindings() {
18694
+ let libs;
18695
+ try {
18696
+ libs = doctorLibraries();
18697
+ } catch (e) {
18698
+ libs = [{name: 'libraries', role: 'required', loaded: false, version: null, status: 'problem',
18699
+ detail: 'the checks failed: ' + (e && e.message ? e.message : e)}];
18700
+ }
18701
+ let problems = [];
18702
+ let notes = [];
18703
+ libs.forEach(function (l) {
18704
+ if (l.status === 'problem') {
18705
+ problems.push(l.name + ': ' + l.detail);
18706
+ } else if (l.status === 'note') {
18707
+ notes.push(l.name + (l.version ? ' ' + l.version : '') + ': ' + l.detail);
18708
+ }
18709
+ });
18710
+ _diagnostics.forEach(function (d) {
18711
+ (d.level === 'problem' ? problems : notes).push(d.text + (d.count > 1 ? ' (' + d.count + ' times)' : ''));
18712
+ });
18713
+ return {libraries: libs, problems: problems, notes: notes,
18714
+ summary: problems.length === 0 ? 'nothing to report'
18715
+ : problems.length + (problems.length === 1 ? ' thing' : ' things') + ' to check'};
18716
+ }
18717
+
18718
+ function doctorReport() {
18719
+ let found = doctorFindings();
18720
+ let libs = found.libraries;
18721
+ let problems = found.problems;
18722
+ let notes = found.notes;
18723
+ let functions;
18724
+ try {
18725
+ functions = doctorFunctions(libs);
18726
+ } catch {
18727
+ functions = [];
18728
+ }
18729
+ let launch = null;
18730
+ try {
18731
+ launch = doctorLaunch();
18732
+ } catch {
18733
+ launch = null;
18734
+ }
18735
+ let nav = (typeof navigator !== 'undefined') ? navigator : null;
18736
+ let rep = {
18737
+ program: NAME,
18738
+ version: VERSION,
18739
+ ok: problems.length === 0,
18740
+ summary: found.summary,
18741
+ problems: problems,
18742
+ notes: notes,
18743
+ libraries: libs,
18744
+ functions: functions,
18745
+ launch: launch,
18746
+ environment: {
18747
+ userAgent: nav && nav.userAgent ? nav.userAgent : null,
18748
+ secureContext: (typeof window !== 'undefined' && typeof window.isSecureContext === 'boolean')
18749
+ ? window.isSecureContext : null
18750
+ }
18751
+ };
18752
+ try {
18753
+ rep.text = doctorText(rep);
18754
+ } catch (e) {
18755
+ rep.text = NAME + ' ' + VERSION + ' diagnostics: the report could not be written ('
18756
+ + (e && e.message ? e.message : e) + ')';
18757
+ }
18758
+ return rep;
18759
+ }
18760
+
18761
+ /**
18762
+ * The state of the program on this page: which libraries are loaded (and
18763
+ * their versions, where a library states one), which functions that
18764
+ * switches off, and what the last launch() warned about. Works before a
18765
+ * launch and after a failed one, and in Node. Never throws.
18766
+ *
18767
+ * @returns {{program: string, version: string, ok: boolean, summary: string,
18768
+ * problems: string[], notes: string[], libraries: Object[], functions: Object[],
18769
+ * launch: Object|null, environment: Object, text: string}} `ok` is false when
18770
+ * something is wrong that nobody chooses (see README "Diagnostics");
18771
+ * `text` is the whole report as plain text, for pasting into an issue.
18772
+ */
18773
+ archaeopteryx.doctor = function () {
18774
+ let rep = doctorReport();
18775
+ syncDoctorDot(); // whoever asks sees a dot that agrees with the answer
18776
+ return rep;
18777
+ };
18778
+
18779
+ // The "check engine light": a small dot after the program name in the
18780
+ // panel header, there only while the report has a problem. Neutral, not
18781
+ // a warning colour: the people who see it are mostly the site's users,
18782
+ // who can do nothing about it, and it is for the one who can.
18783
+ //
18784
+ // What a page has loaded can change under a viewer that is already up (a
18785
+ // library arriving late, a script replacing a global) and nothing
18786
+ // announces it, so the dot is brought up to date whenever it could be
18787
+ // looked at or asked about: when a problem is recorded, when the header
18788
+ // is built, when the page finishes loading, when the pointer enters the
18789
+ // panel, when the About box opens and when doctor() is called.
18790
+ function syncDoctorDot() {
18791
+ try {
18792
+ let host = _onView && _onView.container;
18793
+ if (!host || typeof host.querySelector !== 'function') {
18794
+ return;
18795
+ }
18796
+ let button = host.querySelector('.aptx-panel .' + PROGNAMELINK);
18797
+ let dot = button && button.querySelector('.aptx-doctor-dot');
18798
+ if (!dot) {
18799
+ return;
18800
+ }
18801
+ let found = doctorFindings();
18802
+ let ok = found.problems.length === 0;
18803
+ dot.hidden = ok;
18804
+ button.title = 'About ' + NAME + (ok ? '' : ': ' + found.summary);
18805
+ } catch {
18806
+ // a light that cannot be lit must not take the viewer with it
18807
+ }
18808
+ }
18809
+
18810
+ function showDoctorDialog() {
18811
+ let rep = doctorReport();
18812
+ let shell = makeDialogShell(DOCTOR_DIALOG, 'Diagnostics', 500);
18813
+ shell.body.classList.add('aptx-doctor');
18814
+
18815
+ // The summary and Copy report share the first line: the report is
18816
+ // longer than a small window, and the one button must not be the
18817
+ // thing scrolled out of sight.
18818
+ let head = document.createElement('div');
18819
+ head.className = 'aptx-doctor-head';
18820
+ let status = document.createElement('div');
18821
+ status.className = 'aptx-doctor-status' + (rep.ok ? '' : ' aptx-doctor-bad');
18822
+ status.textContent = NAME + ' ' + rep.version + ': ' + rep.summary;
18823
+ let copy = document.createElement('button');
18824
+ copy.type = 'button';
18825
+ copy.className = 'aptx-reps-button aptx-doctor-copy';
18826
+ copy.textContent = 'Copy report';
18827
+ copy.title = 'copy this report as text, with the launch configuration and the browser added,'
18828
+ + ' to paste into an issue or an e-mail';
18829
+ copy.addEventListener('click', function () {
18830
+ copyText(rep.text, shell.dialog).then(function (ok) {
18831
+ flashButton(copy, ok ? 'Copied \u2713' : 'Copy failed');
18832
+ });
18833
+ });
18834
+ head.appendChild(status);
18835
+ head.appendChild(copy);
18836
+ shell.body.appendChild(head);
18837
+
18838
+ let list = function (items, cls) {
18839
+ items.forEach(function (text) {
18840
+ let item = document.createElement('div');
18841
+ item.className = 'aptx-doctor-item ' + cls;
18842
+ item.textContent = text; // may quote a config value: never markup
18843
+ shell.body.appendChild(item);
18844
+ });
18845
+ };
18846
+ let section = function (title) {
18847
+ let h = document.createElement('div');
18848
+ h.className = 'aptx-doctor-section';
18849
+ h.textContent = title;
18850
+ shell.body.appendChild(h);
18851
+ };
18852
+ let row = function (key, value, detail, bad) {
18853
+ let line = document.createElement('div');
18854
+ line.className = 'aptx-dialog-line' + (bad ? ' aptx-doctor-bad' : '');
18855
+ let k = document.createElement('span');
18856
+ k.className = 'aptx-dialog-key';
18857
+ k.textContent = key;
18858
+ let v = document.createElement('span');
18859
+ v.className = 'aptx-dialog-val';
18860
+ let main = document.createElement('span');
18861
+ main.className = 'aptx-doctor-value';
18862
+ main.textContent = value;
18863
+ v.appendChild(main);
18864
+ if (detail) {
18865
+ let d = document.createElement('span');
18866
+ d.className = 'aptx-doctor-detail';
18867
+ d.textContent = detail;
18868
+ v.appendChild(d);
18869
+ }
18870
+ line.appendChild(k);
18871
+ line.appendChild(v);
18872
+ shell.body.appendChild(line);
18873
+ };
18874
+ list(rep.problems, 'aptx-doctor-bad');
18875
+
18876
+ section('Libraries');
18877
+ rep.libraries.forEach(function (l) {
18878
+ // "not loaded" is the value: a reason that opens with the same
18879
+ // two words and a colon does not say them again. (Only that
18880
+ // exact opening: "loaded, but not attached..." keeps its start.)
18881
+ let value = l.version || (l.loaded ? 'loaded' : 'not loaded');
18882
+ let detail = l.detail === value ? ''
18883
+ : (l.detail.indexOf('not loaded: ') === 0 && value === 'not loaded' ? l.detail.substring(12) : l.detail);
18884
+ row(l.name, value, detail, l.status === 'problem');
18885
+ });
18886
+ section('Functions');
18887
+ rep.functions.forEach(function (f) {
18888
+ row(f.name, f.available ? 'on' : 'off', f.detail, false);
18889
+ });
18890
+ if (rep.notes.length > 0) {
18891
+ section('Notes');
18892
+ list(rep.notes, 'aptx-doctor-note');
18893
+ }
18894
+
18895
+ shell.dialog.showModal();
18896
+ }
18897
+
18091
18898
  // The About box behind the panel's title. Everything here is fixed text and
18092
18899
  // fixed URLs -- no tree data reaches it -- so the links are built as real
18093
- // elements rather than markup.
18900
+ // elements rather than markup. (The Diagnostics row's text is the doctor's
18901
+ // own summary: a count, never a value.)
18094
18902
  function showAboutDialog() {
18903
+ let rep = doctorReport();
18904
+ syncDoctorDot();
18095
18905
  let shell = makeDialogShell(ABOUT_DIALOG, 'About', 380);
18096
18906
  shell.body.classList.add('aptx-about');
18097
18907
 
@@ -18123,8 +18933,9 @@ function (root, d3, forester, phyloXml) {
18123
18933
  ['Desktop version', DESKTOP_WEBSITE, 'cmzmasek.github.io/archaeopteryx'],
18124
18934
  ['Source code', SOURCE_WEBSITE, 'github.com/cmzmasek/archaeopteryx-js'],
18125
18935
  ['License', LICENSE_WEBSITE, LICENSE_NAME],
18126
- ['Keyboard', null, 'shortcuts (' + (IS_MAC ? '\u2318 /' : 'Ctrl+/') + ')'],
18127
- ['Control panel', null, 'what each control does']].forEach(function (row) {
18936
+ ['Keyboard', null, 'shortcuts (' + (IS_MAC ? '\u2318 /' : 'Ctrl+/') + ')', showShortcutsDialog],
18937
+ ['Control panel', null, 'what each control does', showCheatSheetDialog],
18938
+ ['Diagnostics', null, rep.summary, showDoctorDialog]].forEach(function (row) {
18128
18939
  let line = document.createElement('div');
18129
18940
  line.className = 'aptx-dialog-line';
18130
18941
  let key = document.createElement('span');
@@ -18138,9 +18949,9 @@ function (root, d3, forester, phyloXml) {
18138
18949
  a.target = '_blank';
18139
18950
  a.rel = 'noopener noreferrer';
18140
18951
  } else {
18141
- // the two rows with no URL open another dialog in place of
18142
- // this one: the key list, and the control-panel sheet
18143
- let opener = row[0] === 'Control panel' ? showCheatSheetDialog : showShortcutsDialog;
18952
+ // the rows with no URL open another dialog in place of this
18953
+ // one: the key list, the control-panel sheet, the diagnostics
18954
+ let opener = row[3];
18144
18955
  a.href = '#';
18145
18956
  a.addEventListener('click', function (e) {
18146
18957
  e.preventDefault();
@@ -18149,6 +18960,14 @@ function (root, d3, forester, phyloXml) {
18149
18960
  });
18150
18961
  }
18151
18962
  a.textContent = row[2];
18963
+ if (row[0] === 'Diagnostics') {
18964
+ a.className = 'aptx-doctor-link';
18965
+ if (!rep.ok) {
18966
+ let dot = document.createElement('span');
18967
+ dot.className = 'aptx-doctor-dot';
18968
+ val.appendChild(dot);
18969
+ }
18970
+ }
18152
18971
  val.appendChild(a);
18153
18972
  line.appendChild(key);
18154
18973
  line.appendChild(val);
@@ -18720,7 +19539,8 @@ function (root, d3, forester, phyloXml) {
18720
19539
  // by this, and a version is a value -- it belongs in the About box
18721
19540
  // it opens, not in a row that says what the button is
18722
19541
  h = h.concat('<button type="button" class="' + PROGNAMELINK + '" aria-label="' + NAME
18723
- + '" title="About ' + NAME + '">' + NAME + ' ' + VERSION + '</button>');
19542
+ + '" title="About ' + NAME + '">' + NAME + ' ' + VERSION
19543
+ + '<span class="aptx-doctor-dot" hidden></span></button>');
18724
19544
  h = h.concat('</div>');
18725
19545
  return h;
18726
19546
  }
@@ -19397,7 +20217,7 @@ function (root, d3, forester, phyloXml) {
19397
20217
  // 2x, 4x and 8x, and the setting is now the one it starts on
19398
20218
  // (Christian, 2026-09-28) -- said, not done silently
19399
20219
  if (_pngScale !== _settings.pngExportScale) {
19400
- console.warn(WARNING + ': pngExportScale ' + _settings.pngExportScale + ' is not one of '
20220
+ say('problem', 'warn', WARNING + ': pngExportScale ' + _settings.pngExportScale + ' is not one of '
19401
20221
  + DOWNLOAD_PNG_SCALES.join(', ') + ': the PNG starts at ' + _pngScale + 'x');
19402
20222
  }
19403
20223
  setRadioButtonValue(_downloadFullSize ? DOWNLOAD_FULL_SIZE_BUTTON : DOWNLOAD_AS_SHOWN_BUTTON, true);
@@ -20139,7 +20959,7 @@ function (root, d3, forester, phyloXml) {
20139
20959
 
20140
20960
  function downloadAsPdf(graphic) {
20141
20961
  if (!pdfExportAvailable()) {
20142
- console.error(ERROR + 'PDF export needs the optional jspdf and svg2pdf.js libraries on the page');
20962
+ say('note', 'error', ERROR + 'PDF export needs the optional jspdf and svg2pdf.js libraries on the page');
20143
20963
  return;
20144
20964
  }
20145
20965
  let el = new DOMParser().parseFromString(graphic.text, 'image/svg+xml').documentElement;
@@ -20188,7 +21008,7 @@ function (root, d3, forester, phyloXml) {
20188
21008
  holder.remove();
20189
21009
  }, function (err) {
20190
21010
  holder.remove();
20191
- console.error(ERROR + 'PDF export failed: ' + err);
21011
+ say('note', 'error', ERROR + 'PDF export failed: ' + err);
20192
21012
  });
20193
21013
  }
20194
21014
 
@@ -20234,7 +21054,7 @@ function (root, d3, forester, phyloXml) {
20234
21054
  renderPng(graphic).then(function (blob) {
20235
21055
  saveAs(blob, downloadFileName(PNG_SUFFIX));
20236
21056
  }, function (err) {
20237
- console.error(ERROR + 'PNG export failed: ' + err);
21057
+ say('note', 'error', ERROR + 'PNG export failed: ' + err);
20238
21058
  });
20239
21059
  }
20240
21060
 
@@ -20252,7 +21072,7 @@ function (root, d3, forester, phyloXml) {
20252
21072
  return Promise.reject(new Error('this browser cannot paint a canvas of that size'));
20253
21073
  }
20254
21074
  if (target.scale < _pngScale) {
20255
- console.warn(WARNING + ': PNG exported at ' + target.scale.toFixed(2) + 'x rather than ' + _pngScale
21075
+ say('note', 'warn', WARNING + ': PNG exported at ' + target.scale.toFixed(2) + 'x rather than ' + _pngScale
20256
21076
  + 'x: the largest image this browser can paint');
20257
21077
  }
20258
21078
  let canvas = target.canvas;
@@ -20313,7 +21133,7 @@ function (root, d3, forester, phyloXml) {
20313
21133
  written.then(function () {
20314
21134
  flashButton(btn, 'Copied ✓');
20315
21135
  }, function (err) {
20316
- console.error(ERROR + 'copying the PNG failed: ' + err);
21136
+ say('note', 'error', ERROR + 'copying the PNG failed: ' + err);
20317
21137
  flashButton(btn, 'Copy failed');
20318
21138
  });
20319
21139
  }
@@ -20480,7 +21300,17 @@ function (root, d3, forester, phyloXml) {
20480
21300
  return undefined;
20481
21301
  }
20482
21302
 
20483
- archaeopteryx.launchArchaeopteryx = function (container, fileName, data, config) {
21303
+ archaeopteryx.launchArchaeopteryx = function () {
21304
+ try {
21305
+ return parseAndLaunch.apply(null, arguments);
21306
+ } catch (e) {
21307
+ // its own failures -- a fifth argument, a file that does not
21308
+ // parse -- happen before launch() and its shell are reached
21309
+ recordLaunchFailure(e);
21310
+ throw e;
21311
+ }
21312
+ };
21313
+ function parseAndLaunch(container, fileName, data, config) {
20484
21314
  if (arguments.length > 4) {
20485
21315
  throw new Error(ERROR + 'launchArchaeopteryx() takes exactly (container, fileName,'
20486
21316
  + ' data, config); the old trailing arguments were removed. The separate settings'
@@ -20508,7 +21338,7 @@ function (root, d3, forester, phyloXml) {
20508
21338
  // added nothing but a prefix, and swallowing them left the caller with
20509
21339
  // a blank page and no way to find out why.
20510
21340
  return archaeopteryx.launch(container, trees, config);
20511
- };
21341
+ }
20512
21342
 
20513
21343
 
20514
21344
  // --------------------------------------------------------------